From e4cc2e558efac974bb491c44b2bc2f2fe34cff51 Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 3 Sep 2026 18:45:36 +0530 Subject: [PATCH] cash-only registers, day book sections and DD amendments 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) --- cleanup_duplicate_vouchers.sql | 68 + ...se_migration_facility_charge_auto_seed.sql | 9 - database_migration_facility_charges.sql | 4 - ...AccountMappingFromApiJsonDeserializer.java | 45 +- .../infrastructure/jobs/service/JobName.java | 2 + .../portfolio/client/domain/ClientKyc.java | 39 + .../client/domain/ClientKycRepository.java | 13 + .../client/domain/KycExpiryStage.java | 106 + .../savings/SavingsApiConstants.java | 1 - .../domain/CurrentAccountFacilityConfig.java | 4 - .../domain/CurrentAccountPendingEdit.java | 3 - .../domain/CurrentProductAssembler.java | 1 - .../domain/CurrentProductFacilityConfig.java | 6 - ...CurrentProductReadPlatformServiceImpl.java | 1 - .../ClientRiskEscalationServiceImpl.java | 4 + .../api/CashierDepositRequestApiResource.java | 14 +- .../cashreport/api/CashReportApiResource.java | 202 +- .../api/CashSettlementApiResource.java | 5 + .../ClerkWithdrawalRequestApiResource.java | 14 +- .../api/ClientKycExceptionsApiResource.java | 19 + .../ApplyKycExpiryRestrictionsConfig.java | 52 + .../ApplyKycExpiryRestrictionsTasklet.java | 46 + .../service/ClientKycReadPlatformService.java | 10 + .../ClientKycReadPlatformServiceImpl.java | 9 + .../service/KycExpiryRestrictionService.java | 49 + .../KycExpiryRestrictionServiceImpl.java | 134 + .../api/DeafRemittanceApiResource.java | 80 + .../api/DormantAccountsApiResource.java | 59 + .../SavingsDailyInterestOrchestrator.java | 62 + .../SavingsDailyInterestService.java | 142 +- .../SavingsPooledInterestPostingService.java | 105 +- .../SavingsProductTermsCascadeService.java | 194 + .../api/SavingsDailyInterestApiResource.java | 141 + .../jobs/SavingsDailyInterestConfig.java | 52 + .../jobs/SavingsDailyInterestTasklet.java | 52 + .../savings/data/DormantAccountData.java | 75 + ...WritePlatformServiceJpaRepositoryImpl.java | 6 - ...CurrentAccountReadPlatformServiceImpl.java | 1 - ...urrentAccountWritePlatformServiceImpl.java | 1 - .../DeafRemittanceWritePlatformService.java | 40 + ...eafRemittanceWritePlatformServiceImpl.java | 136 + .../DormantAccountReadPlatformService.java | 37 + ...DormantAccountReadPlatformServiceImpl.java | 151 + ...WritePlatformServiceJpaRepositoryImpl.java | 5 - ...SavingsAccountReadPlatformServiceImpl.java | 3246 +++--- ...WritePlatformServiceJpaRepositoryImpl.java | 15 + ...SavingsProductReadPlatformServiceImpl.java | 1 - ...WritePlatformServiceJpaRepositoryImpl.java | 15 + .../savings/starter/SavingsConfiguration.java | 11 +- .../sponsordd/api/SponsorDDApiResource.java | 75 +- .../data/SponsorDDAmendmentRequestData.java | 62 + .../data/SponsorDDGlMappingData.java | 6 + .../sponsordd/data/SponsorDDLeafletData.java | 2 + ...DAmendmentRequestWritePlatformService.java | 45 + ...ndmentRequestWritePlatformServiceImpl.java | 217 + ...sorDDIssueRequestWritePlatformService.java | 2 +- ...DIssueRequestWritePlatformServiceImpl.java | 260 +- .../service/SponsorDDReadPlatformService.java | 6 + .../SponsorDDReadPlatformServiceImpl.java | 117 +- .../api/VoucherVerificationApiResource.java | 83 +- .../db/changelog/tenant/changelog-tenant.xml | 19 +- ...eate_savings_interest_posting_schedule.xml | 5 + .../0520_add_kyc_expiry_restrictions_job.xml | 52 + .../parts/0521_rbi_dormancy_defaults.xml | 72 + .../parts/0522_create_deaf_remittance.xml | 77 + .../parts/0523_dbt_dormancy_exemption.xml | 57 + ...0524_rbi_dormancy_periods_all_products.xml | 77 + .../parts/0525_savings_daily_interest_job.xml | 74 + .../0526_savings_interest_summary_view.xml | 97 + ...ngs_daily_interest_one_row_per_account.xml | 92 + .../0528_fix_escheat_liability_mapping.xml | 51 + ...ation_data_to_sponsor_dd_issue_request.xml | 38 + .../0530_drop_dbt_dormancy_exemption.xml | 45 + ...531_disable_stock_savings_interest_job.xml | 47 + .../0532_sponsor_dd_amendment_request.xml | 82 + ...SavingsProductAccountingDataValidator.java | 16 +- .../savings/domain/DeafRemittance.java | 158 + .../domain/DeafRemittanceRepository.java | 32 + .../domain/FacilityConfigEditFields.java | 3 - .../domain/ProductPendingEditFieldMapper.java | 2 - .../savings/domain/SavingsAccount.java | 9302 +++++++++-------- .../domain/SavingsAccountFacilityConfig.java | 7 - .../domain/SavingsAccountPendingEdit.java | 3 - .../domain/SavingsProductAssembler.java | 2 - .../domain/SavingsProductFacilityConfig.java | 7 - .../FacilityChargeAutoApplyService.java | 2 - .../service/FacilityChargeSeederService.java | 103 +- .../savings/parts/module-changelog-master.xml | 115 +- ...ngs_product_classification_code_values.xml | 17 + .../parts/2032_remove_withdrawal_facility.xml | 101 + 90 files changed, 10606 insertions(+), 6511 deletions(-) create mode 100644 cleanup_duplicate_vouchers.sql create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycExpiryStage.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DeafRemittanceApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DormantAccountsApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsProductTermsCascadeService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDAmendmentRequestData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_add_kyc_expiry_restrictions_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_rbi_dormancy_defaults.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_create_deaf_remittance.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_dbt_dormancy_exemption.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_rbi_dormancy_periods_all_products.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0528_fix_escheat_liability_mapping.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0529_add_denomination_data_to_sponsor_dd_issue_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0530_drop_dbt_dormancy_exemption.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0531_disable_stock_savings_interest_job.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0532_sponsor_dd_amendment_request.xml create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittance.java create mode 100644 fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittanceRepository.java create mode 100644 fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_remove_withdrawal_facility.xml diff --git a/cleanup_duplicate_vouchers.sql b/cleanup_duplicate_vouchers.sql new file mode 100644 index 000000000..a5c7d2a23 --- /dev/null +++ b/cleanup_duplicate_vouchers.sql @@ -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; diff --git a/database_migration_facility_charge_auto_seed.sql b/database_migration_facility_charge_auto_seed.sql index c8114bed8..d05d3b39e 100644 --- a/database_migration_facility_charge_auto_seed.sql +++ b/database_migration_facility_charge_auto_seed.sql @@ -127,14 +127,6 @@ SELECT 'Inter-Bank Cap Charge', 'INR', 0.00, 2, 3, 1, 0, 1, 0, 0, 0, (SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1) WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0); -INSERT IGNORE INTO m_charge - (name, currency_code, amount, charge_applies_to_enum, charge_time_enum, - charge_calculation_enum, is_penalty, is_active, is_deleted, - is_free_withdrawal, is_payment_type, income_or_liability_account_id) -SELECT 'Withdrawal Facility Charge', 'INR', 5.00, 2, 3, 1, 0, 1, 0, 0, 0, - (SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1) -WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0); - INSERT IGNORE INTO m_charge (name, currency_code, amount, charge_applies_to_enum, charge_time_enum, charge_calculation_enum, is_penalty, is_active, is_deleted, @@ -168,6 +160,5 @@ VALUES ('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)), ('RTGS_FACILITY', 'RTGS Facility Charge', 25.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'RTGS Facility Charge' AND is_deleted = 0 LIMIT 1)), ('INTER_BANK_TOTAL_CAP','Inter-Bank Cap Charge', 0.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0 LIMIT 1)), - ('WITHDRAWAL_FACILITY', 'Withdrawal Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0 LIMIT 1)), ('DEPOSIT_FACILITY_KYC','Deposit KYC Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Deposit KYC Charge' AND is_deleted = 0 LIMIT 1)), ('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1)); diff --git a/database_migration_facility_charges.sql b/database_migration_facility_charges.sql index 6a833c86d..2de849402 100644 --- a/database_migration_facility_charges.sql +++ b/database_migration_facility_charges.sql @@ -17,7 +17,6 @@ ALTER TABLE m_current_product_facility_config ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL, - ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL; @@ -36,7 +35,6 @@ ALTER TABLE m_current_product_facility_config ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id), - ADD CONSTRAINT fk_cpfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_cpfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id); @@ -55,7 +53,6 @@ ALTER TABLE m_savings_product_facility_config ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL, - ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL, ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL; @@ -74,6 +71,5 @@ ALTER TABLE m_savings_product_facility_config ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id), - ADD CONSTRAINT fk_spfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id), ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id); diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java index 511ce5e80..fff2ccc74 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/serialization/ProductToGLAccountMappingFromApiJsonDeserializer.java @@ -181,10 +181,14 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer { baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId) .notNull().integerGreaterThanZero(); - final Long transfersInSuspenseAccountId = this.fromApiJsonHelper - .extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element); - baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue()) - .value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero(); + // Transfers In Suspense is not offered by every savings product UI, so it is validated only + // when the client actually sends it. Omitting it leaves the mapping unset rather than failing. + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) { + final Long transfersInSuspenseAccountId = this.fromApiJsonHelper + .extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element); + baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue()) + .value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero(); + } final Long interestOnSavingsAccountId = this.fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element); @@ -210,20 +214,29 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer { } if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) { - final Long overdraftAccount = this.fromApiJsonHelper - .extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element); - baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue()) - .value(overdraftAccount).notNull().integerGreaterThanZero(); + // Overdraft Control / Income From Interest / Write Off apply to overdraft-capable products + // only, so they are validated when supplied rather than demanded from every savings product. + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), + element)) { + final Long overdraftAccount = this.fromApiJsonHelper + .extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element); + baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue()) + .value(overdraftAccount).notNull().integerGreaterThanZero(); + } - final Long incomeFromInterest = this.fromApiJsonHelper - .extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element); - baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()).value(incomeFromInterest) - .notNull().integerGreaterThanZero(); + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) { + final Long incomeFromInterest = this.fromApiJsonHelper + .extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element); + baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()) + .value(incomeFromInterest).notNull().integerGreaterThanZero(); + } - final Long writtenOff = this.fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), - element); - baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull() - .integerGreaterThanZero(); + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) { + final Long writtenOff = this.fromApiJsonHelper + .extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element); + baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff) + .notNull().integerGreaterThanZero(); + } } } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index aca7da9a6..f7aabdc4d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -41,6 +41,8 @@ public enum JobName { FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), // POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), // UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), // + APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), // + SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), // ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS( "Add Accrual Transactions For Loans With Income Posted As Transactions"), // EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), // diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java index 0b2b05eef..1c02f0dc2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java @@ -484,6 +484,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { actualChanges.put(riskParam, newValue); recordOverwrite(riskParam, this.riskCategory); this.riskCategory = newValue; + + // The re-KYC cycle is a property of the risk category, so moving the category moves the + // due date with it — otherwise a customer raised to High keeps a Low customer's ten-year + // renewal and is never called back in for the two-yearly review the rating requires. + final LocalDate newExpiry = recalculateExpiryForRiskCategory(); + if (newExpiry != null) { + actualChanges.put("expiryDate", newExpiry); + } } // remarks is the audit justification itself, so a change to it is deliberately not recorded @@ -677,6 +685,34 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { } } + /** + * Re-dates the re-KYC due date after the risk category moves. + * + * The cycle length belongs to the category the customer is in now (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. + * + *

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. */ public void markRejected(final String remarks) { this.kycStatus = ClientKycStatus.REJECTED.getValue(); @@ -798,6 +834,9 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { */ public void escalateRiskCategory(Integer newCategory) { this.riskCategory = newCategory; + // An auto-escalation moves the rating for the same reason an officer would, so it moves the + // re-KYC due date on the same terms — see recalculateExpiryForRiskCategory(). + recalculateExpiryForRiskCategory(); } public String getPanNumber() { diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java index e7f1f9eee..5b506f6d7 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.client.domain; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -48,6 +49,18 @@ public interface ClientKycRepository extends JpaRepository { @Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status") List findAllByStatus(@Param("status") Integer status); + /** + * KYC records whose periodic re-KYC due date has passed, oldest first. + * + *

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 findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus); + // ── Pre-KYC uniqueness probes ─────────────────────────────────────────── // Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the // external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycExpiryStage.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycExpiryStage.java new file mode 100644 index 000000000..9e98fd63e --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycExpiryStage.java @@ -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. + * + *

+ * 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. + * + *

+ * 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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java index 1bde1ad04..f47a04cf5 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java @@ -258,7 +258,6 @@ public class SavingsApiConstants { public static final String neftFacilityParamName = "neftFacility"; public static final String rtgsFacilityParamName = "rtgsFacility"; public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility"; - public static final String withdrawalFacilityParamName = "withdrawalFacility"; public static final String depositFacilityKycParamName = "depositFacilityKyc"; public static final String virtualDebitCardParamName = "virtualDebitCard"; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java index c91a00cac..90bae273a 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountFacilityConfig.java @@ -77,9 +77,6 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom { @Column(name = "inter_bank_total_cap_facility") private Boolean interBankTotalCapFacility; - @Column(name = "withdrawal_facility") - private Boolean withdrawalFacility; - @Column(name = "deposit_facility_kyc") private Boolean depositFacilityKyc; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java index 450a714ba..4bdecc85c 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java @@ -378,7 +378,6 @@ public class CurrentProductAssembler { config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false); config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false); config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false); - config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ? facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false); config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false); config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false); return config; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductFacilityConfig.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductFacilityConfig.java index cd355ff5b..f0665870f 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductFacilityConfig.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductFacilityConfig.java @@ -75,9 +75,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom commandsSourceWritePlatformService.logCommandSource(commandRequest)); return result.getResourceId() != null ? result.getResourceId().toString() : null; } @@ -740,7 +746,11 @@ public class CashierDepositRequestApiResource { .withJson(payload.toString()) .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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 15d6924a5..1a2b36cc3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -13,6 +13,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; @@ -24,6 +25,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/cashreports") @Component @RequiredArgsConstructor +@Slf4j @Produces({ MediaType.APPLICATION_JSON }) public class CashReportApiResource { @@ -34,6 +36,24 @@ public class CashReportApiResource { private final VaultPositionService vaultPositionService; 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. * @@ -72,7 +92,11 @@ public class CashReportApiResource { // Cashier deposits — money in, so the credit side. 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.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id, r.submitted_at, r.actioned_at, @@ -84,13 +108,31 @@ public class CashReportApiResource { // Clerk withdrawals — money out, so the debit side. 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.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, """ + verifyCols + """ FROM m_clerk_withdrawal_request r """ + verifyJoin + """ + -- A withdrawal is raised by a clerk but the notes leave a cashier's drawer, so it + -- is filed under whoever's cash actually moved. Sectioning it by the maker put + -- every counter payment in the clerk's column and left the cashier's own section + -- short by exactly what they had paid out -- the one figure their drawer is + -- checked against. The payer is read from the denomination breakdown, which is + -- written when the cash is handed over, and the By column names them too. + LEFT JOIN LATERAL ( + SELECT d.officer_username FROM m_savings_txn_denomination d + WHERE r.txn_id ~ '^[0-9]+$' AND d.txn_id = CAST(r.txn_id AS BIGINT) + AND d.officer_username IS NOT NULL + LIMIT 1) paid ON TRUE WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE) """, this::mapDayBookRow, txnDate)); @@ -125,7 +167,7 @@ public class CashReportApiResource { // Disbursement register: a collection pulls money into the GL (CR); a disbursement or a // bank transfer moves it out (DR) — matching how the voucher itself was numbered. rows.addAll(jdbcTemplate.query(""" - SELECT r.voucher_no, + SELECT r.voucher_no, 'CLERK' AS section, CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side, r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type, r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name, @@ -137,6 +179,37 @@ public class CashReportApiResource { WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE) """, this::mapDayBookRow, txnDate)); + // Cash taken at the counter for a Demand Draft — a receipt, so the credit side. + // + // The Sponsor DD register is not one of the three above and has no voucher_no of its own, so + // the day book showed nothing for it: a cashier who took ₹111.80 in notes for a draft saw a + // day book with an empty CR column and no sign of the money. The voucher is joined the other + // way round, from m_voucher.reference_id, which is where the link actually lives. + // + // Only the cash-funded drafts appear, and only those the cashier took the notes for + // directly: an account-debit or cheque draft never crosses the counter, and one funded from + // a cashier deposit request is already listed above under that register's own row. + rows.addAll(jdbcTemplate.query(""" + SELECT v.voucher_no, 'CASHIER' AS section, 'CR' AS side, 'DD CASH' AS kind, + NULL AS account_no, 'GL' AS account_type, + COALESCE(r.walkin_purchaser_name, r.beneficiary_name) AS party, + r.face_value + COALESCE(r.charges_amount, 0) + COALESCE(r.gst_amount, 0) AS amount, + 'CASH' AS payment_type_name, + CONCAT('DD for ', r.beneficiary_name) AS description, + r.status, mk.username AS handled_by, ck.username AS approved_by, + CAST(r.id AS VARCHAR) AS txn_id, + r.created_date AS submitted_at, r.checker_date AS actioned_at, + """ + verifyCols + """ + FROM m_sponsor_dd_issue_request r + LEFT JOIN m_appuser mk ON mk.id = r.maker_id + LEFT JOIN m_appuser ck ON ck.id = r.checker_id + LEFT JOIN m_voucher v ON v.reference_type = 'SPONSOR_DD_ISSUE_REQUEST' + AND v.reference_id = r.id AND v.voucher_no LIKE 'CR/%' + """ + verifyJoin.replace("r.voucher_no", "v.voucher_no") + """ + WHERE r.payment_method = 'CASH' AND r.cashier_deposit_request_id IS NULL + AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE) + """, this::mapDayBookRow, txnDate)); + // Filtering and ordering happen here rather than in SQL: three separate queries cannot be // ordered as one result set, and the row count for a single day is small. final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase(); @@ -195,6 +268,7 @@ public class CashReportApiResource { result.put("date", txnDate); result.put("rows", filtered); result.put("count", filtered.size()); + // Counter figures: the credit and debit the customer side of the day adds up to. result.put("creditTotal", creditTotal); result.put("debitTotal", debitTotal); result.put("netTotal", creditTotal.subtract(debitTotal)); @@ -204,6 +278,10 @@ public class CashReportApiResource { private Map mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException { final Map row = new LinkedHashMap<>(); row.put("voucherNo", rs.getString("voucher_no")); + // Which part of the day's work this row belongs to. The registers do not divide along + // maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says + // where the entry came from, not who typed it. + row.put("section", rs.getString("section")); row.put("side", rs.getString("side")); row.put("kind", rs.getString("kind")); row.put("accountNo", rs.getString("account_no")); @@ -241,12 +319,21 @@ public class CashReportApiResource { row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString()); return row; } - @GET @Path("closing") public String closing(@QueryParam("date") String date) { 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 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, " @@ -255,10 +342,16 @@ public class CashReportApiResource { + "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount " + "FROM m_savings_txn_denomination std " + "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 " + "ORDER BY std.denomination_value DESC, dm.kind"; + final List args = new ArrayList<>(); + args.add(txnDate); + if (scopedCashierId != null) { + args.add(scopedCashierId); + } + final List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { long depositCount = rs.getLong("deposit_count"); long withdrawalCount = rs.getLong("withdrawal_count"); @@ -275,7 +368,7 @@ public class CashReportApiResource { row.put("netCount", depositCount - withdrawalCount); row.put("netAmount", depositAmount.subtract(withdrawalAmount)); return row; - }, txnDate); + }, args.toArray()); BigDecimal depositTotal = BigDecimal.ZERO; BigDecimal withdrawalTotal = BigDecimal.ZERO; @@ -285,6 +378,7 @@ public class CashReportApiResource { } // 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, " + "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, " @@ -294,10 +388,16 @@ public class CashReportApiResource { + "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id " + "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id " + "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 " + "ORDER BY ced.denomination_value DESC, dm.kind"; + final List exArgs = new ArrayList<>(); + exArgs.add(txnDate); + if (scopedCashierId != null) { + exArgs.add(scopedCashierId); + } + final List> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> { long receivedCount = rs.getLong("received_count"); long givenCount = rs.getLong("given_count"); @@ -314,18 +414,96 @@ public class CashReportApiResource { row.put("netCount", receivedCount - givenCount); row.put("netAmount", receivedAmount.subtract(givenAmount)); 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 ddArgs = new ArrayList<>(); + ddArgs.add(txnDate); + if (scopedCashierId != null) { + ddArgs.add(scopedCashierId); + } + + BigDecimal ddCashTotal = BigDecimal.ZERO; + long ddCashCount = 0; + try { + final Map 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 result = new LinkedHashMap<>(); result.put("date", txnDate); result.put("rows", rows); + // The denomination-wise total, which is what the rows below add up to. result.put("depositTotal", depositTotal); 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("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); } + /** + * 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 position = buildPositionForCashier(cashierId, asOfDate); + final Object total = position.get("totalAmount"); + return total instanceof BigDecimal ? (BigDecimal) total : BigDecimal.ZERO; + } + @GET @Path("excess") 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) " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Settlements back to vault @@ -698,7 +876,7 @@ public class CashReportApiResource { + "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Exchange IN: customer gave denomination to cashier (RECEIVED direction) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java index 90007a91a..0d8e7fd68 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; 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.CashSettlementRepository; 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.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; @@ -41,6 +44,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/cashsettlement") @Component @RequiredArgsConstructor +@Slf4j @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) public class CashSettlementApiResource { @@ -52,6 +56,7 @@ public class CashSettlementApiResource { private final PlatformTransactionManager transactionManager; private final JdbcTemplate jdbcTemplate; private final CashierRoleHelper cashierRoleHelper; + private final VoucherService voucherService; private final Gson gson = new Gson(); @POST diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 2dafeb28b..33bc6756b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -788,7 +788,13 @@ public class ClerkWithdrawalRequestApiResource { .withJson(payload.toString()) .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; } @@ -816,7 +822,11 @@ public class ClerkWithdrawalRequestApiResource { .withJson(payload.toString()) .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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java index 9de9c6acc..9c134190d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycExceptionsApiResource.java @@ -70,4 +70,23 @@ public class ClientKycExceptionsApiResource { } return this.toApiJsonSerializer.serialize(exceptions); } + + /** + * Customers whose periodic re-KYC has already fallen due. + * + *

+ * 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 lapsed = this.readPlatformService.retrieveLapsedKyc(); + LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size()); + return this.toApiJsonSerializer.serialize(lapsed); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsConfig.java new file mode 100644 index 000000000..4a87ede2d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsConfig.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsTasklet.java new file mode 100644 index 000000000..2b769e7ff --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/applykycexpiryrestrictions/ApplyKycExpiryRestrictionsTasklet.java @@ -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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java index 93c7e7569..664ebb443 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java @@ -35,4 +35,14 @@ public interface ClientKycReadPlatformService { java.util.List retrieveActiveKycExceptions(); java.util.List retrieveResolvedKycExceptions(); + + /** + * KYC records whose periodic re-KYC due date has already passed, oldest first. + * + *

+ * 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 retrieveLapsedKyc(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java index a9dd3e91d..41ece59e2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java @@ -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.ClientKycRepository; 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.ClientKycType; import org.apache.fineract.portfolio.client.domain.KycRiskCategory; @@ -159,4 +160,12 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe .map(this::toData) .collect(java.util.stream.Collectors.toList()); } + + @Override + public java.util.List retrieveLapsedKyc() { + return this.kycRepository + .findLapsedAsOn(DateUtils.getBusinessLocalDate(), ClientKycStatus.REJECTED.getValue()).stream() + .map(this::toData) + .collect(java.util.stream.Collectors.toList()); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionService.java new file mode 100644 index 000000000..38fdcec07 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionService.java @@ -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) {} +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionServiceImpl.java new file mode 100644 index 000000000..dc7a16755 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycExpiryRestrictionServiceImpl.java @@ -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. + * + *

+ * 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. + * + *

+ * 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 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 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. + * + *

+ * 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 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DeafRemittanceApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DeafRemittanceApiResource.java new file mode 100644 index 000000000..d98aab2a7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DeafRemittanceApiResource.java @@ -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. + * + *

+ * 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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DormantAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DormantAccountsApiResource.java new file mode 100644 index 000000000..966c8df0a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/DormantAccountsApiResource.java @@ -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 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 accounts = this.readPlatformService.retrieveDormantAccounts(subStatus, officeName); + return this.toApiJsonSerializer.serialize(accounts); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java index 2fca89e25..6a3f7da8b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java @@ -26,6 +26,7 @@ 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; /** * 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. */ public void runForDate(final LocalDate businessDate) { accrualService.accrueForDate(businessDate); + enrolNewProducts(); final List> schedules = jdbcTemplate.queryForList(""" 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. + * + *

+ * 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. + * + *

+ * 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 * itself recorded, so the schedule and the rows can never drift apart. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java index 2c00c0b66..57534016c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java @@ -118,6 +118,62 @@ public class SavingsDailyInterestService { return written; } + /** + * Opens the ledger for one newly activated account. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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> 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 account, final LocalDate businessDate, final int financialYearBeginningMonth) { 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 BigDecimal minBalance = (BigDecimal) account.get("min_balance"); - // The previous row is the only thing that carries the chain forward. Its absence means - // this is the account's first day; a gap would mean a day the job never ran, and rather - // than silently re-anchoring we take the last row we do have and let the reconciliation - // report the break. + // The account's row is the only thing that carries the chain forward: it holds the balance + // the last accrued day closed on and the cycle's running total. Its absence means this is + // the account's first day. + // + // 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> previous = jdbcTemplate.queryForList(""" SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number, cycle_start_date, cycle_end_date, is_posting_day FROM m_savings_daily_interest WHERE savings_account_id = ? AND balance_date < ? - ORDER BY balance_date DESC LIMIT 1 """, accountId, businessDate); final BigDecimal openingBalance; @@ -152,20 +211,30 @@ public class SavingsDailyInterestService { openingBalance = BigDecimal.ZERO; carriedInterest = BigDecimal.ZERO; cycleNumber = 1; - // Cycle 1 is a stub: it starts when the account was activated, not on the grid. - cycleStart = activatedOn; - cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(activatedOn, postingPeriod, financialYearBeginningMonth); + // The first row belongs to the cycle the day it records falls in, and starts at + // activation only when that day is still inside the account's opening cycle. Anchoring + // 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 { final Map prev = previous.get(0); openingBalance = (BigDecimal) prev.get("closing_balance"); final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day")); final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate(); - if (prevWasPosting) { - // The cycle closed yesterday: the sum went to the member and starts again at zero. + // A cycle is finished either because it was paid out or because its end date has gone + // 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; cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1; cycleStart = prevCycleEnd.plusDays(1); - cycleEnd = SavingsInterestCycleCalculator.nextCycleEndDate(prevCycleEnd, postingPeriod, + cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod, financialYearBeginningMonth); } else { 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_period_enum, calculation_type_enum, min_balance_for_interest, below_min_balance) 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, - -- A day whose interest has already been paid keeps the balance it was left - -- with. Re-accruing that day recomputes the closing balance from - -- transactions alone, which does not include this ledger's own posting, and - -- writing it back would erase the credit from the chain: the next day would - -- 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, + -- Taken as computed. The interest this ledger posts is counted as movement on + -- the day it was paid (see dayMovement), so the recomputed closing already + -- carries it and needs no adjustment here. + closing_balance = EXCLUDED.closing_balance, daily_interest = EXCLUDED.daily_interest, -- Likewise the total that was actually paid: once money has moved on the - -- strength of this figure, recomputing it would leave the ledger disagreeing - -- with the member's passbook. A posted day is settled. + -- strength of this figure, recomputing the same day would leave the ledger + -- 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 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 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_start_date = EXCLUDED.cycle_start_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 - * folding it back in here would let it accrue interest on itself a second time. It is added to - * the closing balance by the posting run, on the posting day, once. + * Interest credited by this ledger counts as movement like any other credit. It has to: the + * ledger holds one row per account, so a balance is only ever built from the row before it, and + * 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) { final BigDecimal movement = jdbcTemplate.queryForObject(""" 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 ELSE 0 END), 0) FROM m_savings_account_transaction t diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java index 4c2ca6b81..44f6c4172 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -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 * holds the figure that would say so. * - * This posts the product's total first, into Savings Interest Posting Control, moves it to Savings - * Interest Posted Control, and only then pays it out to members. The whole product settles inside - * 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 - * commit leaves a PENDING row naming the product and date for the next run to pick up. + * This posts the product's total first, into the liability the product names as its Savings Interest + * Payable, and only then pays it out to members: + * + *

+ *   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)
+ * 
+ * + * 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 * 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 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_POSTED_CONTROL = "200305"; @@ -142,20 +164,34 @@ public class SavingsPooledInterestPostingService { largest.get("savings_account_id")); } - final Long postingControlGl = glAccountId(GL_POSTING_CONTROL); - final Long postedControlGl = glAccountId(GL_POSTED_CONTROL); - final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS); + // The expense the bill is recognised against, and the liability it sits in until it reaches + // the members. Both come from the product: "Savings Interest Paid" if the product names one, + // 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 String currency = (String) due.get(0).get("currency_code"); 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, postingControlGl, postingDate, pooled, false, poolReference, null); // Stage 2: the pool moves to the account it is paid out of. - journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null); - journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null); + // + // 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, postedControlGl, postingDate, pooled, false, poolReference, null); + } // Stage 3: out to the members, one at a time. BigDecimal distributed = BigDecimal.ZERO; @@ -177,6 +213,9 @@ public class SavingsPooledInterestPostingService { creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference, 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(""" UPDATE m_savings_daily_interest SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?, @@ -297,4 +336,48 @@ public class SavingsPooledInterestPostingService { LIMIT 1 """, Long.class, productId, financialAccountType); } + + /** + * The GL account a product has mapped under one of the reserved names in its own GL mappings. + * + *

+ * 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 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; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsProductTermsCascadeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsProductTermsCascadeService.java new file mode 100644 index 000000000..3c20d5ef4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsProductTermsCascadeService.java @@ -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 changes) { + if (productId == null || changes == null || changes.isEmpty()) { + return 0; + } + if (!touchesInterestTerms(changes)) { + return 0; + } + + final List> 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 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> 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java index 82b77585a..5b8a3977e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java @@ -34,6 +34,7 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; 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.stereotype.Component; @@ -53,8 +54,35 @@ public class SavingsDailyInterestApiResource { private final JdbcTemplate jdbcTemplate; private final SavingsDailyInterestService accrualService; private final SavingsDailyInterestOrchestrator orchestrator; + private final SavingsProductTermsCascadeService termsCascadeService; 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> rows = termsCascadeService.findDrifted(); + final Map 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 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. */ @GET @Path("/products") @@ -117,6 +145,119 @@ public class SavingsDailyInterestApiResource { return gson.toJson(rows); } + /** + * One row per account: where each one stands right now. + * + *

+ * 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. + * + *

+ * 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 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. + * + *

+ * 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. + * + *

+ * {@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 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. */ @GET @Path("/runs") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestConfig.java new file mode 100644 index 000000000..ada5c7a5c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestConfig.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestTasklet.java new file mode 100644 index 000000000..e2d1e6c62 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsDailyInterestTasklet.java @@ -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. + * + *

+ * 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java new file mode 100644 index 000000000..afede6a65 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java @@ -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. + * + *

+ * {@code lastCustomerTransactionDate} is the heart of it: RBI measures the silence from the last + * transaction the customer 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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java index 34f73ee2e..d0f945f84 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -88,7 +88,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY"); FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY"); FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP"); - FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY"); FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC"); FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD"); FACILITY_TYPES.put("internetBanking", "INTERNET_BANKING"); @@ -155,8 +154,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes); populateFacilityField(fc, "interBankTotalCapFacility", currentConfig != null && currentConfig.isInterBankTotalCapFacility(), pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes); - populateFacilityField(fc, "withdrawalFacility", currentConfig != null && currentConfig.isWithdrawalFacility(), - pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes); populateFacilityField(fc, "depositFacilityKyc", currentConfig != null && currentConfig.isDepositFacilityKyc(), pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes); populateFacilityField(fc, "virtualDebitCard", currentConfig != null && currentConfig.isVirtualDebitCard(), @@ -418,7 +415,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements putIfPresent(fc, "neftFacility", p.getNeftFacility()); putIfPresent(fc, "rtgsFacility", p.getRtgsFacility()); putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); - putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility()); putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard()); putIfPresent(fc, "internetBanking", p.getInternetBanking()); @@ -449,7 +445,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current != null && current.isRtgsFacility()); addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(), current != null && current.isInterBankTotalCapFacility()); - addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current != null && current.isWithdrawalFacility()); addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current != null && current.isDepositFacilityKyc()); addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current != null && current.isVirtualDebitCard()); addIfNewlyEnabled(result, "internetBanking", p.getInternetBanking(), current != null && current.isInternetBanking()); @@ -534,7 +529,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements putIfNotNull(fc, "neftFacility", p.getNeftFacility()); putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility()); putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); - putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility()); putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard()); putIfNotNull(fc, "internetBanking", p.getInternetBanking()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java index bf6d014e8..cbc3c5a63 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java @@ -91,7 +91,6 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead cfg.put("neftFacility", rs.getBoolean("neft_facility")); cfg.put("rtgsFacility", rs.getBoolean("rtgs_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("virtualDebitCard", rs.getBoolean("virtual_debit_card")); cfg.put("internetBanking", rs.getBoolean("internet_banking")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java index c6ee7f823..95216e8e9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java @@ -505,7 +505,6 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri cfg.setNeftFacility(getBool(fcJson, "neftFacility")); cfg.setRtgsFacility(getBool(fcJson, "rtgsFacility")); cfg.setInterBankTotalCapFacility(getBool(fcJson, "interBankTotalCapFacility")); - cfg.setWithdrawalFacility(getBool(fcJson, "withdrawalFacility")); cfg.setDepositFacilityKyc(getBool(fcJson, "depositFacilityKyc")); cfg.setVirtualDebitCard(getBool(fcJson, "virtualDebitCard")); cfg.setInternetBanking(getBool(fcJson, "internetBanking")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformService.java new file mode 100644 index 000000000..fb928f7fc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformService.java @@ -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. + * + *

+ * 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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformServiceImpl.java new file mode 100644 index 000000000..509d07fc8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DeafRemittanceWritePlatformServiceImpl.java @@ -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. + * + *

+ * 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 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformService.java new file mode 100644 index 000000000..8e1094b4f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformService.java @@ -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 retrieveDormantAccounts(Integer subStatus, String officeName); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java new file mode 100644 index 000000000..6d5322c63 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java @@ -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. + * + *

+ * 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. + * + *

+ * 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 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 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 { + + @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"; + }; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java index ac6194011..ca4b88631 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -83,7 +83,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY"); FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY"); FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP"); - FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY"); FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC"); 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, "rtgsFacility", currentConfig == null ? null : currentConfig.getRtgsFacility(), pendingEdit::setRtgsFacility, 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, "virtualDebitCard", currentConfig == null ? null : currentConfig.getVirtualDebitCard(), pendingEdit::setVirtualDebitCard, newlyEnabledFacilityTypes); } @@ -370,7 +368,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements putIfPresent(fc, "neftFacility", p.getNeftFacility()); putIfPresent(fc, "rtgsFacility", p.getRtgsFacility()); putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); - putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility()); putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard()); return fc; @@ -397,7 +394,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements addIfNewlyEnabled(result, "neftFacility", p.getNeftFacility(), current == null ? null : current.getNeftFacility()); addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current == null ? null : current.getRtgsFacility()); 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, "virtualDebitCard", p.getVirtualDebitCard(), current == null ? null : current.getVirtualDebitCard()); return result; @@ -483,7 +479,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements putIfNotNull(fc, "neftFacility", p.getNeftFacility()); putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility()); putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility()); - putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility()); putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc()); putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard()); data.setProposedFacilityConfig(fc); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index 4205b3804..3fa41d175 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -1,1624 +1,1622 @@ -/** - * 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 static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_BY_DB_FIELD; -import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_DATE_DB_FIELD; - -import java.math.BigDecimal; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import org.apache.commons.lang3.StringUtils; -import org.apache.fineract.accounting.common.AccountingRuleType; -import org.apache.fineract.accounting.glaccount.data.GLAccountData; -import org.apache.fineract.infrastructure.core.data.EnumOptionData; -import org.apache.fineract.infrastructure.core.domain.ExternalId; -import org.apache.fineract.infrastructure.core.domain.JdbcSupport; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.core.service.Page; -import org.apache.fineract.infrastructure.core.service.PaginationHelper; -import org.apache.fineract.infrastructure.core.service.SearchParameters; -import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; -import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.infrastructure.security.utils.ColumnValidator; -import org.apache.fineract.organisation.monetary.data.CurrencyData; -import org.apache.fineract.portfolio.account.data.AccountTransferData; -import org.apache.fineract.portfolio.client.data.ClientData; -import org.apache.fineract.portfolio.group.data.GroupGeneralData; -import org.apache.fineract.portfolio.paymentdetail.data.PaymentDetailData; -import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; -import org.apache.fineract.portfolio.savings.DepositAccountType; -import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; -import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; -import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; -import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; -import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; -import org.apache.fineract.portfolio.savings.data.SavingsAccountApplicationTimelineData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountSubStatusEnumData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountSummaryData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; -import org.apache.fineract.portfolio.savings.data.SavingsAccrualData; -import org.apache.fineract.portfolio.savings.data.SavingsProductData; -import org.apache.fineract.portfolio.savings.domain.SavingsAccount; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; -import org.apache.fineract.portfolio.tax.data.TaxComponentData; -import org.apache.fineract.portfolio.tax.data.TaxDetailsData; -import org.apache.fineract.portfolio.tax.data.TaxGroupData; -import org.apache.fineract.useradministration.domain.AppUser; -import org.springframework.dao.DataAccessException; -import org.springframework.dao.EmptyResultDataAccessException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.ResultSetExtractor; -import org.springframework.jdbc.core.RowMapper; - -public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountReadPlatformService { - - private final PlatformSecurityContext context; - private final JdbcTemplate jdbcTemplate; - private final DatabaseSpecificSQLGenerator sqlGenerator; - - // mappers - private final SavingsAccountTransactionTemplateMapper transactionTemplateMapper; - protected SavingsAccountTransactionsMapper transactionsMapper; - private final SavingsAccountTransactionsForBatchMapper savingsAccountTransactionsForBatchMapper; - private final SavingAccountMapper savingAccountMapper; - private final SavingAccountMapperForInterestPosting savingAccountMapperForInterestPosting; - // private final SavingsAccountAnnualFeeMapper annualFeeMapper; - - // pagination - private final PaginationHelper paginationHelper; - - private final ColumnValidator columnValidator; - private final SavingsAccountAssembler savingAccountAssembler; - - private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper; - - public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate, - final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator, - DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) { - this.context = context; - this.jdbcTemplate = jdbcTemplate; - this.sqlGenerator = sqlGenerator; - this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper; - this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper(); - this.transactionsMapper = new SavingsAccountTransactionsMapper(); - this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper(); - this.savingAccountMapper = new SavingAccountMapper(); - this.columnValidator = columnValidator; - this.paginationHelper = paginationHelper; - this.savingAccountMapperForInterestPosting = new SavingAccountMapperForInterestPosting(); - this.savingAccountAssembler = savingAccountAssembler; - } - - @Override - public Collection retrieveAllForLookup(final Long clientId) { - - final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); - sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 "); - - final Object[] queryParameters = new Object[] { clientId }; - return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); - } - - @Override - public Collection retrieveActiveForLookup(final Long clientId, DepositAccountType depositAccountType) { - - final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); - sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 and sa.deposit_type_enum = ? "); - - final Object[] queryParameters = new Object[] { clientId, depositAccountType.getValue() }; - return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); - } - - @Override - public Collection retrieveActiveForLookup(final Long clientId, DepositAccountType depositAccountType, - String currencyCode) { - final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); - sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 and sa.deposit_type_enum = ? and sa.currency_code = ? "); - - final Object[] queryParameters = new Object[] { clientId, depositAccountType.getValue(), currencyCode }; - return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); - } - - @Override - public Page retrieveAll(final SearchParameters searchParameters) { - - final AppUser currentUser = this.context.authenticatedUser(); - final String hierarchy = currentUser.getOffice().getHierarchy(); - final String hierarchySearchString = hierarchy + "%"; - - final StringBuilder sqlBuilder = new StringBuilder(200); - sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " "); - sqlBuilder.append(this.savingAccountMapper.schema()); - - sqlBuilder.append(" join m_office o on o.id = c.office_id"); - sqlBuilder.append(" where o.hierarchy like ?"); - - final Object[] objectArray = new Object[15]; - objectArray[0] = hierarchySearchString; - int arrayPos = 1; - if (searchParameters != null) { - - if (StringUtils.isNotBlank(searchParameters.getStatus())) { - if ("terminated".equalsIgnoreCase(searchParameters.getStatus())) { - sqlBuilder.append(" and sa.terminate_true = true"); - } else { - final Integer statusEnum = resolveStatusEnum(searchParameters.getStatus()); - if (statusEnum != null) { - sqlBuilder.append(" and sa.status_enum = ?"); - objectArray[arrayPos++] = statusEnum; - if (statusEnum == 100) { - sqlBuilder.append(" and (sa.terminate_true = false or sa.terminate_true is null)"); - } - } - } - } - - if (StringUtils.isNotBlank(searchParameters.getExternalId())) { - sqlBuilder.append(" and sa.external_id = ?"); - objectArray[arrayPos] = searchParameters.getExternalId(); - arrayPos = arrayPos + 1; - } - if (searchParameters.getOfficeId() != null) { - sqlBuilder.append(" and c.office_id = ?"); - objectArray[arrayPos++] = searchParameters.getOfficeId(); - } - if (StringUtils.isNotBlank(searchParameters.getAccountNo())) { - sqlBuilder.append(" and sa.account_no like ?"); - objectArray[arrayPos++] = "%" + searchParameters.getAccountNo() + "%"; - } - if (StringUtils.isNotBlank(searchParameters.getName())) { - sqlBuilder.append(" and c.display_name like ?"); - objectArray[arrayPos++] = "%" + searchParameters.getName() + "%"; - } - if (searchParameters.getDepositType() != null) { - sqlBuilder.append(" and sa.deposit_type_enum = ?"); - objectArray[arrayPos++] = searchParameters.getDepositType(); - } - if (searchParameters.hasFromDate()) { - sqlBuilder.append(" and sa.activatedon_date >= ?"); - objectArray[arrayPos++] = searchParameters.getFromDate(); - } - if (searchParameters.hasToDate()) { - sqlBuilder.append(" and sa.activatedon_date <= ?"); - objectArray[arrayPos++] = searchParameters.getToDate(); - } - if (searchParameters.hasAsOnDate()) { - sqlBuilder.append(" and sa.activatedon_date <= ?"); - objectArray[arrayPos++] = searchParameters.getAsOnDate(); - } - if (searchParameters.hasOrderBy()) { - sqlBuilder.append(" order by ").append(searchParameters.getOrderBy()); - this.columnValidator.validateSqlInjection(sqlBuilder.toString(), searchParameters.getOrderBy()); - - if (searchParameters.hasSortOrder()) { - sqlBuilder.append(' ').append(searchParameters.getSortOrder()); - this.columnValidator.validateSqlInjection(sqlBuilder.toString(), searchParameters.getSortOrder()); - } - } else { - sqlBuilder.append(" order by sa.id desc"); - } - - if (searchParameters.hasLimit()) { - sqlBuilder.append(" "); - if (searchParameters.hasOffset()) { - sqlBuilder.append(sqlGenerator.limit(searchParameters.getLimit(), searchParameters.getOffset())); - } else { - sqlBuilder.append(sqlGenerator.limit(searchParameters.getLimit())); - } - } - } - final Object[] finalObjectArray = Arrays.copyOf(objectArray, arrayPos); - return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(), finalObjectArray, this.savingAccountMapper); - } - - private Integer resolveStatusEnum(final String status) { - if (status == null) { - return null; - } - return switch (status.toLowerCase()) { - case "active" -> 300; - case "approved" -> 200; - case "submitted", "submittedandpendingapproval" -> 100; - case "withdrawn" -> 400; - case "rejected" -> 500; - case "closed" -> 600; - case "terminated" -> 900; - default -> { - try { - yield Integer.parseInt(status); - } catch (NumberFormatException e) { - yield null; - } - } - }; - } - - @Override - public SavingsAccountData retrieveOne(final Long accountId) { - - try { - final String sql = "select " + this.savingAccountMapper.schema() + " where sa.id = ?"; - - final SavingsAccountData accountData = this.jdbcTemplate.queryForObject(sql, this.savingAccountMapper, - new Object[] { accountId }); // NOSONAR - - // Load joint holders with explicit camelCase keys (queryForList returns lowercase from MySQL) - if (accountData != null) { - final String jointSql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, " - + "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory " - + "FROM m_savings_account_joint_holders jh WHERE jh.savings_account_id = ? " - + "ORDER BY jh.is_primary DESC, jh.id ASC"; - final List> jointHolders = this.jdbcTemplate.query(jointSql, (rs, rowNum) -> { - final java.util.Map h = new java.util.LinkedHashMap<>(); - h.put("id", rs.getLong("id")); - h.put("clientId", rs.getLong("client_id")); - h.put("cifNumber", rs.getString("cif_number")); - h.put("displayName", rs.getString("display_name")); - h.put("sharePercentage", rs.getBigDecimal("share_percentage")); - h.put("signingAuthority", rs.getString("signing_authority")); - h.put("role", rs.getString("role")); - h.put("isPrimary", rs.getBoolean("is_primary")); - h.put("isMandatory", rs.getBoolean("is_mandatory")); - return h; - }, accountId); - if (!jointHolders.isEmpty()) { - accountData.setJointHolders(jointHolders); - } - } - - return accountData; - } catch (final EmptyResultDataAccessException e) { - throw new SavingsAccountNotFoundException(accountId, e); - } - } - - @Override - public List retrieveAllTransactionData(final List refNo) throws DataAccessException { - String inSql = String.join(",", Collections.nCopies(refNo.size(), "?")); - String sql = "select " + this.savingsAccountTransactionsForBatchMapper.schema() + " where tr.ref_no in (%s)"; - Object[] params = new Object[refNo.size()]; - int i = 0; - for (String element : refNo) { - params[i] = element; - i++; - } - return this.jdbcTemplate.query(String.format(sql, inSql), this.savingsAccountTransactionsForBatchMapper, params); - } - - @Override - public List retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize, - final Integer status, final Long maxSavingsId) { - LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1); - String sql = "select " + this.savingAccountMapperForInterestPosting.schema() - + "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id "; - if (backdatedTxnsAllowedTill) { - sql = sql - + "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) "; - } - - sql = sql + " and (sa.interest_posted_till_date is null or sa.interest_posted_till_date <= ? ) "; - // #audit backward compatibility - sql = sql + " order by sa.id, tr.transaction_date, tr." + CREATED_DATE_DB_FIELD + ", tr.created_date, tr.id"; - - List savingsAccountDataList = this.jdbcTemplate.query(sql, this.savingAccountMapperForInterestPosting, // NOSONAR - new Object[] { maxSavingsId, status, pageSize, yesterday }); - for (SavingsAccountData savingsAccountData : savingsAccountDataList) { - this.savingAccountAssembler.assembleSavings(savingsAccountData); - } - return savingsAccountDataList; - } - - private static final class SavingAccountMapperForInterestPosting implements ResultSetExtractor> { - - private final String schemaSql; - - SavingAccountMapperForInterestPosting() { - final StringBuilder sqlBuilder = new StringBuilder(400); - sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, "); - sqlBuilder.append("sa.deposit_type_enum as depositType, "); - sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, "); - sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, "); - sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, "); - sqlBuilder.append("sa.status_enum as statusEnum, "); - sqlBuilder.append("sa.sub_status_enum as subStatusEnum, "); - sqlBuilder.append("sa.submittedon_date as submittedOnDate,"); - sqlBuilder.append("sa.rejectedon_date as rejectedOnDate,"); - sqlBuilder.append("sa.withdrawnon_date as withdrawnOnDate,"); - sqlBuilder.append("sa.approvedon_date as approvedOnDate,"); - sqlBuilder.append("sa.activatedon_date as activatedOnDate,"); - sqlBuilder.append("sa.closedon_date as closedOnDate,"); - sqlBuilder.append( - "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); - sqlBuilder.append("sa.nominal_annual_interest_rate as nominalAnnualInterestRate, "); - sqlBuilder.append("sa.interest_compounding_period_enum as interestCompoundingPeriodType, "); - sqlBuilder.append("sa.interest_posting_period_enum as interestPostingPeriodType, "); - sqlBuilder.append("sa.interest_calculation_type_enum as interestCalculationType, "); - sqlBuilder.append("sa.interest_calculation_days_in_year_type_enum as interestCalculationDaysInYearType, "); - sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance, "); - sqlBuilder.append("sa.lockin_period_frequency as lockinPeriodFrequency,"); - sqlBuilder.append("sa.lockin_period_frequency_enum as lockinPeriodFrequencyType, "); - sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, "); - sqlBuilder.append("sa.allow_overdraft as allowOverdraft, "); - sqlBuilder.append("sa.overdraft_limit as overdraftLimit, "); - sqlBuilder.append("sa.nominal_annual_interest_rate_overdraft as nominalAnnualInterestRateOverdraft, "); - sqlBuilder.append("sa.min_overdraft_for_interest_calculation as minOverdraftForInterestCalculation, "); - sqlBuilder.append("sa.total_deposits_derived as totalDeposits, "); - sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); - sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); - sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); - sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); - sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); - sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); - sqlBuilder.append("sa.account_balance_derived as accountBalance, "); - sqlBuilder.append("sa.total_fees_charge_derived as totalFeeCharge, "); - sqlBuilder.append("sa.total_penalty_charge_derived as totalPenaltyCharge, "); - sqlBuilder.append("sa.min_balance_for_interest_calculation as minBalanceForInterestCalculation,"); - sqlBuilder.append("sa.min_required_balance as minRequiredBalance, "); - sqlBuilder.append("sa.enforce_min_required_balance as enforceMinRequiredBalance, "); - sqlBuilder.append("sa.max_allowed_lien_limit as maxAllowedLienLimit, "); - sqlBuilder.append("sa.is_lien_allowed as lienAllowed, "); - sqlBuilder.append("sa.on_hold_funds_derived as onHoldFunds, "); - sqlBuilder.append("sa.withhold_tax as withHoldTax, "); - sqlBuilder.append("sa.total_withhold_tax_derived as totalWithholdTax, "); - sqlBuilder.append("sa.last_interest_calculation_date as lastInterestCalculationDate, "); - sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); - sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, "); - sqlBuilder.append("tg.id as taxGroupId, "); - sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); - sqlBuilder.append("from m_savings_account_transaction as sat "); - sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false "); - sqlBuilder.append("and sat.transaction_type_enum in (1,2) "); - sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, "); - sqlBuilder.append("sp.id as productId, "); - sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); - sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); - sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, "); - sqlBuilder.append("sp.days_to_escheat as daysToEscheat, "); - sqlBuilder.append("sp.accounting_type as accountingType, "); - sqlBuilder.append("tr.id as transactionId, tr.transaction_type_enum as transactionType, "); - sqlBuilder.append("tr.transaction_date as transactionDate, tr.amount as transactionAmount,"); - sqlBuilder.append("tr.submitted_on_date as transSubmittedOnDate,tr.cumulative_balance_derived as cumulativeBalance,"); - sqlBuilder.append("tr.running_balance_derived as runningBalance, tr.is_reversed as reversed,"); - sqlBuilder.append("tr.balance_end_date_derived as balanceEndDate, tr.overdraft_amount_derived as overdraftAmount,"); - sqlBuilder.append("tr.is_manual as manualTransaction,tr.office_id as officeId, "); - sqlBuilder.append("pd.payment_type_id as paymentType,pd.account_number as accountNumber,pd.check_number as checkNumber, "); - sqlBuilder.append("pd.receipt_number as receiptNumber, pd.bank_number as bankNumber,pd.routing_code as routingCode, "); - sqlBuilder.append("pt.value as paymentTypeName, "); - sqlBuilder.append("msacpb.amount as paidByAmount, msacpb.id as chargesPaidById, "); - sqlBuilder.append( - "msac.id as chargeId, msac.amount as chargeAmount, msac.charge_time_enum as chargeTimeType, msac.is_penalty as isPenaltyCharge, "); - sqlBuilder.append("txd.id as taxDetailsId, txd.amount as taxAmount, "); - sqlBuilder.append("apm.gl_account_id as glAccountIdForInterestOnSavings, apm1.gl_account_id as glAccountIdForSavingsControl, "); - sqlBuilder.append( - "apm2.gl_account_id as glAccountIdForInterestReceivable,apm3.gl_account_id as glAccountIdForOverdraftPorfolio, "); - sqlBuilder.append("apm4.gl_account_id as glAccountIdForInterestPayable, "); - sqlBuilder.append( - "mtc.id as taxComponentId, mtc.debit_account_id as debitAccountId, mtc.credit_account_id as creditAccountId, mtc.percentage as taxPercentage "); - sqlBuilder.append("from m_savings_account sa "); - sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); - sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); - sqlBuilder.append("join m_savings_account_transaction tr on sa.id = tr.savings_account_id "); - sqlBuilder.append("left join m_payment_detail pd on pd.id = tr.payment_detail_id "); - sqlBuilder.append("left join m_payment_type pt on pt.id = pd.payment_type_id "); - sqlBuilder.append("left join m_savings_account_charge_paid_by msacpb on msacpb.savings_account_transaction_id = tr.id "); - sqlBuilder.append("left join m_savings_account_charge msac on msac.id = msacpb.savings_account_charge_id "); - sqlBuilder.append("left join m_client c ON c.id = sa.client_id "); - sqlBuilder.append("left join m_group g ON g.id = sa.group_id "); - sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id "); - sqlBuilder.append("left join m_savings_account_transaction_tax_details txd on txd.savings_transaction_id = tr.id "); - sqlBuilder.append("left join m_tax_component mtc on mtc.id = txd.tax_component_id "); - sqlBuilder.append( - "left join acc_product_mapping apm on apm.product_type = 2 and apm.product_id = sp.id and apm.financial_account_type=3 "); - sqlBuilder.append( - "left join acc_product_mapping apm1 on apm1.product_type = 2 and apm1.product_id = sp.id and apm1.financial_account_type=2 "); - sqlBuilder.append("left join acc_product_mapping apm2 on apm2.product_id = sp.id and apm2.financial_account_type=18 "); - sqlBuilder.append("left join acc_product_mapping apm3 on apm3.product_id = sp.id and apm3.financial_account_type = 11 "); - sqlBuilder.append("left join acc_product_mapping apm4 on apm4.product_id = sp.id and apm4.financial_account_type = 17 "); - // Needed so this batch engine can tell a monthly-payout deposit (FD Special) from a - // cumulative one: interest that is swept out to a linked account each month must never - // compound, and without this flag the engine compounded it. Left join because ordinary - // savings accounts have no term-and-preclosure row at all. - sqlBuilder.append( - "left join m_deposit_account_term_and_preclosure datp on datp.savings_account_id = sa.id "); - - this.schemaSql = sqlBuilder.toString(); - } - - public String schema() { - return this.schemaSql; - } - - @Override - public List extractData(final ResultSet rs) throws SQLException { - - List savingsAccountDataList = new ArrayList<>(); - HashMap savingsMap = new HashMap<>(); - String currencyCode = null; - Integer currencyDigits = null; - Integer inMultiplesOf = null; - CurrencyData currency = null; - HashMap transMap = new HashMap<>(); - HashMap taxDetails = new HashMap<>(); - HashMap chargeDetails = new HashMap<>(); - SavingsAccountTransactionData savingsAccountTransactionData = null; - SavingsAccountData savingsAccountData = null; - int count = 0; - - while (rs.next()) { - final Long id = rs.getLong("id"); - final Long transactionId = rs.getLong("transactionId"); - final Long taxDetailId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxDetailsId"); - final Long taxComponentId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxComponentId"); - final String accountNo = rs.getString("accountNo"); - final Long chargeId = rs.getLong("chargeId"); - - if (!savingsMap.containsValue(id)) { - if (count > 0) { - savingsAccountDataList.add(savingsAccountData); - } - count++; - savingsMap.put("id", id); - - final String externalId = rs.getString("externalId"); - final Integer depositTypeId = rs.getInt("depositType"); - final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); - final Long groupId = JdbcSupport.getLong(rs, "groupId"); - final Long groupOfficeId = JdbcSupport.getLong(rs, "groupOfficeId"); - final GroupGeneralData groupGeneralData = new GroupGeneralData(groupId, groupOfficeId); - - final Long clientId = JdbcSupport.getLong(rs, "clientId"); - final Long clientOfficeId = JdbcSupport.getLong(rs, "clientOfficeId"); - final ClientData clientData = ClientData.createClientForInterestPosting(clientId, clientOfficeId); - - final Long glAccountIdForInterestOnSavings = rs.getLong("glAccountIdForInterestOnSavings"); - final Long glAccountIdForSavingsControl = rs.getLong("glAccountIdForSavingsControl"); - - final Long glAccountIdForOverdraftPorfolio = rs.getLong("glAccountIdForOverdraftPorfolio"); - final Long glAccountIdForInterestReceivable = rs.getLong("glAccountIdForInterestReceivable"); - - final Long glAccountIdForInterestPayable = rs.getLong("glAccountIdForInterestPayable"); - - final Long productId = rs.getLong("productId"); - final Integer accountType = rs.getInt("accountingType"); - final AccountingRuleType accountingRuleType = AccountingRuleType.fromInt(accountType); - final EnumOptionData enumOptionDataForAccounting = new EnumOptionData(accountType.longValue(), - accountingRuleType.getCode(), accountingRuleType.getValue().toString()); - final SavingsProductData savingsProductData = SavingsProductData.createForInterestPosting(productId, - enumOptionDataForAccounting); - - final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); - final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); - final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); - final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); - final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); - final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); - final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); - final Integer numDaysToDormancy = JdbcSupport.getInteger(rs, "daysToDormancy"); - final Integer numDaysToEscheat = JdbcSupport.getInteger(rs, "daysToEscheat"); - Integer daysToInactive = null; - Integer daysToDormancy = null; - Integer daysToEscheat = null; - - LocalDate currentDate = DateUtils.getBusinessLocalDate(); - if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { - if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { - daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, - lastActiveTransactionDate.plusDays(numDaysToEscheat)); - } - if (subStatusEnum < SavingsAccountSubStatusEnum.DORMANT.getValue()) { - daysToDormancy = DateUtils.getExactDifferenceInDays(currentDate, - lastActiveTransactionDate.plusDays(numDaysToDormancy)); - } - if (subStatusEnum < SavingsAccountSubStatusEnum.INACTIVE.getValue()) { - daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, - lastActiveTransactionDate.plusDays(numDaysToInactive)); - } - } - final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate"); - final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate"); - final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); - final LocalDate activatedOnDate = JdbcSupport.getLocalDate(rs, "activatedOnDate"); - final LocalDate closedOnDate = JdbcSupport.getLocalDate(rs, "closedOnDate"); - final SavingsAccountApplicationTimelineData timeline = new SavingsAccountApplicationTimelineData(submittedOnDate, null, - null, null, null, null, null, null, withdrawnOnDate, null, null, null, approvedOnDate, null, null, null, - activatedOnDate, null, null, null, closedOnDate, null, null, null); - - currencyCode = rs.getString("currencyCode"); - currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); - inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); - currency = new CurrencyData(currencyCode, currencyDigits, inMultiplesOf); - - final BigDecimal totalDeposits = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalDeposits"); - final BigDecimal totalWithdrawals = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawals"); - final BigDecimal totalWithdrawalFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawalFees"); - final BigDecimal totalAnnualFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalAnnualFees"); - - final BigDecimal totalInterestEarned = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalInterestEarned"); - final BigDecimal totalInterestPosted = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalInterestPosted"); - final BigDecimal accountBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "accountBalance"); - final BigDecimal totalFeeCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalFeeCharge"); - final BigDecimal totalPenaltyCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalPenaltyCharge"); - final BigDecimal totalOverdraftInterestDerived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, - "totalOverdraftInterestDerived"); - final BigDecimal totalWithholdTax = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithholdTax"); - final LocalDate interestPostedTillDate = JdbcSupport.getLocalDate(rs, "interestPostedTillDate"); - - final BigDecimal minBalanceForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "minBalanceForInterestCalculation"); - final BigDecimal onHoldFunds = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "onHoldFunds"); - - final BigDecimal onHoldAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "onHoldAmount"); - - BigDecimal availableBalance = accountBalance; - if (availableBalance != null && onHoldFunds != null) { - - availableBalance = availableBalance.subtract(onHoldFunds); - } - - if (availableBalance != null && onHoldAmount != null) { - - availableBalance = availableBalance.subtract(onHoldAmount); - } - - BigDecimal interestNotPosted = BigDecimal.ZERO; - LocalDate lastInterestCalculationDate = null; - if (totalInterestEarned != null) { - interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); - lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); - } - - final SavingsAccountSummaryData summary = new SavingsAccountSummaryData(currency, totalDeposits, totalWithdrawals, - totalWithdrawalFees, totalAnnualFees, totalInterestEarned, totalInterestPosted, accountBalance, totalFeeCharge, - totalPenaltyCharge, totalOverdraftInterestDerived, totalWithholdTax, interestNotPosted, - lastInterestCalculationDate, availableBalance, interestPostedTillDate); - summary.setPrevInterestPostedTillDate(interestPostedTillDate); - - final boolean withHoldTax = rs.getBoolean("withHoldTax"); - final Long taxGroupId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxGroupId"); - TaxGroupData taxGroupData = null; - if (taxGroupId != null) { - taxGroupData = TaxGroupData.lookup(taxGroupId, null); - } - - final BigDecimal nominalAnnualInterestRate = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "nominalAnnualInterestRate"); - - final EnumOptionData interestCompoundingPeriodType = SavingsEnumerations.compoundingInterestPeriodType( - SavingsCompoundingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestCompoundingPeriodType"))); - - final EnumOptionData interestPostingPeriodType = SavingsEnumerations.interestPostingPeriodType( - SavingsPostingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestPostingPeriodType"))); - - final EnumOptionData interestCalculationType = SavingsEnumerations.interestCalculationType( - SavingsInterestCalculationType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationType"))); - - final EnumOptionData interestCalculationDaysInYearType = SavingsEnumerations - .interestCalculationDaysInYearType(SavingsInterestCalculationDaysInYearType - .fromInt(JdbcSupport.getInteger(rs, "interestCalculationDaysInYearType"))); - - final BigDecimal minRequiredOpeningBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "minRequiredOpeningBalance"); - - final Integer lockinPeriodFrequency = JdbcSupport.getInteger(rs, "lockinPeriodFrequency"); - EnumOptionData lockinPeriodFrequencyType = null; - final Integer lockinPeriodFrequencyTypeValue = JdbcSupport.getInteger(rs, "lockinPeriodFrequencyType"); - if (lockinPeriodFrequencyTypeValue != null) { - final SavingsPeriodFrequencyType lockinPeriodType = SavingsPeriodFrequencyType - .fromInt(lockinPeriodFrequencyTypeValue); - lockinPeriodFrequencyType = SavingsEnumerations.lockinPeriodFrequencyType(lockinPeriodType); - } - - final boolean withdrawalFeeForTransfers = rs.getBoolean("withdrawalFeeForTransfers"); - - final boolean allowOverdraft = rs.getBoolean("allowOverdraft"); - final BigDecimal overdraftLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "overdraftLimit"); - final BigDecimal nominalAnnualInterestRateOverdraft = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "nominalAnnualInterestRateOverdraft"); - final BigDecimal minOverdraftForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "minOverdraftForInterestCalculation"); - - final BigDecimal minRequiredBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredBalance"); - final boolean enforceMinRequiredBalance = rs.getBoolean("enforceMinRequiredBalance"); - final BigDecimal maxAllowedLienLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "maxAllowedLienLimit"); - final boolean lienAllowed = rs.getBoolean("lienAllowed"); - savingsAccountData = SavingsAccountData.instance(id, accountNo, depositType, externalId, null, null, null, null, - productId, null, null, null, status, subStatus, null, timeline, currency, nominalAnnualInterestRate, - interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, - interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, - withdrawalFeeForTransfers, summary, allowOverdraft, overdraftLimit, minRequiredBalance, - enforceMinRequiredBalance, maxAllowedLienLimit, lienAllowed, minBalanceForInterestCalculation, onHoldFunds, - nominalAnnualInterestRateOverdraft, minOverdraftForInterestCalculation, withHoldTax, taxGroupData, - lastActiveTransactionDate, isDormancyTrackingActive, daysToInactive, daysToDormancy, daysToEscheat, - onHoldAmount); - - savingsAccountData.setClientData(clientData); - savingsAccountData.setGroupGeneralData(groupGeneralData); - savingsAccountData.setSavingsProduct(savingsProductData); - - savingsAccountData.setGlAccountIdForInterestReceivable(glAccountIdForInterestReceivable); - savingsAccountData.setGlAccountIdForOverdraftPorfolio(glAccountIdForOverdraftPorfolio); - - savingsAccountData.setGlAccountIdForInterestPayable(glAccountIdForInterestPayable); - - savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings); - savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl); - // Monthly-payout deposits must not compound: their interest leaves the account every - // period, so each period earns on the principal alone. - savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount")); - } - - if (!transMap.containsValue(transactionId)) { - - final int transactionTypeInt = JdbcSupport.getInteger(rs, "transactionType"); - final SavingsAccountTransactionEnumData transactionType = SavingsEnumerations.transactionType(transactionTypeInt); - - final LocalDate date = JdbcSupport.getLocalDate(rs, "transactionDate"); - final LocalDate balanceEndDate = JdbcSupport.getLocalDate(rs, "balanceEndDate"); - final LocalDate transSubmittedOnDate = JdbcSupport.getLocalDate(rs, "transSubmittedOnDate"); - final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "transactionAmount"); - final BigDecimal overdraftAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "overdraftAmount"); - final BigDecimal outstandingChargeAmount = null; - final BigDecimal runningBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "runningBalance"); - final boolean reversed = rs.getBoolean("reversed"); - final Long officeId = rs.getLong("officeId"); - final BigDecimal cumulativeBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "cumulativeBalance"); - - final boolean postInterestAsOn = false; - - PaymentDetailData paymentDetailData = null; - if (transactionType.isDepositOrWithdrawal()) { - final Long paymentTypeId = JdbcSupport.getLong(rs, "paymentType"); - if (paymentTypeId != null) { - final String typeName = rs.getString("paymentTypeName"); - final PaymentTypeData paymentTypeData = new PaymentTypeData(paymentTypeId, typeName, null, false, null, null, - false); - paymentDetailData = new PaymentDetailData(id, paymentTypeData, null, null, null, null, null); - } - } - - savingsAccountTransactionData = SavingsAccountTransactionData.create(transactionId, transactionType, paymentDetailData, - id, accountNo, date, currency, amount, outstandingChargeAmount, runningBalance, reversed, transSubmittedOnDate, - postInterestAsOn, cumulativeBalance, balanceEndDate); - savingsAccountTransactionData.setOverdraftAmount(overdraftAmount); - - transMap.put("id", transactionId); - if (savingsAccountData.getOfficeId() == null) { - savingsAccountData.setOfficeId(officeId); - } - - savingsAccountData.setSavingsAccountTransactionData(savingsAccountTransactionData); - } - - if (chargeId != null && !chargeDetails.containsValue(chargeId)) { - final boolean isPenalty = rs.getBoolean("isPenaltyCharge"); - final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "chargeAmount"); - final Integer chargesTimeType = rs.getInt("chargeTimeType"); - final EnumOptionData enumOptionDataForChargesTimeType = new EnumOptionData(chargesTimeType.longValue(), null, null); - final SavingsAccountChargeData savingsAccountChargeData = new SavingsAccountChargeData(chargeId, chargeAmount, - enumOptionDataForChargesTimeType, isPenalty); - - final Long chargesPaidById = rs.getLong("chargesPaidById"); - final BigDecimal chargesPaid = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "paidByAmount"); - final SavingsAccountChargesPaidByData savingsAccountChargesPaidByData = new SavingsAccountChargesPaidByData( - chargesPaidById, chargesPaid); - savingsAccountChargesPaidByData.setSavingsAccountChargeData(savingsAccountChargeData); - if (savingsAccountChargesPaidByData != null) { - savingsAccountTransactionData.setChargesPaidByData(savingsAccountChargesPaidByData); - } - - chargeDetails.put("id", chargeId); - } - - if (taxDetailId != null && !taxDetails.containsValue(taxDetailId)) { - final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "taxAmount"); - final BigDecimal percentage = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "taxPercentage"); - final Long debitId = rs.getLong("debitAccountId"); - final Long creditId = rs.getLong("creditAccountId"); - final GLAccountData debitAccount = GLAccountData.createFrom(debitId); - final GLAccountData creditAccount = GLAccountData.createFrom(creditId); - - if (taxComponentId != null) { - final TaxComponentData taxComponent = TaxComponentData.createTaxComponent(taxComponentId, percentage, debitAccount, - creditAccount); - savingsAccountTransactionData.setTaxDetails(new TaxDetailsData(taxComponent, amount)); - } - - taxDetails.put("id", taxDetailId); - } - - } - if (savingsAccountData != null) { - savingsAccountDataList.add(savingsAccountData); - } - return savingsAccountDataList; - - } - } - - private static final class SavingAccountMapper implements RowMapper { - - private final String schemaSql; - - SavingAccountMapper() { - final StringBuilder sqlBuilder = new StringBuilder(400); - sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, "); - sqlBuilder.append("sa.deposit_type_enum as depositType, "); - sqlBuilder.append("c.id as clientId, c.display_name as clientName, "); - sqlBuilder.append("g.id as groupId, g.display_name as groupName, "); - sqlBuilder.append("sp.id as productId, sp.name as productName, "); - sqlBuilder.append("s.id fieldOfficerId, s.display_name as fieldOfficerName, "); - sqlBuilder.append("sa.status_enum as statusEnum, "); - sqlBuilder.append("sa.sub_status_enum as subStatusEnum, "); - sqlBuilder.append("sa.reason_for_block as reasonForBlock, "); - sqlBuilder.append("sa.submittedon_date as submittedOnDate,"); - sqlBuilder.append("sbu.username as submittedByUsername,"); - sqlBuilder.append("sbu.firstname as submittedByFirstname, sbu.lastname as submittedByLastname,"); - - sqlBuilder.append("sa.rejectedon_date as rejectedOnDate,"); - sqlBuilder.append("rbu.username as rejectedByUsername,"); - sqlBuilder.append("rbu.firstname as rejectedByFirstname, rbu.lastname as rejectedByLastname,"); - - sqlBuilder.append("sa.withdrawnon_date as withdrawnOnDate,"); - sqlBuilder.append("wbu.username as withdrawnByUsername,"); - sqlBuilder.append("wbu.firstname as withdrawnByFirstname, wbu.lastname as withdrawnByLastname,"); - - sqlBuilder.append("sa.approvedon_date as approvedOnDate,"); - sqlBuilder.append("abu.username as approvedByUsername,"); - sqlBuilder.append("abu.firstname as approvedByFirstname, abu.lastname as approvedByLastname,"); - - sqlBuilder.append("sa.activatedon_date as activatedOnDate,"); - sqlBuilder.append("avbu.username as activatedByUsername,"); - sqlBuilder.append("avbu.firstname as activatedByFirstname, avbu.lastname as activatedByLastname,"); - - sqlBuilder.append("sa.closedon_date as closedOnDate,"); - sqlBuilder.append("cbu.username as closedByUsername,"); - sqlBuilder.append("cbu.firstname as closedByFirstname, cbu.lastname as closedByLastname,"); - - sqlBuilder.append( - "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); - sqlBuilder.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); - sqlBuilder.append("curr.display_symbol as currencyDisplaySymbol, "); - - sqlBuilder.append("sa.nominal_annual_interest_rate as nominalAnnualInterestRate, "); - sqlBuilder.append("sa.interest_compounding_period_enum as interestCompoundingPeriodType, "); - sqlBuilder.append("sa.interest_posting_period_enum as interestPostingPeriodType, "); - sqlBuilder.append("sa.interest_calculation_type_enum as interestCalculationType, "); - sqlBuilder.append("sa.interest_calculation_days_in_year_type_enum as interestCalculationDaysInYearType, "); - sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance, "); - sqlBuilder.append("sa.lockin_period_frequency as lockinPeriodFrequency,"); - sqlBuilder.append("sa.lockin_period_frequency_enum as lockinPeriodFrequencyType, "); - - sqlBuilder.append("sa.allow_overdraft as allowOverdraft, "); - sqlBuilder.append("sa.overdraft_limit as overdraftLimit, "); - sqlBuilder.append("sa.nominal_annual_interest_rate_overdraft as nominalAnnualInterestRateOverdraft, "); - sqlBuilder.append("sa.min_overdraft_for_interest_calculation as minOverdraftForInterestCalculation, "); - - sqlBuilder.append("sa.total_deposits_derived as totalDeposits, "); - sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); - sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); - sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); - sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); - sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); - sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); - sqlBuilder.append("sa.account_balance_derived as accountBalance, "); - sqlBuilder.append("sa.total_fees_charge_derived as totalFeeCharge, "); - sqlBuilder.append("sa.total_penalty_charge_derived as totalPenaltyCharge, "); - sqlBuilder.append("sa.min_balance_for_interest_calculation as minBalanceForInterestCalculation,"); - sqlBuilder.append("sa.min_required_balance as minRequiredBalance, "); - sqlBuilder.append("sa.enforce_min_required_balance as enforceMinRequiredBalance, "); - sqlBuilder.append("sa.max_allowed_lien_limit as maxAllowedLienLimit, "); - sqlBuilder.append("sa.is_lien_allowed as lienAllowed, "); - sqlBuilder.append("sa.on_hold_funds_derived as onHoldFunds, "); - sqlBuilder.append("sa.withhold_tax as withHoldTax, "); - sqlBuilder.append("sa.total_withhold_tax_derived as totalWithholdTax, "); - sqlBuilder.append("sa.last_interest_calculation_date as lastInterestCalculationDate, "); - sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, "); - sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); - sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, "); - sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName, "); - sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); - sqlBuilder.append("from m_savings_account_transaction as sat "); - sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false "); - sqlBuilder.append("and sat.transaction_type_enum in (1,2) "); - sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, "); - sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); - sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); - sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, "); - sqlBuilder.append("sp.days_to_escheat as daysToEscheat, "); - // CBS extended fields - sqlBuilder.append("sa.cbs_account_type as cbsAccountType, "); - sqlBuilder.append("sa.customer_segment as customerSegment, "); - sqlBuilder.append("sa.account_status as accountStatus, "); - sqlBuilder.append("sa.mode_of_operation as modeOfOperation, "); - sqlBuilder.append("sa.special_category as specialCategory, "); - sqlBuilder.append("sa.weaker_section as weakerSection, "); - sqlBuilder.append("sa.guardian_name as guardianName, "); - sqlBuilder.append("sa.guardian_cif_number as guardianCifNumber, "); - sqlBuilder.append("sa.guardian_relationship as guardianRelationship, "); - sqlBuilder.append("sa.minor_date_of_birth as minorDateOfBirth, "); - sqlBuilder.append("sa.guardian_client_id as guardianClientId, "); - sqlBuilder.append("sa.introducer_client_id as introducerClientId, "); - sqlBuilder.append("sa.introducer_name as introducerName, "); - sqlBuilder.append("sa.introducer_cif_number as introducerCifNumber, "); - sqlBuilder.append("sa.nominee_data as nomineeData, "); - sqlBuilder.append("sa.linked_account_id as linkedAccountId, "); - sqlBuilder.append("sa.linked_account_type as linkedAccountType, "); - sqlBuilder.append("sa.linked_account_no as linkedAccountNo, "); - sqlBuilder.append("sp.balance_swap_option as balanceSwapOption, "); - sqlBuilder.append("sp.msme as msme, "); - sqlBuilder.append("COALESCE(sa.nomination_facility, sp.nomination_facility) as nominationFacility, "); - sqlBuilder.append("COALESCE(sa.auto_debit_facility, sp.auto_debit_facility) as autoDebitFacility, "); - sqlBuilder.append("COALESCE(sa.introducer_required, sp.introducer_required) as introducerRequired, "); - sqlBuilder.append("COALESCE(sa.share_linking_enabled, sp.share_linking_enabled) as shareLinkingEnabled, "); - sqlBuilder.append("sa.share_account_id as shareAccountId, msha.account_no as shareAccountNo, "); - // Facility config fields - sqlBuilder.append("safc.atm_facility as atmFacility, "); - sqlBuilder.append("safc.atm_withdrawal_facility as atmWithdrawalFacility, "); - sqlBuilder.append("safc.upi_facility as upiFacility, "); - sqlBuilder.append("safc.pos_transactions_facility as posTransactionsFacility, "); - sqlBuilder.append("safc.cheque_book as chequeBook, "); - sqlBuilder.append("safc.imps_facility as impsFacility, "); - sqlBuilder.append("safc.sms_alerts as smsAlerts, "); - sqlBuilder.append("safc.bbps_facility as bbpsFacility, "); - sqlBuilder.append("safc.insurance_cover as insuranceCover, "); - sqlBuilder.append("safc.bepg_facility as bepgFacility, "); - sqlBuilder.append("safc.neft_facility as neftFacility, "); - sqlBuilder.append("safc.rtgs_facility as rtgsFacility, "); - 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.virtual_debit_card as virtualDebitCard, "); - sqlBuilder.append("sa.terminate_true as terminateTrue "); - sqlBuilder.append("from m_savings_account sa "); - sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); - sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); - sqlBuilder.append("left join m_client c ON c.id = sa.client_id "); - sqlBuilder.append("left join m_group g ON g.id = sa.group_id "); - sqlBuilder.append("left join m_staff s ON s.id = sa.field_officer_id "); - sqlBuilder.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid "); - sqlBuilder.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid "); - sqlBuilder.append("left join m_appuser wbu on wbu.id = sa.withdrawnon_userid "); - sqlBuilder.append("left join m_appuser abu on abu.id = sa.approvedon_userid "); - sqlBuilder.append("left join m_appuser avbu on avbu.id = sa.activatedon_userid "); - sqlBuilder.append("left join m_appuser cbu on cbu.id = sa.closedon_userid "); - sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id "); - sqlBuilder.append("left join m_savings_account_facility_config safc on safc.savings_account_id = sa.id "); - sqlBuilder.append("left join m_share_account msha on msha.id = sa.share_account_id "); - - this.schemaSql = sqlBuilder.toString(); - } - - public String schema() { - return this.schemaSql; - } - - @Override - public SavingsAccountData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { - - final Long id = rs.getLong("id"); - final String accountNo = rs.getString("accountNo"); - final String externalId = rs.getString("externalId"); - final Integer depositTypeId = rs.getInt("depositType"); - final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); - - final Long groupId = JdbcSupport.getLong(rs, "groupId"); - final String groupName = rs.getString("groupName"); - - final Long clientId = JdbcSupport.getLong(rs, "clientId"); - final String clientName = rs.getString("clientName"); - - final Long productId = rs.getLong("productId"); - final String productName = rs.getString("productName"); - - final Long fieldOfficerId = rs.getLong("fieldOfficerId"); - final String fieldOfficerName = rs.getString("fieldOfficerName"); - - final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); - final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); - - final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); - final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); - final String reasonForBlock = rs.getString("reasonForBlock"); - - final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); - final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); - final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); - final Integer numDaysToDormancy = JdbcSupport.getInteger(rs, "daysToDormancy"); - final Integer numDaysToEscheat = JdbcSupport.getInteger(rs, "daysToEscheat"); - Integer daysToInactive = null; - Integer daysToDormancy = null; - Integer daysToEscheat = null; - - LocalDate currentDate = DateUtils.getBusinessLocalDate(); - if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { - if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { - daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat)); - } - if (subStatusEnum < SavingsAccountSubStatusEnum.DORMANT.getValue()) { - daysToDormancy = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToDormancy)); - } - if (subStatusEnum < SavingsAccountSubStatusEnum.INACTIVE.getValue()) { - daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToInactive)); - } - } - - final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); - final String submittedByUsername = rs.getString("submittedByUsername"); - final String submittedByFirstname = rs.getString("submittedByFirstname"); - final String submittedByLastname = rs.getString("submittedByLastname"); - - final LocalDate rejectedOnDate = JdbcSupport.getLocalDate(rs, "rejectedOnDate"); - final String rejectedByUsername = rs.getString("rejectedByUsername"); - final String rejectedByFirstname = rs.getString("rejectedByFirstname"); - final String rejectedByLastname = rs.getString("rejectedByLastname"); - - final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate"); - final String withdrawnByUsername = rs.getString("withdrawnByUsername"); - final String withdrawnByFirstname = rs.getString("withdrawnByFirstname"); - final String withdrawnByLastname = rs.getString("withdrawnByLastname"); - - final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate"); - final String approvedByUsername = rs.getString("approvedByUsername"); - final String approvedByFirstname = rs.getString("approvedByFirstname"); - final String approvedByLastname = rs.getString("approvedByLastname"); - - final LocalDate activatedOnDate = JdbcSupport.getLocalDate(rs, "activatedOnDate"); - final String activatedByUsername = rs.getString("activatedByUsername"); - final String activatedByFirstname = rs.getString("activatedByFirstname"); - final String activatedByLastname = rs.getString("activatedByLastname"); - - final LocalDate closedOnDate = JdbcSupport.getLocalDate(rs, "closedOnDate"); - final String closedByUsername = rs.getString("closedByUsername"); - final String closedByFirstname = rs.getString("closedByFirstname"); - final String closedByLastname = rs.getString("closedByLastname"); - - final SavingsAccountApplicationTimelineData timeline = new SavingsAccountApplicationTimelineData(submittedOnDate, - submittedByUsername, submittedByFirstname, submittedByLastname, rejectedOnDate, rejectedByUsername, rejectedByFirstname, - rejectedByLastname, withdrawnOnDate, withdrawnByUsername, withdrawnByFirstname, withdrawnByLastname, approvedOnDate, - approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname, - activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname); - - final String currencyCode = rs.getString("currencyCode"); - final String currencyName = rs.getString("currencyName"); - final String currencyNameCode = rs.getString("currencyNameCode"); - final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); - final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); - final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); - final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, - currencyNameCode); - - final BigDecimal nominalAnnualInterestRate = rs.getBigDecimal("nominalAnnualInterestRate"); - - final EnumOptionData interestCompoundingPeriodType = SavingsEnumerations.compoundingInterestPeriodType( - SavingsCompoundingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestCompoundingPeriodType"))); - - final EnumOptionData interestPostingPeriodType = SavingsEnumerations.interestPostingPeriodType( - SavingsPostingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestPostingPeriodType"))); - - final EnumOptionData interestCalculationType = SavingsEnumerations - .interestCalculationType(SavingsInterestCalculationType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationType"))); - - final EnumOptionData interestCalculationDaysInYearType = SavingsEnumerations.interestCalculationDaysInYearType( - SavingsInterestCalculationDaysInYearType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationDaysInYearType"))); - - final BigDecimal minRequiredOpeningBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredOpeningBalance"); - - final Integer lockinPeriodFrequency = JdbcSupport.getInteger(rs, "lockinPeriodFrequency"); - EnumOptionData lockinPeriodFrequencyType = null; - final Integer lockinPeriodFrequencyTypeValue = JdbcSupport.getInteger(rs, "lockinPeriodFrequencyType"); - if (lockinPeriodFrequencyTypeValue != null) { - final SavingsPeriodFrequencyType lockinPeriodType = SavingsPeriodFrequencyType.fromInt(lockinPeriodFrequencyTypeValue); - lockinPeriodFrequencyType = SavingsEnumerations.lockinPeriodFrequencyType(lockinPeriodType); - } - - final boolean withdrawalFeeForTransfers = rs.getBoolean("withdrawalFeeForTransfers"); - - final boolean allowOverdraft = rs.getBoolean("allowOverdraft"); - final BigDecimal overdraftLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "overdraftLimit"); - final BigDecimal nominalAnnualInterestRateOverdraft = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "nominalAnnualInterestRateOverdraft"); - final BigDecimal minOverdraftForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "minOverdraftForInterestCalculation"); - - final BigDecimal minRequiredBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredBalance"); - final boolean enforceMinRequiredBalance = rs.getBoolean("enforceMinRequiredBalance"); - - final BigDecimal maxAllowedLienLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "maxAllowedLienLimit"); - final boolean lienAllowed = rs.getBoolean("lienAllowed"); - - final BigDecimal totalDeposits = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalDeposits"); - final BigDecimal totalWithdrawals = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawals"); - final BigDecimal totalWithdrawalFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawalFees"); - final BigDecimal totalAnnualFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalAnnualFees"); - - final BigDecimal totalInterestEarned = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalInterestEarned"); - final BigDecimal totalInterestPosted = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalInterestPosted"); - final BigDecimal accountBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "accountBalance"); - final BigDecimal totalFeeCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalFeeCharge"); - final BigDecimal totalPenaltyCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalPenaltyCharge"); - final BigDecimal totalOverdraftInterestDerived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, - "totalOverdraftInterestDerived"); - final BigDecimal totalWithholdTax = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithholdTax"); - final LocalDate interestPostedTillDate = JdbcSupport.getLocalDate(rs, "interestPostedTillDate"); - - final BigDecimal minBalanceForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, - "minBalanceForInterestCalculation"); - final BigDecimal onHoldFunds = rs.getBigDecimal("onHoldFunds"); - - final BigDecimal onHoldAmount = rs.getBigDecimal("onHoldAmount"); - - BigDecimal availableBalance = accountBalance; - if (availableBalance != null && onHoldFunds != null) { - availableBalance = availableBalance.subtract(onHoldFunds); - } - - if (availableBalance != null && onHoldAmount != null) { - - availableBalance = availableBalance.subtract(onHoldAmount); - } - - BigDecimal interestNotPosted = BigDecimal.ZERO; - LocalDate lastInterestCalculationDate = null; - if (totalInterestEarned != null) { - interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); - lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); - } - - final SavingsAccountSummaryData summary = new SavingsAccountSummaryData(currency, totalDeposits, totalWithdrawals, - totalWithdrawalFees, totalAnnualFees, totalInterestEarned, totalInterestPosted, accountBalance, totalFeeCharge, - totalPenaltyCharge, totalOverdraftInterestDerived, totalWithholdTax, interestNotPosted, lastInterestCalculationDate, - availableBalance, interestPostedTillDate); - - final boolean withHoldTax = rs.getBoolean("withHoldTax"); - final Long taxGroupId = JdbcSupport.getLong(rs, "taxGroupId"); - final String taxGroupName = rs.getString("taxGroupName"); - TaxGroupData taxGroupData = null; - if (taxGroupId != null) { - taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName); - } - - final SavingsAccountData accountData = SavingsAccountData.instance(id, accountNo, depositType, externalId, groupId, groupName, - clientId, clientName, productId, productName, fieldOfficerId, fieldOfficerName, status, subStatus, reasonForBlock, - timeline, currency, nominalAnnualInterestRate, interestCompoundingPeriodType, interestPostingPeriodType, - interestCalculationType, interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, - lockinPeriodFrequencyType, withdrawalFeeForTransfers, summary, allowOverdraft, overdraftLimit, minRequiredBalance, - enforceMinRequiredBalance, maxAllowedLienLimit, lienAllowed, minBalanceForInterestCalculation, onHoldFunds, - nominalAnnualInterestRateOverdraft, minOverdraftForInterestCalculation, withHoldTax, taxGroupData, - lastActiveTransactionDate, isDormancyTrackingActive, daysToInactive, daysToDormancy, daysToEscheat, onHoldAmount); - - // CBS extended fields - accountData.setCbsAccountType(rs.getString("cbsAccountType")); - accountData.setCustomerSegment(rs.getString("customerSegment")); - accountData.setAccountStatus(rs.getString("accountStatus")); - accountData.setModeOfOperation(rs.getString("modeOfOperation")); - accountData.setSpecialCategory(rs.getString("specialCategory")); - accountData.setWeakerSection(rs.getString("weakerSection")); - accountData.setGuardianName(rs.getString("guardianName")); - accountData.setGuardianCifNumber(rs.getString("guardianCifNumber")); - accountData.setGuardianRelationship(rs.getString("guardianRelationship")); - accountData.setMinorDateOfBirth(JdbcSupport.getLocalDate(rs, "minorDateOfBirth")); - accountData.setGuardianClientId(JdbcSupport.getLong(rs, "guardianClientId")); - accountData.setIntroducerClientId(JdbcSupport.getLong(rs, "introducerClientId")); - accountData.setIntroducerName(rs.getString("introducerName")); - accountData.setIntroducerCifNumber(rs.getString("introducerCifNumber")); - accountData.setLinkedAccountId(JdbcSupport.getLong(rs, "linkedAccountId")); - accountData.setLinkedAccountType(rs.getString("linkedAccountType")); - accountData.setLinkedAccountNo(rs.getString("linkedAccountNo")); - accountData.setBalanceSwapOption(rs.getObject("balanceSwapOption") != null ? rs.getBoolean("balanceSwapOption") : null); - accountData.setMsme(rs.getObject("msme") != null ? rs.getBoolean("msme") : null); - accountData.setNominationFacility(rs.getObject("nominationFacility") != null ? rs.getBoolean("nominationFacility") : null); - accountData.setAutoDebitFacility(rs.getObject("autoDebitFacility") != null ? rs.getBoolean("autoDebitFacility") : null); - accountData.setIntroducerRequired(rs.getObject("introducerRequired") != null ? rs.getBoolean("introducerRequired") : null); - accountData.setShareLinkingEnabled(rs.getObject("shareLinkingEnabled") != null ? rs.getBoolean("shareLinkingEnabled") : null); - accountData.setShareAccountId(JdbcSupport.getLong(rs, "shareAccountId")); - accountData.setShareAccountNo(rs.getString("shareAccountNo")); - accountData.setTerminateTrue(rs.getObject("terminateTrue") != null ? rs.getBoolean("terminateTrue") : null); - - // Nominee as parsed map - final String nomineeDataStr = rs.getString("nomineeData"); - if (nomineeDataStr != null && !nomineeDataStr.isEmpty()) { - try { - com.google.gson.Gson gson = new com.google.gson.Gson(); - @SuppressWarnings("unchecked") - java.util.Map nomineeMap = gson.fromJson(nomineeDataStr, - new com.google.gson.reflect.TypeToken>() {}.getType()); - accountData.setNomineeData(nomineeMap); - } catch (Exception ignored) { - // intentionally ignored - } - } - - // Facility config - final boolean hasFacilityConfig = rs.getObject("atmFacility") != null; - if (hasFacilityConfig) { - final java.util.Map facilityConfigMap = new java.util.LinkedHashMap<>(); - facilityConfigMap.put("atmFacility", rs.getBoolean("atmFacility")); - facilityConfigMap.put("atmWithdrawalFacility", rs.getBoolean("atmWithdrawalFacility")); - facilityConfigMap.put("upiFacility", rs.getBoolean("upiFacility")); - facilityConfigMap.put("posTransactionsFacility", rs.getBoolean("posTransactionsFacility")); - facilityConfigMap.put("chequeBook", rs.getBoolean("chequeBook")); - facilityConfigMap.put("impsFacility", rs.getBoolean("impsFacility")); - facilityConfigMap.put("smsAlerts", rs.getBoolean("smsAlerts")); - facilityConfigMap.put("bbpsFacility", rs.getBoolean("bbpsFacility")); - facilityConfigMap.put("insuranceCover", rs.getBoolean("insuranceCover")); - facilityConfigMap.put("bepgFacility", rs.getBoolean("bepgFacility")); - facilityConfigMap.put("neftFacility", rs.getBoolean("neftFacility")); - facilityConfigMap.put("rtgsFacility", rs.getBoolean("rtgsFacility")); - facilityConfigMap.put("interBankTotalCapFacility", rs.getBoolean("interBankTotalCapFacility")); - facilityConfigMap.put("withdrawalFacility", rs.getBoolean("withdrawalFacility")); - facilityConfigMap.put("depositFacilityKyc", rs.getBoolean("depositFacilityKyc")); - facilityConfigMap.put("virtualDebitCard", rs.getBoolean("virtualDebitCard")); - accountData.setAccountFacilityConfig(facilityConfigMap); - } - - return accountData; - } - } - - private static final class SavingAccountMapperForLookup implements RowMapper { - - private final String schemaSql; - - SavingAccountMapperForLookup() { - final StringBuilder sqlBuilder = new StringBuilder(400); - sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); - sqlBuilder.append("sa.deposit_type_enum as depositType, "); - sqlBuilder.append("sp.id as productId, sp.name as productName, "); - sqlBuilder.append("sa.status_enum as statusEnum "); - - sqlBuilder.append("from m_savings_account sa "); - sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); - - this.schemaSql = sqlBuilder.toString(); - } - - public String schema() { - return this.schemaSql; - } - - @Override - public SavingsAccountData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { - - final Long id = rs.getLong("id"); - final String accountNo = rs.getString("accountNo"); - final Integer depositTypeId = rs.getInt("depositType"); - final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); - - final Long productId = rs.getLong("productId"); - final String productName = rs.getString("productName"); - - final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); - final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); - - return SavingsAccountData.lookupWithProductDetails(id, accountNo, depositType, productId, productName, status); - } - } - - @Override - public SavingsAccountTransactionData retrieveDepositTransactionTemplate(final Long savingsId, - final DepositAccountType depositAccountType) { - - try { - final String sql = "select " + this.transactionTemplateMapper.schema() + " where sa.id = ? and sa.deposit_type_enum = ?"; - - return this.jdbcTemplate.queryForObject(sql, this.transactionTemplateMapper, // NOSONAR - new Object[] { savingsId, depositAccountType.getValue() }); - } catch (final EmptyResultDataAccessException e) { - throw new SavingsAccountNotFoundException(savingsId, e); - } - } - - @Override - public Collection retrieveAllTransactions(final Long savingsId, DepositAccountType depositAccountType) { - // #audit backward compatibility - final String sql = "select " + this.transactionsMapper.schema() - + " where sa.id = ? and sa.deposit_type_enum = ? order by tr.transaction_date DESC," + " tr." + CREATED_DATE_DB_FIELD - + " DESC, tr.created_date DESC, tr.id DESC"; - return this.jdbcTemplate.query(sql, this.transactionsMapper, new Object[] { savingsId, depositAccountType.getValue() }); // NOSONAR - } - - @Override - public SavingsAccountTransactionData retrieveSavingsTransaction(final Long savingsId, final Long transactionId, - DepositAccountType depositAccountType) { - - final String sql = "select " + this.transactionsMapper.schema() + " where sa.id = ? and sa.deposit_type_enum = ? and tr.id= ?"; - - return this.jdbcTemplate.queryForObject(sql, this.transactionsMapper, // NOSONAR - new Object[] { savingsId, depositAccountType.getValue(), transactionId }); - } - - private static final class SavingsAccountTransactionsForBatchMapper implements RowMapper { - - private final String schemaSql; - - SavingsAccountTransactionsForBatchMapper() { - - final StringBuilder sqlBuilder = new StringBuilder(400); - sqlBuilder.append("tr.id as transactionId, tr.ref_no as refNo "); - sqlBuilder.append("from m_savings_account_transaction tr"); - - this.schemaSql = sqlBuilder.toString(); - } - - public String schema() { - return this.schemaSql; - } - - @Override - public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { - final Long id = rs.getLong("transactionId"); - SavingsAccountTransactionData savingsAccountTransactionData = SavingsAccountTransactionData.create(id); - final String refNo = rs.getString("refNo"); - savingsAccountTransactionData.setRefNo(refNo); - return savingsAccountTransactionData; - } - } - - /* - * @Override public Collection retrieveAccountsWithAnnualFeeDue() { final String sql = - * "select " + this.annualFeeMapper.schema() + - * " where sa.annual_fee_next_due_date is not null and sa.annual_fee_next_due_date <= NOW()" ; - * - * return this.jdbcTemplate.query(sql, this.annualFeeMapper, new Object[] {}); } - */ - - public static class SavingsAccountTransactionsMapper implements RowMapper { - - private static final String SELECT = buildSelect(); - private static final String FROM = buildFrom(); - private static final String SCHEMA = SELECT + FROM; - - public SavingsAccountTransactionsMapper() {} - - protected static String buildSelect() { - return "tr.id as transactionId, tr.transaction_type_enum as transactionType, " - + "tr.transaction_date as transactionDate, tr.amount as transactionAmount, " - + "tr.release_id_of_hold_amount as releaseTransactionId, tr.reason_for_block as reasonForBlock, " - + "tr.submitted_on_date as submittedOnDate, au.username as submittedByUsername, nt.note as transactionNote, " - + "tr.running_balance_derived as runningBalance, tr.is_reversed as reversed, " - + "tr.is_reversal as isReversal, tr.original_transaction_id as originalTransactionId, tr.is_lien_transaction as lienTransaction, " - + "fromtran.id as fromTransferId, fromtran.is_reversed as fromTransferReversed, " - + "fromtran.transaction_date as fromTransferDate, fromtran.amount as fromTransferAmount, " - + "fromtran.description as fromTransferDescription, " - + "totran.id as toTransferId, totran.is_reversed as toTransferReversed, " - + "totran.transaction_date as toTransferDate, totran.amount as toTransferAmount, " - + "totran.description as toTransferDescription, sa.id as savingsId, sa.account_no as accountNo, " - + "pd.payment_type_id as paymentType,pd.account_number as accountNumber,pd.check_number as checkNumber, " - + "pd.receipt_number as receiptNumber, pd.bank_number as bankNumber,pd.routing_code as routingCode, " - + "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, " - + "curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, " - + "curr.display_symbol as currencyDisplaySymbol, pt.value as paymentTypeName, " + "tr.is_manual as postInterestAsOn "; - } - - protected static String buildFrom() { - return " FROM m_savings_account_transaction tr join m_savings_account sa on tr.savings_account_id = sa.id " - + "join m_currency curr on curr.code = sa.currency_code " - + "left join m_account_transfer_transaction fromtran on fromtran.from_savings_transaction_id = tr.id " - + "left join m_account_transfer_transaction totran on totran.to_savings_transaction_id = tr.id " - + "left join m_payment_detail pd on tr.payment_detail_id = pd.id " - + "left join m_payment_type pt on pd.payment_type_id = pt.id left join m_appuser au on au.id= tr." + CREATED_BY_DB_FIELD - + " left join m_note nt ON nt.savings_account_transaction_id=tr.id "; - } - - public String schema() { - return SCHEMA; - } - - public String select() { - return SELECT; - } - - public String from() { - return FROM; - } - - @Override - public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { - final Long id = rs.getLong("transactionId"); - final int transactionTypeInt = JdbcSupport.getInteger(rs, "transactionType"); - final SavingsAccountTransactionEnumData transactionType = SavingsEnumerations.transactionType(transactionTypeInt); - - final LocalDate date = JdbcSupport.getLocalDate(rs, "transactionDate"); - final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); - final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "transactionAmount"); - final Long releaseTransactionId = rs.getLong("releaseTransactionId"); - final String reasonForBlock = rs.getString("reasonForBlock"); - final BigDecimal outstandingChargeAmount = null; - final BigDecimal runningBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "runningBalance"); - final boolean reversed = rs.getBoolean("reversed"); - final boolean isReversal = rs.getBoolean("isReversal"); - final Long originalTransactionId = rs.getLong("originalTransactionId"); - final Boolean lienTransaction = rs.getBoolean("lienTransaction"); - - final Long savingsId = rs.getLong("savingsId"); - final String accountNo = rs.getString("accountNo"); - final boolean postInterestAsOn = rs.getBoolean("postInterestAsOn"); - - PaymentDetailData paymentDetailData = null; - if (transactionType.isDepositOrWithdrawal()) { - final Long paymentTypeId = JdbcSupport.getLong(rs, "paymentType"); - if (paymentTypeId != null) { - final String typeName = rs.getString("paymentTypeName"); - final PaymentTypeData paymentType = PaymentTypeData.builder().id(paymentTypeId).name(typeName).build(); - final String accountNumber = rs.getString("accountNumber"); - final String checkNumber = rs.getString("checkNumber"); - final String routingCode = rs.getString("routingCode"); - final String receiptNumber = rs.getString("receiptNumber"); - final String bankNumber = rs.getString("bankNumber"); - paymentDetailData = new PaymentDetailData(id, paymentType, accountNumber, checkNumber, routingCode, receiptNumber, - bankNumber); - } - } - - final String currencyCode = rs.getString("currencyCode"); - final String currencyName = rs.getString("currencyName"); - final String currencyNameCode = rs.getString("currencyNameCode"); - final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); - final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); - final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); - final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, - currencyNameCode); - - AccountTransferData transfer = null; - final Long fromTransferId = JdbcSupport.getLong(rs, "fromTransferId"); - final Long toTransferId = JdbcSupport.getLong(rs, "toTransferId"); - if (fromTransferId != null) { - final LocalDate fromTransferDate = JdbcSupport.getLocalDate(rs, "fromTransferDate"); - final BigDecimal fromTransferAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "fromTransferAmount"); - final boolean fromTransferReversed = rs.getBoolean("fromTransferReversed"); - final String fromTransferDescription = rs.getString("fromTransferDescription"); - - transfer = AccountTransferData.transferBasicDetails(fromTransferId, currency, fromTransferAmount, fromTransferDate, - fromTransferDescription, fromTransferReversed); - } else if (toTransferId != null) { - final LocalDate toTransferDate = JdbcSupport.getLocalDate(rs, "toTransferDate"); - final BigDecimal toTransferAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "toTransferAmount"); - final boolean toTransferReversed = rs.getBoolean("toTransferReversed"); - final String toTransferDescription = rs.getString("toTransferDescription"); - - transfer = AccountTransferData.transferBasicDetails(toTransferId, currency, toTransferAmount, toTransferDate, - toTransferDescription, toTransferReversed); - } - final String submittedByUsername = rs.getString("submittedByUsername"); - final String note = rs.getString("transactionNote"); - return SavingsAccountTransactionData.create(id, transactionType, paymentDetailData, savingsId, accountNo, date, currency, - amount, outstandingChargeAmount, runningBalance, reversed, transfer, submittedOnDate, postInterestAsOn, - submittedByUsername, note, isReversal, originalTransactionId, lienTransaction, releaseTransactionId, reasonForBlock); - } - } - - private static final class SavingsAccountTransactionTemplateMapper implements RowMapper { - - private final String schemaSql; - - SavingsAccountTransactionTemplateMapper() { - final StringBuilder sqlBuilder = new StringBuilder(400); - sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); - sqlBuilder.append( - "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); - sqlBuilder.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); - sqlBuilder.append("curr.display_symbol as currencyDisplaySymbol, "); - sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance "); - sqlBuilder.append("from m_savings_account sa "); - sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); - - this.schemaSql = sqlBuilder.toString(); - } - - public String schema() { - return this.schemaSql; - } - - @Override - public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { - - final Long savingsId = rs.getLong("id"); - final String accountNo = rs.getString("accountNo"); - - final String currencyCode = rs.getString("currencyCode"); - final String currencyName = rs.getString("currencyName"); - final String currencyNameCode = rs.getString("currencyNameCode"); - final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); - final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); - final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); - final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, - currencyNameCode); - - return SavingsAccountTransactionData.template(savingsId, accountNo, DateUtils.getBusinessLocalDate(), currency); - } - } - - @Override - public Collection retrieveForLookup(Long clientId, Boolean overdraft) { - - SavingAccountMapperForLookup accountMapperForLookup = new SavingAccountMapperForLookup(); - final StringBuilder sqlBuilder = new StringBuilder("select " + accountMapperForLookup.schema()); - sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300"); - Object[] queryParameters = null; - if (overdraft == null) { - queryParameters = new Object[] { clientId }; - } else { - sqlBuilder.append(" and sa.allow_overdraft = ?"); - queryParameters = new Object[] { clientId, overdraft }; - } - return this.jdbcTemplate.query(sqlBuilder.toString(), accountMapperForLookup, queryParameters); - - } - - @Override - public List retrieveSavingsIdsPendingInactive(LocalDate tenantLocalDate) { - List ret = null; - StringBuilder sql = new StringBuilder("select sa.id "); - sql.append(" from m_savings_account as sa "); - sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); - sql.append(" where sa.status_enum = 300 "); - sql.append(" and sa.sub_status_enum = 0 "); - String compareDate = "(select COALESCE(max(sat.transaction_date), sa.activatedon_date) " - + "from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false" - + " and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)"; - sql.append(" and ").append(sqlGenerator.dateDiff("?", compareDate)).append(" >= sp.days_to_inactive "); - - try { - ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class); - } catch (EmptyResultDataAccessException e) { - // ignore empty result scenario - } catch (DataAccessException e) { - throw e; - } - - return ret; - } - - @Override - public List retrieveSavingsIdsPendingDormant(LocalDate tenantLocalDate) { - List ret = null; - StringBuilder sql = new StringBuilder("select sa.id "); - sql.append(" from m_savings_account as sa "); - sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); - sql.append(" where sa.status_enum = 300 "); - sql.append(" and sa.sub_status_enum = 100 "); - sql.append(" and " + sqlGenerator.dateDiff("?", - "(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") - + " "); - sql.append(" >= sp.days_to_dormancy "); - - try { - ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class); - } catch (EmptyResultDataAccessException e) { - // ignore empty result scenario - } catch (DataAccessException e) { - throw e; - } - - return ret; - } - - @Override - public List retrieveSavingsIdsPendingEscheat(LocalDate tenantLocalDate) { - List ret = null; - StringBuilder sql = new StringBuilder("select sa.id "); - sql.append(" from m_savings_account as sa "); - sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); - sql.append(" where sa.status_enum = 300 "); - sql.append(" and sa.sub_status_enum = 200 "); - sql.append(" and " + sqlGenerator.dateDiff("?", - "(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") - + " "); - sql.append(" >= sp.days_to_escheat "); - - try { - ret = this.jdbcTemplate.queryForList(sql.toString(), Long.class, new Object[] { tenantLocalDate }); - } catch (EmptyResultDataAccessException e) { - // ignore empty result scenario - } catch (DataAccessException e) { - throw e; - } - - return ret; - } - - @Override - public boolean isAccountBelongsToClient(final Long clientId, final Long accountId, final DepositAccountType depositAccountType, - final String currencyCode) { - try { - final StringBuilder buff = new StringBuilder("select count(*) from m_savings_account sa "); - buff.append( - " where sa.id = ? and sa.client_id = ? and sa.deposit_type_enum = ? and sa.currency_code = ? and sa.status_enum = 300"); - return this.jdbcTemplate.queryForObject(buff.toString(), Integer.class, accountId, clientId, depositAccountType.getValue(), - currencyCode) > 0; - } catch (final EmptyResultDataAccessException e) { - throw new SavingsAccountNotFoundException(accountId, e); - } - } - /* - * private static final class SavingsAccountAnnualFeeMapper implements RowMapper { - * - * private final String schemaSql; - * - * public SavingsAccountAnnualFeeMapper() { final StringBuilder sqlBuilder = new StringBuilder(200); - * sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); sqlBuilder - * .append("sa.annual_fee_next_due_date as annualFeeNextDueDate "); sqlBuilder.append("from m_savings_account sa "); - * - * this.schemaSql = sqlBuilder.toString(); } - * - * public String schema() { return this.schemaSql; } - * - * @Override public SavingsAccountAnnualFeeData mapRow(final ResultSet rs, - * - * @SuppressWarnings("unused") final int rowNum) throws SQLException { - * - * final Long id = rs.getLong("id"); final String accountNo = rs.getString("accountNo"); final LocalDate - * annualFeeNextDueDate = JdbcSupport.getLocalDate(rs, "annualFeeNextDueDate"); - * - * return SavingsAccountAnnualFeeData.instance(id, accountNo, annualFeeNextDueDate); } } - */ - - @Override - public String retrieveAccountNumberByAccountId(Long accountId) { - try { - final String sql = "select s.account_no from m_savings_account s where s.id = ?"; - return this.jdbcTemplate.queryForObject(sql, String.class, accountId); - } catch (final EmptyResultDataAccessException e) { - throw new SavingsAccountNotFoundException(accountId, e); - } - } - - @Override - public List getAccountsIdsByStatusPaged(Integer status, int pageSize, Long maxSavingsIdInList) { - String sql = new StringBuilder().append(" SELECT sa.id FROM m_savings_account sa ") - .append(" where sa.id > ? and sa.status_enum = ? ").append(" order by sa.id limit ?").toString(); - - try { - return this.jdbcTemplate.queryForList(sql, Long.class, new Object[] { maxSavingsIdInList, status, pageSize }); - } catch (EmptyResultDataAccessException e) { - return new ArrayList<>(); - } - } - - @Override - public Long retrieveAccountIdByExternalId(final ExternalId externalId) { - return savingsAccountRepositoryWrapper.findIdByExternalId(externalId); - } - - @Override - public List retrievePeriodicAccrualData(LocalDate tillDate, SavingsAccount savings) { - Long savingsId = (savings != null) ? savings.getId() : null; - Integer status = SavingsAccountStatusType.ACTIVE.getValue(); - Integer accountingRule = AccountingRuleType.ACCRUAL_PERIODIC.getValue(); - - return this.savingsAccountRepositoryWrapper.findAccrualData(tillDate, savingsId, status, accountingRule); - } -} +/** + * 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 static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_BY_DB_FIELD; +import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_DATE_DB_FIELD; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.accounting.common.AccountingRuleType; +import org.apache.fineract.accounting.glaccount.data.GLAccountData; +import org.apache.fineract.infrastructure.core.data.EnumOptionData; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.domain.JdbcSupport; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.core.service.PaginationHelper; +import org.apache.fineract.infrastructure.core.service.SearchParameters; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.infrastructure.security.utils.ColumnValidator; +import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.portfolio.account.data.AccountTransferData; +import org.apache.fineract.portfolio.client.data.ClientData; +import org.apache.fineract.portfolio.group.data.GroupGeneralData; +import org.apache.fineract.portfolio.paymentdetail.data.PaymentDetailData; +import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; +import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; +import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; +import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; +import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; +import org.apache.fineract.portfolio.savings.data.SavingsAccountApplicationTimelineData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountSubStatusEnumData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountSummaryData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; +import org.apache.fineract.portfolio.savings.data.SavingsAccrualData; +import org.apache.fineract.portfolio.savings.data.SavingsProductData; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; +import org.apache.fineract.portfolio.tax.data.TaxComponentData; +import org.apache.fineract.portfolio.tax.data.TaxDetailsData; +import org.apache.fineract.portfolio.tax.data.TaxGroupData; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.ResultSetExtractor; +import org.springframework.jdbc.core.RowMapper; + +public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountReadPlatformService { + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final DatabaseSpecificSQLGenerator sqlGenerator; + + // mappers + private final SavingsAccountTransactionTemplateMapper transactionTemplateMapper; + protected SavingsAccountTransactionsMapper transactionsMapper; + private final SavingsAccountTransactionsForBatchMapper savingsAccountTransactionsForBatchMapper; + private final SavingAccountMapper savingAccountMapper; + private final SavingAccountMapperForInterestPosting savingAccountMapperForInterestPosting; + // private final SavingsAccountAnnualFeeMapper annualFeeMapper; + + // pagination + private final PaginationHelper paginationHelper; + + private final ColumnValidator columnValidator; + private final SavingsAccountAssembler savingAccountAssembler; + + private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper; + + public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate, + final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator, + DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) { + this.context = context; + this.jdbcTemplate = jdbcTemplate; + this.sqlGenerator = sqlGenerator; + this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper; + this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper(); + this.transactionsMapper = new SavingsAccountTransactionsMapper(); + this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper(); + this.savingAccountMapper = new SavingAccountMapper(); + this.columnValidator = columnValidator; + this.paginationHelper = paginationHelper; + this.savingAccountMapperForInterestPosting = new SavingAccountMapperForInterestPosting(); + this.savingAccountAssembler = savingAccountAssembler; + } + + @Override + public Collection retrieveAllForLookup(final Long clientId) { + + final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); + sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 "); + + final Object[] queryParameters = new Object[] { clientId }; + return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); + } + + @Override + public Collection retrieveActiveForLookup(final Long clientId, DepositAccountType depositAccountType) { + + final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); + sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 and sa.deposit_type_enum = ? "); + + final Object[] queryParameters = new Object[] { clientId, depositAccountType.getValue() }; + return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); + } + + @Override + public Collection retrieveActiveForLookup(final Long clientId, DepositAccountType depositAccountType, + String currencyCode) { + final StringBuilder sqlBuilder = new StringBuilder("select " + this.savingAccountMapper.schema()); + sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300 and sa.deposit_type_enum = ? and sa.currency_code = ? "); + + final Object[] queryParameters = new Object[] { clientId, depositAccountType.getValue(), currencyCode }; + return this.jdbcTemplate.query(sqlBuilder.toString(), this.savingAccountMapper, queryParameters); + } + + @Override + public Page retrieveAll(final SearchParameters searchParameters) { + + final AppUser currentUser = this.context.authenticatedUser(); + final String hierarchy = currentUser.getOffice().getHierarchy(); + final String hierarchySearchString = hierarchy + "%"; + + final StringBuilder sqlBuilder = new StringBuilder(200); + sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " "); + sqlBuilder.append(this.savingAccountMapper.schema()); + + sqlBuilder.append(" join m_office o on o.id = c.office_id"); + sqlBuilder.append(" where o.hierarchy like ?"); + + final Object[] objectArray = new Object[15]; + objectArray[0] = hierarchySearchString; + int arrayPos = 1; + if (searchParameters != null) { + + if (StringUtils.isNotBlank(searchParameters.getStatus())) { + if ("terminated".equalsIgnoreCase(searchParameters.getStatus())) { + sqlBuilder.append(" and sa.terminate_true = true"); + } else { + final Integer statusEnum = resolveStatusEnum(searchParameters.getStatus()); + if (statusEnum != null) { + sqlBuilder.append(" and sa.status_enum = ?"); + objectArray[arrayPos++] = statusEnum; + if (statusEnum == 100) { + sqlBuilder.append(" and (sa.terminate_true = false or sa.terminate_true is null)"); + } + } + } + } + + if (StringUtils.isNotBlank(searchParameters.getExternalId())) { + sqlBuilder.append(" and sa.external_id = ?"); + objectArray[arrayPos] = searchParameters.getExternalId(); + arrayPos = arrayPos + 1; + } + if (searchParameters.getOfficeId() != null) { + sqlBuilder.append(" and c.office_id = ?"); + objectArray[arrayPos++] = searchParameters.getOfficeId(); + } + if (StringUtils.isNotBlank(searchParameters.getAccountNo())) { + sqlBuilder.append(" and sa.account_no like ?"); + objectArray[arrayPos++] = "%" + searchParameters.getAccountNo() + "%"; + } + if (StringUtils.isNotBlank(searchParameters.getName())) { + sqlBuilder.append(" and c.display_name like ?"); + objectArray[arrayPos++] = "%" + searchParameters.getName() + "%"; + } + if (searchParameters.getDepositType() != null) { + sqlBuilder.append(" and sa.deposit_type_enum = ?"); + objectArray[arrayPos++] = searchParameters.getDepositType(); + } + if (searchParameters.hasFromDate()) { + sqlBuilder.append(" and sa.activatedon_date >= ?"); + objectArray[arrayPos++] = searchParameters.getFromDate(); + } + if (searchParameters.hasToDate()) { + sqlBuilder.append(" and sa.activatedon_date <= ?"); + objectArray[arrayPos++] = searchParameters.getToDate(); + } + if (searchParameters.hasAsOnDate()) { + sqlBuilder.append(" and sa.activatedon_date <= ?"); + objectArray[arrayPos++] = searchParameters.getAsOnDate(); + } + if (searchParameters.hasOrderBy()) { + sqlBuilder.append(" order by ").append(searchParameters.getOrderBy()); + this.columnValidator.validateSqlInjection(sqlBuilder.toString(), searchParameters.getOrderBy()); + + if (searchParameters.hasSortOrder()) { + sqlBuilder.append(' ').append(searchParameters.getSortOrder()); + this.columnValidator.validateSqlInjection(sqlBuilder.toString(), searchParameters.getSortOrder()); + } + } else { + sqlBuilder.append(" order by sa.id desc"); + } + + if (searchParameters.hasLimit()) { + sqlBuilder.append(" "); + if (searchParameters.hasOffset()) { + sqlBuilder.append(sqlGenerator.limit(searchParameters.getLimit(), searchParameters.getOffset())); + } else { + sqlBuilder.append(sqlGenerator.limit(searchParameters.getLimit())); + } + } + } + final Object[] finalObjectArray = Arrays.copyOf(objectArray, arrayPos); + return this.paginationHelper.fetchPage(this.jdbcTemplate, sqlBuilder.toString(), finalObjectArray, this.savingAccountMapper); + } + + private Integer resolveStatusEnum(final String status) { + if (status == null) { + return null; + } + return switch (status.toLowerCase()) { + case "active" -> 300; + case "approved" -> 200; + case "submitted", "submittedandpendingapproval" -> 100; + case "withdrawn" -> 400; + case "rejected" -> 500; + case "closed" -> 600; + case "terminated" -> 900; + default -> { + try { + yield Integer.parseInt(status); + } catch (NumberFormatException e) { + yield null; + } + } + }; + } + + @Override + public SavingsAccountData retrieveOne(final Long accountId) { + + try { + final String sql = "select " + this.savingAccountMapper.schema() + " where sa.id = ?"; + + final SavingsAccountData accountData = this.jdbcTemplate.queryForObject(sql, this.savingAccountMapper, + new Object[] { accountId }); // NOSONAR + + // Load joint holders with explicit camelCase keys (queryForList returns lowercase from MySQL) + if (accountData != null) { + final String jointSql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, " + + "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory " + + "FROM m_savings_account_joint_holders jh WHERE jh.savings_account_id = ? " + + "ORDER BY jh.is_primary DESC, jh.id ASC"; + final List> jointHolders = this.jdbcTemplate.query(jointSql, (rs, rowNum) -> { + final java.util.Map h = new java.util.LinkedHashMap<>(); + h.put("id", rs.getLong("id")); + h.put("clientId", rs.getLong("client_id")); + h.put("cifNumber", rs.getString("cif_number")); + h.put("displayName", rs.getString("display_name")); + h.put("sharePercentage", rs.getBigDecimal("share_percentage")); + h.put("signingAuthority", rs.getString("signing_authority")); + h.put("role", rs.getString("role")); + h.put("isPrimary", rs.getBoolean("is_primary")); + h.put("isMandatory", rs.getBoolean("is_mandatory")); + return h; + }, accountId); + if (!jointHolders.isEmpty()) { + accountData.setJointHolders(jointHolders); + } + } + + return accountData; + } catch (final EmptyResultDataAccessException e) { + throw new SavingsAccountNotFoundException(accountId, e); + } + } + + @Override + public List retrieveAllTransactionData(final List refNo) throws DataAccessException { + String inSql = String.join(",", Collections.nCopies(refNo.size(), "?")); + String sql = "select " + this.savingsAccountTransactionsForBatchMapper.schema() + " where tr.ref_no in (%s)"; + Object[] params = new Object[refNo.size()]; + int i = 0; + for (String element : refNo) { + params[i] = element; + i++; + } + return this.jdbcTemplate.query(String.format(sql, inSql), this.savingsAccountTransactionsForBatchMapper, params); + } + + @Override + public List retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize, + final Integer status, final Long maxSavingsId) { + LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1); + String sql = "select " + this.savingAccountMapperForInterestPosting.schema() + + "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id "; + if (backdatedTxnsAllowedTill) { + sql = sql + + "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) "; + } + + sql = sql + " and (sa.interest_posted_till_date is null or sa.interest_posted_till_date <= ? ) "; + // #audit backward compatibility + sql = sql + " order by sa.id, tr.transaction_date, tr." + CREATED_DATE_DB_FIELD + ", tr.created_date, tr.id"; + + List savingsAccountDataList = this.jdbcTemplate.query(sql, this.savingAccountMapperForInterestPosting, // NOSONAR + new Object[] { maxSavingsId, status, pageSize, yesterday }); + for (SavingsAccountData savingsAccountData : savingsAccountDataList) { + this.savingAccountAssembler.assembleSavings(savingsAccountData); + } + return savingsAccountDataList; + } + + private static final class SavingAccountMapperForInterestPosting implements ResultSetExtractor> { + + private final String schemaSql; + + SavingAccountMapperForInterestPosting() { + final StringBuilder sqlBuilder = new StringBuilder(400); + sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, "); + sqlBuilder.append("sa.deposit_type_enum as depositType, "); + sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, "); + sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, "); + sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, "); + sqlBuilder.append("sa.status_enum as statusEnum, "); + sqlBuilder.append("sa.sub_status_enum as subStatusEnum, "); + sqlBuilder.append("sa.submittedon_date as submittedOnDate,"); + sqlBuilder.append("sa.rejectedon_date as rejectedOnDate,"); + sqlBuilder.append("sa.withdrawnon_date as withdrawnOnDate,"); + sqlBuilder.append("sa.approvedon_date as approvedOnDate,"); + sqlBuilder.append("sa.activatedon_date as activatedOnDate,"); + sqlBuilder.append("sa.closedon_date as closedOnDate,"); + sqlBuilder.append( + "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); + sqlBuilder.append("sa.nominal_annual_interest_rate as nominalAnnualInterestRate, "); + sqlBuilder.append("sa.interest_compounding_period_enum as interestCompoundingPeriodType, "); + sqlBuilder.append("sa.interest_posting_period_enum as interestPostingPeriodType, "); + sqlBuilder.append("sa.interest_calculation_type_enum as interestCalculationType, "); + sqlBuilder.append("sa.interest_calculation_days_in_year_type_enum as interestCalculationDaysInYearType, "); + sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance, "); + sqlBuilder.append("sa.lockin_period_frequency as lockinPeriodFrequency,"); + sqlBuilder.append("sa.lockin_period_frequency_enum as lockinPeriodFrequencyType, "); + sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, "); + sqlBuilder.append("sa.allow_overdraft as allowOverdraft, "); + sqlBuilder.append("sa.overdraft_limit as overdraftLimit, "); + sqlBuilder.append("sa.nominal_annual_interest_rate_overdraft as nominalAnnualInterestRateOverdraft, "); + sqlBuilder.append("sa.min_overdraft_for_interest_calculation as minOverdraftForInterestCalculation, "); + sqlBuilder.append("sa.total_deposits_derived as totalDeposits, "); + sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); + sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); + sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); + sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); + sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); + sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); + sqlBuilder.append("sa.account_balance_derived as accountBalance, "); + sqlBuilder.append("sa.total_fees_charge_derived as totalFeeCharge, "); + sqlBuilder.append("sa.total_penalty_charge_derived as totalPenaltyCharge, "); + sqlBuilder.append("sa.min_balance_for_interest_calculation as minBalanceForInterestCalculation,"); + sqlBuilder.append("sa.min_required_balance as minRequiredBalance, "); + sqlBuilder.append("sa.enforce_min_required_balance as enforceMinRequiredBalance, "); + sqlBuilder.append("sa.max_allowed_lien_limit as maxAllowedLienLimit, "); + sqlBuilder.append("sa.is_lien_allowed as lienAllowed, "); + sqlBuilder.append("sa.on_hold_funds_derived as onHoldFunds, "); + sqlBuilder.append("sa.withhold_tax as withHoldTax, "); + sqlBuilder.append("sa.total_withhold_tax_derived as totalWithholdTax, "); + sqlBuilder.append("sa.last_interest_calculation_date as lastInterestCalculationDate, "); + sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); + sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, "); + sqlBuilder.append("tg.id as taxGroupId, "); + sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); + sqlBuilder.append("from m_savings_account_transaction as sat "); + sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false "); + sqlBuilder.append("and sat.transaction_type_enum in (1,2) "); + sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, "); + sqlBuilder.append("sp.id as productId, "); + sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); + sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); + sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, "); + sqlBuilder.append("sp.days_to_escheat as daysToEscheat, "); + sqlBuilder.append("sp.accounting_type as accountingType, "); + sqlBuilder.append("tr.id as transactionId, tr.transaction_type_enum as transactionType, "); + sqlBuilder.append("tr.transaction_date as transactionDate, tr.amount as transactionAmount,"); + sqlBuilder.append("tr.submitted_on_date as transSubmittedOnDate,tr.cumulative_balance_derived as cumulativeBalance,"); + sqlBuilder.append("tr.running_balance_derived as runningBalance, tr.is_reversed as reversed,"); + sqlBuilder.append("tr.balance_end_date_derived as balanceEndDate, tr.overdraft_amount_derived as overdraftAmount,"); + sqlBuilder.append("tr.is_manual as manualTransaction,tr.office_id as officeId, "); + sqlBuilder.append("pd.payment_type_id as paymentType,pd.account_number as accountNumber,pd.check_number as checkNumber, "); + sqlBuilder.append("pd.receipt_number as receiptNumber, pd.bank_number as bankNumber,pd.routing_code as routingCode, "); + sqlBuilder.append("pt.value as paymentTypeName, "); + sqlBuilder.append("msacpb.amount as paidByAmount, msacpb.id as chargesPaidById, "); + sqlBuilder.append( + "msac.id as chargeId, msac.amount as chargeAmount, msac.charge_time_enum as chargeTimeType, msac.is_penalty as isPenaltyCharge, "); + sqlBuilder.append("txd.id as taxDetailsId, txd.amount as taxAmount, "); + sqlBuilder.append("apm.gl_account_id as glAccountIdForInterestOnSavings, apm1.gl_account_id as glAccountIdForSavingsControl, "); + sqlBuilder.append( + "apm2.gl_account_id as glAccountIdForInterestReceivable,apm3.gl_account_id as glAccountIdForOverdraftPorfolio, "); + sqlBuilder.append("apm4.gl_account_id as glAccountIdForInterestPayable, "); + sqlBuilder.append( + "mtc.id as taxComponentId, mtc.debit_account_id as debitAccountId, mtc.credit_account_id as creditAccountId, mtc.percentage as taxPercentage "); + sqlBuilder.append("from m_savings_account sa "); + sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); + sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); + sqlBuilder.append("join m_savings_account_transaction tr on sa.id = tr.savings_account_id "); + sqlBuilder.append("left join m_payment_detail pd on pd.id = tr.payment_detail_id "); + sqlBuilder.append("left join m_payment_type pt on pt.id = pd.payment_type_id "); + sqlBuilder.append("left join m_savings_account_charge_paid_by msacpb on msacpb.savings_account_transaction_id = tr.id "); + sqlBuilder.append("left join m_savings_account_charge msac on msac.id = msacpb.savings_account_charge_id "); + sqlBuilder.append("left join m_client c ON c.id = sa.client_id "); + sqlBuilder.append("left join m_group g ON g.id = sa.group_id "); + sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id "); + sqlBuilder.append("left join m_savings_account_transaction_tax_details txd on txd.savings_transaction_id = tr.id "); + sqlBuilder.append("left join m_tax_component mtc on mtc.id = txd.tax_component_id "); + sqlBuilder.append( + "left join acc_product_mapping apm on apm.product_type = 2 and apm.product_id = sp.id and apm.financial_account_type=3 "); + sqlBuilder.append( + "left join acc_product_mapping apm1 on apm1.product_type = 2 and apm1.product_id = sp.id and apm1.financial_account_type=2 "); + sqlBuilder.append("left join acc_product_mapping apm2 on apm2.product_id = sp.id and apm2.financial_account_type=18 "); + sqlBuilder.append("left join acc_product_mapping apm3 on apm3.product_id = sp.id and apm3.financial_account_type = 11 "); + sqlBuilder.append("left join acc_product_mapping apm4 on apm4.product_id = sp.id and apm4.financial_account_type = 17 "); + // Needed so this batch engine can tell a monthly-payout deposit (FD Special) from a + // cumulative one: interest that is swept out to a linked account each month must never + // compound, and without this flag the engine compounded it. Left join because ordinary + // savings accounts have no term-and-preclosure row at all. + sqlBuilder.append( + "left join m_deposit_account_term_and_preclosure datp on datp.savings_account_id = sa.id "); + + this.schemaSql = sqlBuilder.toString(); + } + + public String schema() { + return this.schemaSql; + } + + @Override + public List extractData(final ResultSet rs) throws SQLException { + + List savingsAccountDataList = new ArrayList<>(); + HashMap savingsMap = new HashMap<>(); + String currencyCode = null; + Integer currencyDigits = null; + Integer inMultiplesOf = null; + CurrencyData currency = null; + HashMap transMap = new HashMap<>(); + HashMap taxDetails = new HashMap<>(); + HashMap chargeDetails = new HashMap<>(); + SavingsAccountTransactionData savingsAccountTransactionData = null; + SavingsAccountData savingsAccountData = null; + int count = 0; + + while (rs.next()) { + final Long id = rs.getLong("id"); + final Long transactionId = rs.getLong("transactionId"); + final Long taxDetailId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxDetailsId"); + final Long taxComponentId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxComponentId"); + final String accountNo = rs.getString("accountNo"); + final Long chargeId = rs.getLong("chargeId"); + + if (!savingsMap.containsValue(id)) { + if (count > 0) { + savingsAccountDataList.add(savingsAccountData); + } + count++; + savingsMap.put("id", id); + + final String externalId = rs.getString("externalId"); + final Integer depositTypeId = rs.getInt("depositType"); + final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); + final Long groupId = JdbcSupport.getLong(rs, "groupId"); + final Long groupOfficeId = JdbcSupport.getLong(rs, "groupOfficeId"); + final GroupGeneralData groupGeneralData = new GroupGeneralData(groupId, groupOfficeId); + + final Long clientId = JdbcSupport.getLong(rs, "clientId"); + final Long clientOfficeId = JdbcSupport.getLong(rs, "clientOfficeId"); + final ClientData clientData = ClientData.createClientForInterestPosting(clientId, clientOfficeId); + + final Long glAccountIdForInterestOnSavings = rs.getLong("glAccountIdForInterestOnSavings"); + final Long glAccountIdForSavingsControl = rs.getLong("glAccountIdForSavingsControl"); + + final Long glAccountIdForOverdraftPorfolio = rs.getLong("glAccountIdForOverdraftPorfolio"); + final Long glAccountIdForInterestReceivable = rs.getLong("glAccountIdForInterestReceivable"); + + final Long glAccountIdForInterestPayable = rs.getLong("glAccountIdForInterestPayable"); + + final Long productId = rs.getLong("productId"); + final Integer accountType = rs.getInt("accountingType"); + final AccountingRuleType accountingRuleType = AccountingRuleType.fromInt(accountType); + final EnumOptionData enumOptionDataForAccounting = new EnumOptionData(accountType.longValue(), + accountingRuleType.getCode(), accountingRuleType.getValue().toString()); + final SavingsProductData savingsProductData = SavingsProductData.createForInterestPosting(productId, + enumOptionDataForAccounting); + + final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); + final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); + final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); + final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); + final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); + final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); + final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); + final Integer numDaysToDormancy = JdbcSupport.getInteger(rs, "daysToDormancy"); + final Integer numDaysToEscheat = JdbcSupport.getInteger(rs, "daysToEscheat"); + Integer daysToInactive = null; + Integer daysToDormancy = null; + Integer daysToEscheat = null; + + LocalDate currentDate = DateUtils.getBusinessLocalDate(); + if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { + if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { + daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToEscheat)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.DORMANT.getValue()) { + daysToDormancy = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToDormancy)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.INACTIVE.getValue()) { + daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, + lastActiveTransactionDate.plusDays(numDaysToInactive)); + } + } + final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate"); + final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate"); + final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); + final LocalDate activatedOnDate = JdbcSupport.getLocalDate(rs, "activatedOnDate"); + final LocalDate closedOnDate = JdbcSupport.getLocalDate(rs, "closedOnDate"); + final SavingsAccountApplicationTimelineData timeline = new SavingsAccountApplicationTimelineData(submittedOnDate, null, + null, null, null, null, null, null, withdrawnOnDate, null, null, null, approvedOnDate, null, null, null, + activatedOnDate, null, null, null, closedOnDate, null, null, null); + + currencyCode = rs.getString("currencyCode"); + currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); + inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); + currency = new CurrencyData(currencyCode, currencyDigits, inMultiplesOf); + + final BigDecimal totalDeposits = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalDeposits"); + final BigDecimal totalWithdrawals = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawals"); + final BigDecimal totalWithdrawalFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawalFees"); + final BigDecimal totalAnnualFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalAnnualFees"); + + final BigDecimal totalInterestEarned = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalInterestEarned"); + final BigDecimal totalInterestPosted = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalInterestPosted"); + final BigDecimal accountBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "accountBalance"); + final BigDecimal totalFeeCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalFeeCharge"); + final BigDecimal totalPenaltyCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalPenaltyCharge"); + final BigDecimal totalOverdraftInterestDerived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, + "totalOverdraftInterestDerived"); + final BigDecimal totalWithholdTax = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithholdTax"); + final LocalDate interestPostedTillDate = JdbcSupport.getLocalDate(rs, "interestPostedTillDate"); + + final BigDecimal minBalanceForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "minBalanceForInterestCalculation"); + final BigDecimal onHoldFunds = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "onHoldFunds"); + + final BigDecimal onHoldAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "onHoldAmount"); + + BigDecimal availableBalance = accountBalance; + if (availableBalance != null && onHoldFunds != null) { + + availableBalance = availableBalance.subtract(onHoldFunds); + } + + if (availableBalance != null && onHoldAmount != null) { + + availableBalance = availableBalance.subtract(onHoldAmount); + } + + BigDecimal interestNotPosted = BigDecimal.ZERO; + LocalDate lastInterestCalculationDate = null; + if (totalInterestEarned != null) { + interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); + lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); + } + + final SavingsAccountSummaryData summary = new SavingsAccountSummaryData(currency, totalDeposits, totalWithdrawals, + totalWithdrawalFees, totalAnnualFees, totalInterestEarned, totalInterestPosted, accountBalance, totalFeeCharge, + totalPenaltyCharge, totalOverdraftInterestDerived, totalWithholdTax, interestNotPosted, + lastInterestCalculationDate, availableBalance, interestPostedTillDate); + summary.setPrevInterestPostedTillDate(interestPostedTillDate); + + final boolean withHoldTax = rs.getBoolean("withHoldTax"); + final Long taxGroupId = JdbcSupport.getLongDefaultToNullIfZero(rs, "taxGroupId"); + TaxGroupData taxGroupData = null; + if (taxGroupId != null) { + taxGroupData = TaxGroupData.lookup(taxGroupId, null); + } + + final BigDecimal nominalAnnualInterestRate = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "nominalAnnualInterestRate"); + + final EnumOptionData interestCompoundingPeriodType = SavingsEnumerations.compoundingInterestPeriodType( + SavingsCompoundingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestCompoundingPeriodType"))); + + final EnumOptionData interestPostingPeriodType = SavingsEnumerations.interestPostingPeriodType( + SavingsPostingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestPostingPeriodType"))); + + final EnumOptionData interestCalculationType = SavingsEnumerations.interestCalculationType( + SavingsInterestCalculationType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationType"))); + + final EnumOptionData interestCalculationDaysInYearType = SavingsEnumerations + .interestCalculationDaysInYearType(SavingsInterestCalculationDaysInYearType + .fromInt(JdbcSupport.getInteger(rs, "interestCalculationDaysInYearType"))); + + final BigDecimal minRequiredOpeningBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "minRequiredOpeningBalance"); + + final Integer lockinPeriodFrequency = JdbcSupport.getInteger(rs, "lockinPeriodFrequency"); + EnumOptionData lockinPeriodFrequencyType = null; + final Integer lockinPeriodFrequencyTypeValue = JdbcSupport.getInteger(rs, "lockinPeriodFrequencyType"); + if (lockinPeriodFrequencyTypeValue != null) { + final SavingsPeriodFrequencyType lockinPeriodType = SavingsPeriodFrequencyType + .fromInt(lockinPeriodFrequencyTypeValue); + lockinPeriodFrequencyType = SavingsEnumerations.lockinPeriodFrequencyType(lockinPeriodType); + } + + final boolean withdrawalFeeForTransfers = rs.getBoolean("withdrawalFeeForTransfers"); + + final boolean allowOverdraft = rs.getBoolean("allowOverdraft"); + final BigDecimal overdraftLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "overdraftLimit"); + final BigDecimal nominalAnnualInterestRateOverdraft = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "nominalAnnualInterestRateOverdraft"); + final BigDecimal minOverdraftForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "minOverdraftForInterestCalculation"); + + final BigDecimal minRequiredBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredBalance"); + final boolean enforceMinRequiredBalance = rs.getBoolean("enforceMinRequiredBalance"); + final BigDecimal maxAllowedLienLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "maxAllowedLienLimit"); + final boolean lienAllowed = rs.getBoolean("lienAllowed"); + savingsAccountData = SavingsAccountData.instance(id, accountNo, depositType, externalId, null, null, null, null, + productId, null, null, null, status, subStatus, null, timeline, currency, nominalAnnualInterestRate, + interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, + interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, + withdrawalFeeForTransfers, summary, allowOverdraft, overdraftLimit, minRequiredBalance, + enforceMinRequiredBalance, maxAllowedLienLimit, lienAllowed, minBalanceForInterestCalculation, onHoldFunds, + nominalAnnualInterestRateOverdraft, minOverdraftForInterestCalculation, withHoldTax, taxGroupData, + lastActiveTransactionDate, isDormancyTrackingActive, daysToInactive, daysToDormancy, daysToEscheat, + onHoldAmount); + + savingsAccountData.setClientData(clientData); + savingsAccountData.setGroupGeneralData(groupGeneralData); + savingsAccountData.setSavingsProduct(savingsProductData); + + savingsAccountData.setGlAccountIdForInterestReceivable(glAccountIdForInterestReceivable); + savingsAccountData.setGlAccountIdForOverdraftPorfolio(glAccountIdForOverdraftPorfolio); + + savingsAccountData.setGlAccountIdForInterestPayable(glAccountIdForInterestPayable); + + savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings); + savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl); + // Monthly-payout deposits must not compound: their interest leaves the account every + // period, so each period earns on the principal alone. + savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount")); + } + + if (!transMap.containsValue(transactionId)) { + + final int transactionTypeInt = JdbcSupport.getInteger(rs, "transactionType"); + final SavingsAccountTransactionEnumData transactionType = SavingsEnumerations.transactionType(transactionTypeInt); + + final LocalDate date = JdbcSupport.getLocalDate(rs, "transactionDate"); + final LocalDate balanceEndDate = JdbcSupport.getLocalDate(rs, "balanceEndDate"); + final LocalDate transSubmittedOnDate = JdbcSupport.getLocalDate(rs, "transSubmittedOnDate"); + final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "transactionAmount"); + final BigDecimal overdraftAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "overdraftAmount"); + final BigDecimal outstandingChargeAmount = null; + final BigDecimal runningBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "runningBalance"); + final boolean reversed = rs.getBoolean("reversed"); + final Long officeId = rs.getLong("officeId"); + final BigDecimal cumulativeBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "cumulativeBalance"); + + final boolean postInterestAsOn = false; + + PaymentDetailData paymentDetailData = null; + if (transactionType.isDepositOrWithdrawal()) { + final Long paymentTypeId = JdbcSupport.getLong(rs, "paymentType"); + if (paymentTypeId != null) { + final String typeName = rs.getString("paymentTypeName"); + final PaymentTypeData paymentTypeData = new PaymentTypeData(paymentTypeId, typeName, null, false, null, null, + false); + paymentDetailData = new PaymentDetailData(id, paymentTypeData, null, null, null, null, null); + } + } + + savingsAccountTransactionData = SavingsAccountTransactionData.create(transactionId, transactionType, paymentDetailData, + id, accountNo, date, currency, amount, outstandingChargeAmount, runningBalance, reversed, transSubmittedOnDate, + postInterestAsOn, cumulativeBalance, balanceEndDate); + savingsAccountTransactionData.setOverdraftAmount(overdraftAmount); + + transMap.put("id", transactionId); + if (savingsAccountData.getOfficeId() == null) { + savingsAccountData.setOfficeId(officeId); + } + + savingsAccountData.setSavingsAccountTransactionData(savingsAccountTransactionData); + } + + if (chargeId != null && !chargeDetails.containsValue(chargeId)) { + final boolean isPenalty = rs.getBoolean("isPenaltyCharge"); + final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "chargeAmount"); + final Integer chargesTimeType = rs.getInt("chargeTimeType"); + final EnumOptionData enumOptionDataForChargesTimeType = new EnumOptionData(chargesTimeType.longValue(), null, null); + final SavingsAccountChargeData savingsAccountChargeData = new SavingsAccountChargeData(chargeId, chargeAmount, + enumOptionDataForChargesTimeType, isPenalty); + + final Long chargesPaidById = rs.getLong("chargesPaidById"); + final BigDecimal chargesPaid = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "paidByAmount"); + final SavingsAccountChargesPaidByData savingsAccountChargesPaidByData = new SavingsAccountChargesPaidByData( + chargesPaidById, chargesPaid); + savingsAccountChargesPaidByData.setSavingsAccountChargeData(savingsAccountChargeData); + if (savingsAccountChargesPaidByData != null) { + savingsAccountTransactionData.setChargesPaidByData(savingsAccountChargesPaidByData); + } + + chargeDetails.put("id", chargeId); + } + + if (taxDetailId != null && !taxDetails.containsValue(taxDetailId)) { + final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "taxAmount"); + final BigDecimal percentage = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "taxPercentage"); + final Long debitId = rs.getLong("debitAccountId"); + final Long creditId = rs.getLong("creditAccountId"); + final GLAccountData debitAccount = GLAccountData.createFrom(debitId); + final GLAccountData creditAccount = GLAccountData.createFrom(creditId); + + if (taxComponentId != null) { + final TaxComponentData taxComponent = TaxComponentData.createTaxComponent(taxComponentId, percentage, debitAccount, + creditAccount); + savingsAccountTransactionData.setTaxDetails(new TaxDetailsData(taxComponent, amount)); + } + + taxDetails.put("id", taxDetailId); + } + + } + if (savingsAccountData != null) { + savingsAccountDataList.add(savingsAccountData); + } + return savingsAccountDataList; + + } + } + + private static final class SavingAccountMapper implements RowMapper { + + private final String schemaSql; + + SavingAccountMapper() { + final StringBuilder sqlBuilder = new StringBuilder(400); + sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, "); + sqlBuilder.append("sa.deposit_type_enum as depositType, "); + sqlBuilder.append("c.id as clientId, c.display_name as clientName, "); + sqlBuilder.append("g.id as groupId, g.display_name as groupName, "); + sqlBuilder.append("sp.id as productId, sp.name as productName, "); + sqlBuilder.append("s.id fieldOfficerId, s.display_name as fieldOfficerName, "); + sqlBuilder.append("sa.status_enum as statusEnum, "); + sqlBuilder.append("sa.sub_status_enum as subStatusEnum, "); + sqlBuilder.append("sa.reason_for_block as reasonForBlock, "); + sqlBuilder.append("sa.submittedon_date as submittedOnDate,"); + sqlBuilder.append("sbu.username as submittedByUsername,"); + sqlBuilder.append("sbu.firstname as submittedByFirstname, sbu.lastname as submittedByLastname,"); + + sqlBuilder.append("sa.rejectedon_date as rejectedOnDate,"); + sqlBuilder.append("rbu.username as rejectedByUsername,"); + sqlBuilder.append("rbu.firstname as rejectedByFirstname, rbu.lastname as rejectedByLastname,"); + + sqlBuilder.append("sa.withdrawnon_date as withdrawnOnDate,"); + sqlBuilder.append("wbu.username as withdrawnByUsername,"); + sqlBuilder.append("wbu.firstname as withdrawnByFirstname, wbu.lastname as withdrawnByLastname,"); + + sqlBuilder.append("sa.approvedon_date as approvedOnDate,"); + sqlBuilder.append("abu.username as approvedByUsername,"); + sqlBuilder.append("abu.firstname as approvedByFirstname, abu.lastname as approvedByLastname,"); + + sqlBuilder.append("sa.activatedon_date as activatedOnDate,"); + sqlBuilder.append("avbu.username as activatedByUsername,"); + sqlBuilder.append("avbu.firstname as activatedByFirstname, avbu.lastname as activatedByLastname,"); + + sqlBuilder.append("sa.closedon_date as closedOnDate,"); + sqlBuilder.append("cbu.username as closedByUsername,"); + sqlBuilder.append("cbu.firstname as closedByFirstname, cbu.lastname as closedByLastname,"); + + sqlBuilder.append( + "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); + sqlBuilder.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); + sqlBuilder.append("curr.display_symbol as currencyDisplaySymbol, "); + + sqlBuilder.append("sa.nominal_annual_interest_rate as nominalAnnualInterestRate, "); + sqlBuilder.append("sa.interest_compounding_period_enum as interestCompoundingPeriodType, "); + sqlBuilder.append("sa.interest_posting_period_enum as interestPostingPeriodType, "); + sqlBuilder.append("sa.interest_calculation_type_enum as interestCalculationType, "); + sqlBuilder.append("sa.interest_calculation_days_in_year_type_enum as interestCalculationDaysInYearType, "); + sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance, "); + sqlBuilder.append("sa.lockin_period_frequency as lockinPeriodFrequency,"); + sqlBuilder.append("sa.lockin_period_frequency_enum as lockinPeriodFrequencyType, "); + + sqlBuilder.append("sa.allow_overdraft as allowOverdraft, "); + sqlBuilder.append("sa.overdraft_limit as overdraftLimit, "); + sqlBuilder.append("sa.nominal_annual_interest_rate_overdraft as nominalAnnualInterestRateOverdraft, "); + sqlBuilder.append("sa.min_overdraft_for_interest_calculation as minOverdraftForInterestCalculation, "); + + sqlBuilder.append("sa.total_deposits_derived as totalDeposits, "); + sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, "); + sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, "); + sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, "); + sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, "); + sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, "); + sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, "); + sqlBuilder.append("sa.account_balance_derived as accountBalance, "); + sqlBuilder.append("sa.total_fees_charge_derived as totalFeeCharge, "); + sqlBuilder.append("sa.total_penalty_charge_derived as totalPenaltyCharge, "); + sqlBuilder.append("sa.min_balance_for_interest_calculation as minBalanceForInterestCalculation,"); + sqlBuilder.append("sa.min_required_balance as minRequiredBalance, "); + sqlBuilder.append("sa.enforce_min_required_balance as enforceMinRequiredBalance, "); + sqlBuilder.append("sa.max_allowed_lien_limit as maxAllowedLienLimit, "); + sqlBuilder.append("sa.is_lien_allowed as lienAllowed, "); + sqlBuilder.append("sa.on_hold_funds_derived as onHoldFunds, "); + sqlBuilder.append("sa.withhold_tax as withHoldTax, "); + sqlBuilder.append("sa.total_withhold_tax_derived as totalWithholdTax, "); + sqlBuilder.append("sa.last_interest_calculation_date as lastInterestCalculationDate, "); + sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, "); + sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, "); + sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, "); + sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName, "); + sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) "); + sqlBuilder.append("from m_savings_account_transaction as sat "); + sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false "); + sqlBuilder.append("and sat.transaction_type_enum in (1,2) "); + sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, "); + sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, "); + sqlBuilder.append("sp.days_to_inactive as daysToInactive, "); + sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, "); + sqlBuilder.append("sp.days_to_escheat as daysToEscheat, "); + // CBS extended fields + sqlBuilder.append("sa.cbs_account_type as cbsAccountType, "); + sqlBuilder.append("sa.customer_segment as customerSegment, "); + sqlBuilder.append("sa.account_status as accountStatus, "); + sqlBuilder.append("sa.mode_of_operation as modeOfOperation, "); + sqlBuilder.append("sa.special_category as specialCategory, "); + sqlBuilder.append("sa.weaker_section as weakerSection, "); + sqlBuilder.append("sa.guardian_name as guardianName, "); + sqlBuilder.append("sa.guardian_cif_number as guardianCifNumber, "); + sqlBuilder.append("sa.guardian_relationship as guardianRelationship, "); + sqlBuilder.append("sa.minor_date_of_birth as minorDateOfBirth, "); + sqlBuilder.append("sa.guardian_client_id as guardianClientId, "); + sqlBuilder.append("sa.introducer_client_id as introducerClientId, "); + sqlBuilder.append("sa.introducer_name as introducerName, "); + sqlBuilder.append("sa.introducer_cif_number as introducerCifNumber, "); + sqlBuilder.append("sa.nominee_data as nomineeData, "); + sqlBuilder.append("sa.linked_account_id as linkedAccountId, "); + sqlBuilder.append("sa.linked_account_type as linkedAccountType, "); + sqlBuilder.append("sa.linked_account_no as linkedAccountNo, "); + sqlBuilder.append("sp.balance_swap_option as balanceSwapOption, "); + sqlBuilder.append("sp.msme as msme, "); + sqlBuilder.append("COALESCE(sa.nomination_facility, sp.nomination_facility) as nominationFacility, "); + sqlBuilder.append("COALESCE(sa.auto_debit_facility, sp.auto_debit_facility) as autoDebitFacility, "); + sqlBuilder.append("COALESCE(sa.introducer_required, sp.introducer_required) as introducerRequired, "); + sqlBuilder.append("COALESCE(sa.share_linking_enabled, sp.share_linking_enabled) as shareLinkingEnabled, "); + sqlBuilder.append("sa.share_account_id as shareAccountId, msha.account_no as shareAccountNo, "); + // Facility config fields + sqlBuilder.append("safc.atm_facility as atmFacility, "); + sqlBuilder.append("safc.atm_withdrawal_facility as atmWithdrawalFacility, "); + sqlBuilder.append("safc.upi_facility as upiFacility, "); + sqlBuilder.append("safc.pos_transactions_facility as posTransactionsFacility, "); + sqlBuilder.append("safc.cheque_book as chequeBook, "); + sqlBuilder.append("safc.imps_facility as impsFacility, "); + sqlBuilder.append("safc.sms_alerts as smsAlerts, "); + sqlBuilder.append("safc.bbps_facility as bbpsFacility, "); + sqlBuilder.append("safc.insurance_cover as insuranceCover, "); + sqlBuilder.append("safc.bepg_facility as bepgFacility, "); + sqlBuilder.append("safc.neft_facility as neftFacility, "); + sqlBuilder.append("safc.rtgs_facility as rtgsFacility, "); + sqlBuilder.append("safc.inter_bank_total_cap_facility as interBankTotalCapFacility, "); + sqlBuilder.append("safc.deposit_facility_kyc as depositFacilityKyc, "); + sqlBuilder.append("safc.virtual_debit_card as virtualDebitCard, "); + sqlBuilder.append("sa.terminate_true as terminateTrue "); + sqlBuilder.append("from m_savings_account sa "); + sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); + sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); + sqlBuilder.append("left join m_client c ON c.id = sa.client_id "); + sqlBuilder.append("left join m_group g ON g.id = sa.group_id "); + sqlBuilder.append("left join m_staff s ON s.id = sa.field_officer_id "); + sqlBuilder.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid "); + sqlBuilder.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid "); + sqlBuilder.append("left join m_appuser wbu on wbu.id = sa.withdrawnon_userid "); + sqlBuilder.append("left join m_appuser abu on abu.id = sa.approvedon_userid "); + sqlBuilder.append("left join m_appuser avbu on avbu.id = sa.activatedon_userid "); + sqlBuilder.append("left join m_appuser cbu on cbu.id = sa.closedon_userid "); + sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id "); + sqlBuilder.append("left join m_savings_account_facility_config safc on safc.savings_account_id = sa.id "); + sqlBuilder.append("left join m_share_account msha on msha.id = sa.share_account_id "); + + this.schemaSql = sqlBuilder.toString(); + } + + public String schema() { + return this.schemaSql; + } + + @Override + public SavingsAccountData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + + final Long id = rs.getLong("id"); + final String accountNo = rs.getString("accountNo"); + final String externalId = rs.getString("externalId"); + final Integer depositTypeId = rs.getInt("depositType"); + final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); + + final Long groupId = JdbcSupport.getLong(rs, "groupId"); + final String groupName = rs.getString("groupName"); + + final Long clientId = JdbcSupport.getLong(rs, "clientId"); + final String clientName = rs.getString("clientName"); + + final Long productId = rs.getLong("productId"); + final String productName = rs.getString("productName"); + + final Long fieldOfficerId = rs.getLong("fieldOfficerId"); + final String fieldOfficerName = rs.getString("fieldOfficerName"); + + final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); + final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); + + final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum"); + final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum); + final String reasonForBlock = rs.getString("reasonForBlock"); + + final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate"); + final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive"); + final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive"); + final Integer numDaysToDormancy = JdbcSupport.getInteger(rs, "daysToDormancy"); + final Integer numDaysToEscheat = JdbcSupport.getInteger(rs, "daysToEscheat"); + Integer daysToInactive = null; + Integer daysToDormancy = null; + Integer daysToEscheat = null; + + LocalDate currentDate = DateUtils.getBusinessLocalDate(); + if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) { + if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) { + daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.DORMANT.getValue()) { + daysToDormancy = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToDormancy)); + } + if (subStatusEnum < SavingsAccountSubStatusEnum.INACTIVE.getValue()) { + daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToInactive)); + } + } + + final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); + final String submittedByUsername = rs.getString("submittedByUsername"); + final String submittedByFirstname = rs.getString("submittedByFirstname"); + final String submittedByLastname = rs.getString("submittedByLastname"); + + final LocalDate rejectedOnDate = JdbcSupport.getLocalDate(rs, "rejectedOnDate"); + final String rejectedByUsername = rs.getString("rejectedByUsername"); + final String rejectedByFirstname = rs.getString("rejectedByFirstname"); + final String rejectedByLastname = rs.getString("rejectedByLastname"); + + final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate"); + final String withdrawnByUsername = rs.getString("withdrawnByUsername"); + final String withdrawnByFirstname = rs.getString("withdrawnByFirstname"); + final String withdrawnByLastname = rs.getString("withdrawnByLastname"); + + final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate"); + final String approvedByUsername = rs.getString("approvedByUsername"); + final String approvedByFirstname = rs.getString("approvedByFirstname"); + final String approvedByLastname = rs.getString("approvedByLastname"); + + final LocalDate activatedOnDate = JdbcSupport.getLocalDate(rs, "activatedOnDate"); + final String activatedByUsername = rs.getString("activatedByUsername"); + final String activatedByFirstname = rs.getString("activatedByFirstname"); + final String activatedByLastname = rs.getString("activatedByLastname"); + + final LocalDate closedOnDate = JdbcSupport.getLocalDate(rs, "closedOnDate"); + final String closedByUsername = rs.getString("closedByUsername"); + final String closedByFirstname = rs.getString("closedByFirstname"); + final String closedByLastname = rs.getString("closedByLastname"); + + final SavingsAccountApplicationTimelineData timeline = new SavingsAccountApplicationTimelineData(submittedOnDate, + submittedByUsername, submittedByFirstname, submittedByLastname, rejectedOnDate, rejectedByUsername, rejectedByFirstname, + rejectedByLastname, withdrawnOnDate, withdrawnByUsername, withdrawnByFirstname, withdrawnByLastname, approvedOnDate, + approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname, + activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname); + + final String currencyCode = rs.getString("currencyCode"); + final String currencyName = rs.getString("currencyName"); + final String currencyNameCode = rs.getString("currencyNameCode"); + final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); + final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); + final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); + final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, + currencyNameCode); + + final BigDecimal nominalAnnualInterestRate = rs.getBigDecimal("nominalAnnualInterestRate"); + + final EnumOptionData interestCompoundingPeriodType = SavingsEnumerations.compoundingInterestPeriodType( + SavingsCompoundingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestCompoundingPeriodType"))); + + final EnumOptionData interestPostingPeriodType = SavingsEnumerations.interestPostingPeriodType( + SavingsPostingInterestPeriodType.fromInt(JdbcSupport.getInteger(rs, "interestPostingPeriodType"))); + + final EnumOptionData interestCalculationType = SavingsEnumerations + .interestCalculationType(SavingsInterestCalculationType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationType"))); + + final EnumOptionData interestCalculationDaysInYearType = SavingsEnumerations.interestCalculationDaysInYearType( + SavingsInterestCalculationDaysInYearType.fromInt(JdbcSupport.getInteger(rs, "interestCalculationDaysInYearType"))); + + final BigDecimal minRequiredOpeningBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredOpeningBalance"); + + final Integer lockinPeriodFrequency = JdbcSupport.getInteger(rs, "lockinPeriodFrequency"); + EnumOptionData lockinPeriodFrequencyType = null; + final Integer lockinPeriodFrequencyTypeValue = JdbcSupport.getInteger(rs, "lockinPeriodFrequencyType"); + if (lockinPeriodFrequencyTypeValue != null) { + final SavingsPeriodFrequencyType lockinPeriodType = SavingsPeriodFrequencyType.fromInt(lockinPeriodFrequencyTypeValue); + lockinPeriodFrequencyType = SavingsEnumerations.lockinPeriodFrequencyType(lockinPeriodType); + } + + final boolean withdrawalFeeForTransfers = rs.getBoolean("withdrawalFeeForTransfers"); + + final boolean allowOverdraft = rs.getBoolean("allowOverdraft"); + final BigDecimal overdraftLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "overdraftLimit"); + final BigDecimal nominalAnnualInterestRateOverdraft = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "nominalAnnualInterestRateOverdraft"); + final BigDecimal minOverdraftForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "minOverdraftForInterestCalculation"); + + final BigDecimal minRequiredBalance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "minRequiredBalance"); + final boolean enforceMinRequiredBalance = rs.getBoolean("enforceMinRequiredBalance"); + + final BigDecimal maxAllowedLienLimit = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "maxAllowedLienLimit"); + final boolean lienAllowed = rs.getBoolean("lienAllowed"); + + final BigDecimal totalDeposits = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalDeposits"); + final BigDecimal totalWithdrawals = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawals"); + final BigDecimal totalWithdrawalFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithdrawalFees"); + final BigDecimal totalAnnualFees = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalAnnualFees"); + + final BigDecimal totalInterestEarned = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalInterestEarned"); + final BigDecimal totalInterestPosted = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "totalInterestPosted"); + final BigDecimal accountBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "accountBalance"); + final BigDecimal totalFeeCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalFeeCharge"); + final BigDecimal totalPenaltyCharge = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalPenaltyCharge"); + final BigDecimal totalOverdraftInterestDerived = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, + "totalOverdraftInterestDerived"); + final BigDecimal totalWithholdTax = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "totalWithholdTax"); + final LocalDate interestPostedTillDate = JdbcSupport.getLocalDate(rs, "interestPostedTillDate"); + + final BigDecimal minBalanceForInterestCalculation = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, + "minBalanceForInterestCalculation"); + final BigDecimal onHoldFunds = rs.getBigDecimal("onHoldFunds"); + + final BigDecimal onHoldAmount = rs.getBigDecimal("onHoldAmount"); + + BigDecimal availableBalance = accountBalance; + if (availableBalance != null && onHoldFunds != null) { + availableBalance = availableBalance.subtract(onHoldFunds); + } + + if (availableBalance != null && onHoldAmount != null) { + + availableBalance = availableBalance.subtract(onHoldAmount); + } + + BigDecimal interestNotPosted = BigDecimal.ZERO; + LocalDate lastInterestCalculationDate = null; + if (totalInterestEarned != null) { + interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived); + lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate"); + } + + final SavingsAccountSummaryData summary = new SavingsAccountSummaryData(currency, totalDeposits, totalWithdrawals, + totalWithdrawalFees, totalAnnualFees, totalInterestEarned, totalInterestPosted, accountBalance, totalFeeCharge, + totalPenaltyCharge, totalOverdraftInterestDerived, totalWithholdTax, interestNotPosted, lastInterestCalculationDate, + availableBalance, interestPostedTillDate); + + final boolean withHoldTax = rs.getBoolean("withHoldTax"); + final Long taxGroupId = JdbcSupport.getLong(rs, "taxGroupId"); + final String taxGroupName = rs.getString("taxGroupName"); + TaxGroupData taxGroupData = null; + if (taxGroupId != null) { + taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName); + } + + final SavingsAccountData accountData = SavingsAccountData.instance(id, accountNo, depositType, externalId, groupId, groupName, + clientId, clientName, productId, productName, fieldOfficerId, fieldOfficerName, status, subStatus, reasonForBlock, + timeline, currency, nominalAnnualInterestRate, interestCompoundingPeriodType, interestPostingPeriodType, + interestCalculationType, interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, + lockinPeriodFrequencyType, withdrawalFeeForTransfers, summary, allowOverdraft, overdraftLimit, minRequiredBalance, + enforceMinRequiredBalance, maxAllowedLienLimit, lienAllowed, minBalanceForInterestCalculation, onHoldFunds, + nominalAnnualInterestRateOverdraft, minOverdraftForInterestCalculation, withHoldTax, taxGroupData, + lastActiveTransactionDate, isDormancyTrackingActive, daysToInactive, daysToDormancy, daysToEscheat, onHoldAmount); + + // CBS extended fields + accountData.setCbsAccountType(rs.getString("cbsAccountType")); + accountData.setCustomerSegment(rs.getString("customerSegment")); + accountData.setAccountStatus(rs.getString("accountStatus")); + accountData.setModeOfOperation(rs.getString("modeOfOperation")); + accountData.setSpecialCategory(rs.getString("specialCategory")); + accountData.setWeakerSection(rs.getString("weakerSection")); + accountData.setGuardianName(rs.getString("guardianName")); + accountData.setGuardianCifNumber(rs.getString("guardianCifNumber")); + accountData.setGuardianRelationship(rs.getString("guardianRelationship")); + accountData.setMinorDateOfBirth(JdbcSupport.getLocalDate(rs, "minorDateOfBirth")); + accountData.setGuardianClientId(JdbcSupport.getLong(rs, "guardianClientId")); + accountData.setIntroducerClientId(JdbcSupport.getLong(rs, "introducerClientId")); + accountData.setIntroducerName(rs.getString("introducerName")); + accountData.setIntroducerCifNumber(rs.getString("introducerCifNumber")); + accountData.setLinkedAccountId(JdbcSupport.getLong(rs, "linkedAccountId")); + accountData.setLinkedAccountType(rs.getString("linkedAccountType")); + accountData.setLinkedAccountNo(rs.getString("linkedAccountNo")); + accountData.setBalanceSwapOption(rs.getObject("balanceSwapOption") != null ? rs.getBoolean("balanceSwapOption") : null); + accountData.setMsme(rs.getObject("msme") != null ? rs.getBoolean("msme") : null); + accountData.setNominationFacility(rs.getObject("nominationFacility") != null ? rs.getBoolean("nominationFacility") : null); + accountData.setAutoDebitFacility(rs.getObject("autoDebitFacility") != null ? rs.getBoolean("autoDebitFacility") : null); + accountData.setIntroducerRequired(rs.getObject("introducerRequired") != null ? rs.getBoolean("introducerRequired") : null); + accountData.setShareLinkingEnabled(rs.getObject("shareLinkingEnabled") != null ? rs.getBoolean("shareLinkingEnabled") : null); + accountData.setShareAccountId(JdbcSupport.getLong(rs, "shareAccountId")); + accountData.setShareAccountNo(rs.getString("shareAccountNo")); + accountData.setTerminateTrue(rs.getObject("terminateTrue") != null ? rs.getBoolean("terminateTrue") : null); + + // Nominee as parsed map + final String nomineeDataStr = rs.getString("nomineeData"); + if (nomineeDataStr != null && !nomineeDataStr.isEmpty()) { + try { + com.google.gson.Gson gson = new com.google.gson.Gson(); + @SuppressWarnings("unchecked") + java.util.Map nomineeMap = gson.fromJson(nomineeDataStr, + new com.google.gson.reflect.TypeToken>() {}.getType()); + accountData.setNomineeData(nomineeMap); + } catch (Exception ignored) { + // intentionally ignored + } + } + + // Facility config + final boolean hasFacilityConfig = rs.getObject("atmFacility") != null; + if (hasFacilityConfig) { + final java.util.Map facilityConfigMap = new java.util.LinkedHashMap<>(); + facilityConfigMap.put("atmFacility", rs.getBoolean("atmFacility")); + facilityConfigMap.put("atmWithdrawalFacility", rs.getBoolean("atmWithdrawalFacility")); + facilityConfigMap.put("upiFacility", rs.getBoolean("upiFacility")); + facilityConfigMap.put("posTransactionsFacility", rs.getBoolean("posTransactionsFacility")); + facilityConfigMap.put("chequeBook", rs.getBoolean("chequeBook")); + facilityConfigMap.put("impsFacility", rs.getBoolean("impsFacility")); + facilityConfigMap.put("smsAlerts", rs.getBoolean("smsAlerts")); + facilityConfigMap.put("bbpsFacility", rs.getBoolean("bbpsFacility")); + facilityConfigMap.put("insuranceCover", rs.getBoolean("insuranceCover")); + facilityConfigMap.put("bepgFacility", rs.getBoolean("bepgFacility")); + facilityConfigMap.put("neftFacility", rs.getBoolean("neftFacility")); + facilityConfigMap.put("rtgsFacility", rs.getBoolean("rtgsFacility")); + facilityConfigMap.put("interBankTotalCapFacility", rs.getBoolean("interBankTotalCapFacility")); + facilityConfigMap.put("depositFacilityKyc", rs.getBoolean("depositFacilityKyc")); + facilityConfigMap.put("virtualDebitCard", rs.getBoolean("virtualDebitCard")); + accountData.setAccountFacilityConfig(facilityConfigMap); + } + + return accountData; + } + } + + private static final class SavingAccountMapperForLookup implements RowMapper { + + private final String schemaSql; + + SavingAccountMapperForLookup() { + final StringBuilder sqlBuilder = new StringBuilder(400); + sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); + sqlBuilder.append("sa.deposit_type_enum as depositType, "); + sqlBuilder.append("sp.id as productId, sp.name as productName, "); + sqlBuilder.append("sa.status_enum as statusEnum "); + + sqlBuilder.append("from m_savings_account sa "); + sqlBuilder.append("join m_savings_product sp ON sa.product_id = sp.id "); + + this.schemaSql = sqlBuilder.toString(); + } + + public String schema() { + return this.schemaSql; + } + + @Override + public SavingsAccountData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + + final Long id = rs.getLong("id"); + final String accountNo = rs.getString("accountNo"); + final Integer depositTypeId = rs.getInt("depositType"); + final EnumOptionData depositType = SavingsEnumerations.depositType(depositTypeId); + + final Long productId = rs.getLong("productId"); + final String productName = rs.getString("productName"); + + final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum"); + final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum); + + return SavingsAccountData.lookupWithProductDetails(id, accountNo, depositType, productId, productName, status); + } + } + + @Override + public SavingsAccountTransactionData retrieveDepositTransactionTemplate(final Long savingsId, + final DepositAccountType depositAccountType) { + + try { + final String sql = "select " + this.transactionTemplateMapper.schema() + " where sa.id = ? and sa.deposit_type_enum = ?"; + + return this.jdbcTemplate.queryForObject(sql, this.transactionTemplateMapper, // NOSONAR + new Object[] { savingsId, depositAccountType.getValue() }); + } catch (final EmptyResultDataAccessException e) { + throw new SavingsAccountNotFoundException(savingsId, e); + } + } + + @Override + public Collection retrieveAllTransactions(final Long savingsId, DepositAccountType depositAccountType) { + // #audit backward compatibility + final String sql = "select " + this.transactionsMapper.schema() + + " where sa.id = ? and sa.deposit_type_enum = ? order by tr.transaction_date DESC," + " tr." + CREATED_DATE_DB_FIELD + + " DESC, tr.created_date DESC, tr.id DESC"; + return this.jdbcTemplate.query(sql, this.transactionsMapper, new Object[] { savingsId, depositAccountType.getValue() }); // NOSONAR + } + + @Override + public SavingsAccountTransactionData retrieveSavingsTransaction(final Long savingsId, final Long transactionId, + DepositAccountType depositAccountType) { + + final String sql = "select " + this.transactionsMapper.schema() + " where sa.id = ? and sa.deposit_type_enum = ? and tr.id= ?"; + + return this.jdbcTemplate.queryForObject(sql, this.transactionsMapper, // NOSONAR + new Object[] { savingsId, depositAccountType.getValue(), transactionId }); + } + + private static final class SavingsAccountTransactionsForBatchMapper implements RowMapper { + + private final String schemaSql; + + SavingsAccountTransactionsForBatchMapper() { + + final StringBuilder sqlBuilder = new StringBuilder(400); + sqlBuilder.append("tr.id as transactionId, tr.ref_no as refNo "); + sqlBuilder.append("from m_savings_account_transaction tr"); + + this.schemaSql = sqlBuilder.toString(); + } + + public String schema() { + return this.schemaSql; + } + + @Override + public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + final Long id = rs.getLong("transactionId"); + SavingsAccountTransactionData savingsAccountTransactionData = SavingsAccountTransactionData.create(id); + final String refNo = rs.getString("refNo"); + savingsAccountTransactionData.setRefNo(refNo); + return savingsAccountTransactionData; + } + } + + /* + * @Override public Collection retrieveAccountsWithAnnualFeeDue() { final String sql = + * "select " + this.annualFeeMapper.schema() + + * " where sa.annual_fee_next_due_date is not null and sa.annual_fee_next_due_date <= NOW()" ; + * + * return this.jdbcTemplate.query(sql, this.annualFeeMapper, new Object[] {}); } + */ + + public static class SavingsAccountTransactionsMapper implements RowMapper { + + private static final String SELECT = buildSelect(); + private static final String FROM = buildFrom(); + private static final String SCHEMA = SELECT + FROM; + + public SavingsAccountTransactionsMapper() {} + + protected static String buildSelect() { + return "tr.id as transactionId, tr.transaction_type_enum as transactionType, " + + "tr.transaction_date as transactionDate, tr.amount as transactionAmount, " + + "tr.release_id_of_hold_amount as releaseTransactionId, tr.reason_for_block as reasonForBlock, " + + "tr.submitted_on_date as submittedOnDate, au.username as submittedByUsername, nt.note as transactionNote, " + + "tr.running_balance_derived as runningBalance, tr.is_reversed as reversed, " + + "tr.is_reversal as isReversal, tr.original_transaction_id as originalTransactionId, tr.is_lien_transaction as lienTransaction, " + + "fromtran.id as fromTransferId, fromtran.is_reversed as fromTransferReversed, " + + "fromtran.transaction_date as fromTransferDate, fromtran.amount as fromTransferAmount, " + + "fromtran.description as fromTransferDescription, " + + "totran.id as toTransferId, totran.is_reversed as toTransferReversed, " + + "totran.transaction_date as toTransferDate, totran.amount as toTransferAmount, " + + "totran.description as toTransferDescription, sa.id as savingsId, sa.account_no as accountNo, " + + "pd.payment_type_id as paymentType,pd.account_number as accountNumber,pd.check_number as checkNumber, " + + "pd.receipt_number as receiptNumber, pd.bank_number as bankNumber,pd.routing_code as routingCode, " + + "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, " + + "curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, " + + "curr.display_symbol as currencyDisplaySymbol, pt.value as paymentTypeName, " + "tr.is_manual as postInterestAsOn "; + } + + protected static String buildFrom() { + return " FROM m_savings_account_transaction tr join m_savings_account sa on tr.savings_account_id = sa.id " + + "join m_currency curr on curr.code = sa.currency_code " + + "left join m_account_transfer_transaction fromtran on fromtran.from_savings_transaction_id = tr.id " + + "left join m_account_transfer_transaction totran on totran.to_savings_transaction_id = tr.id " + + "left join m_payment_detail pd on tr.payment_detail_id = pd.id " + + "left join m_payment_type pt on pd.payment_type_id = pt.id left join m_appuser au on au.id= tr." + CREATED_BY_DB_FIELD + + " left join m_note nt ON nt.savings_account_transaction_id=tr.id "; + } + + public String schema() { + return SCHEMA; + } + + public String select() { + return SELECT; + } + + public String from() { + return FROM; + } + + @Override + public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + final Long id = rs.getLong("transactionId"); + final int transactionTypeInt = JdbcSupport.getInteger(rs, "transactionType"); + final SavingsAccountTransactionEnumData transactionType = SavingsEnumerations.transactionType(transactionTypeInt); + + final LocalDate date = JdbcSupport.getLocalDate(rs, "transactionDate"); + final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate"); + final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "transactionAmount"); + final Long releaseTransactionId = rs.getLong("releaseTransactionId"); + final String reasonForBlock = rs.getString("reasonForBlock"); + final BigDecimal outstandingChargeAmount = null; + final BigDecimal runningBalance = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "runningBalance"); + final boolean reversed = rs.getBoolean("reversed"); + final boolean isReversal = rs.getBoolean("isReversal"); + final Long originalTransactionId = rs.getLong("originalTransactionId"); + final Boolean lienTransaction = rs.getBoolean("lienTransaction"); + + final Long savingsId = rs.getLong("savingsId"); + final String accountNo = rs.getString("accountNo"); + final boolean postInterestAsOn = rs.getBoolean("postInterestAsOn"); + + PaymentDetailData paymentDetailData = null; + if (transactionType.isDepositOrWithdrawal()) { + final Long paymentTypeId = JdbcSupport.getLong(rs, "paymentType"); + if (paymentTypeId != null) { + final String typeName = rs.getString("paymentTypeName"); + final PaymentTypeData paymentType = PaymentTypeData.builder().id(paymentTypeId).name(typeName).build(); + final String accountNumber = rs.getString("accountNumber"); + final String checkNumber = rs.getString("checkNumber"); + final String routingCode = rs.getString("routingCode"); + final String receiptNumber = rs.getString("receiptNumber"); + final String bankNumber = rs.getString("bankNumber"); + paymentDetailData = new PaymentDetailData(id, paymentType, accountNumber, checkNumber, routingCode, receiptNumber, + bankNumber); + } + } + + final String currencyCode = rs.getString("currencyCode"); + final String currencyName = rs.getString("currencyName"); + final String currencyNameCode = rs.getString("currencyNameCode"); + final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); + final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); + final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); + final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, + currencyNameCode); + + AccountTransferData transfer = null; + final Long fromTransferId = JdbcSupport.getLong(rs, "fromTransferId"); + final Long toTransferId = JdbcSupport.getLong(rs, "toTransferId"); + if (fromTransferId != null) { + final LocalDate fromTransferDate = JdbcSupport.getLocalDate(rs, "fromTransferDate"); + final BigDecimal fromTransferAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "fromTransferAmount"); + final boolean fromTransferReversed = rs.getBoolean("fromTransferReversed"); + final String fromTransferDescription = rs.getString("fromTransferDescription"); + + transfer = AccountTransferData.transferBasicDetails(fromTransferId, currency, fromTransferAmount, fromTransferDate, + fromTransferDescription, fromTransferReversed); + } else if (toTransferId != null) { + final LocalDate toTransferDate = JdbcSupport.getLocalDate(rs, "toTransferDate"); + final BigDecimal toTransferAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "toTransferAmount"); + final boolean toTransferReversed = rs.getBoolean("toTransferReversed"); + final String toTransferDescription = rs.getString("toTransferDescription"); + + transfer = AccountTransferData.transferBasicDetails(toTransferId, currency, toTransferAmount, toTransferDate, + toTransferDescription, toTransferReversed); + } + final String submittedByUsername = rs.getString("submittedByUsername"); + final String note = rs.getString("transactionNote"); + return SavingsAccountTransactionData.create(id, transactionType, paymentDetailData, savingsId, accountNo, date, currency, + amount, outstandingChargeAmount, runningBalance, reversed, transfer, submittedOnDate, postInterestAsOn, + submittedByUsername, note, isReversal, originalTransactionId, lienTransaction, releaseTransactionId, reasonForBlock); + } + } + + private static final class SavingsAccountTransactionTemplateMapper implements RowMapper { + + private final String schemaSql; + + SavingsAccountTransactionTemplateMapper() { + final StringBuilder sqlBuilder = new StringBuilder(400); + sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); + sqlBuilder.append( + "sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, "); + sqlBuilder.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); + sqlBuilder.append("curr.display_symbol as currencyDisplaySymbol, "); + sqlBuilder.append("sa.min_required_opening_balance as minRequiredOpeningBalance "); + sqlBuilder.append("from m_savings_account sa "); + sqlBuilder.append("join m_currency curr on curr.code = sa.currency_code "); + + this.schemaSql = sqlBuilder.toString(); + } + + public String schema() { + return this.schemaSql; + } + + @Override + public SavingsAccountTransactionData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + + final Long savingsId = rs.getLong("id"); + final String accountNo = rs.getString("accountNo"); + + final String currencyCode = rs.getString("currencyCode"); + final String currencyName = rs.getString("currencyName"); + final String currencyNameCode = rs.getString("currencyNameCode"); + final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol"); + final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits"); + final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf"); + final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf, currencyDisplaySymbol, + currencyNameCode); + + return SavingsAccountTransactionData.template(savingsId, accountNo, DateUtils.getBusinessLocalDate(), currency); + } + } + + @Override + public Collection retrieveForLookup(Long clientId, Boolean overdraft) { + + SavingAccountMapperForLookup accountMapperForLookup = new SavingAccountMapperForLookup(); + final StringBuilder sqlBuilder = new StringBuilder("select " + accountMapperForLookup.schema()); + sqlBuilder.append(" where sa.client_id = ? and sa.status_enum = 300"); + Object[] queryParameters = null; + if (overdraft == null) { + queryParameters = new Object[] { clientId }; + } else { + sqlBuilder.append(" and sa.allow_overdraft = ?"); + queryParameters = new Object[] { clientId, overdraft }; + } + return this.jdbcTemplate.query(sqlBuilder.toString(), accountMapperForLookup, queryParameters); + + } + + @Override + public List retrieveSavingsIdsPendingInactive(LocalDate tenantLocalDate) { + List ret = null; + StringBuilder sql = new StringBuilder("select sa.id "); + sql.append(" from m_savings_account as sa "); + sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); + sql.append(" where sa.status_enum = 300 "); + sql.append(" and sa.sub_status_enum = 0 "); + String compareDate = "(select COALESCE(max(sat.transaction_date), sa.activatedon_date) " + + "from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false" + + " and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)"; + sql.append(" and ").append(sqlGenerator.dateDiff("?", compareDate)).append(" >= sp.days_to_inactive "); + + try { + ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class); + } catch (EmptyResultDataAccessException e) { + // ignore empty result scenario + } catch (DataAccessException e) { + throw e; + } + + return ret; + } + + @Override + public List retrieveSavingsIdsPendingDormant(LocalDate tenantLocalDate) { + List ret = null; + StringBuilder sql = new StringBuilder("select sa.id "); + sql.append(" from m_savings_account as sa "); + sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); + sql.append(" where sa.status_enum = 300 "); + sql.append(" and sa.sub_status_enum = 100 "); + sql.append(" and " + sqlGenerator.dateDiff("?", + "(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") + + " "); + sql.append(" >= sp.days_to_dormancy "); + + try { + ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class); + } catch (EmptyResultDataAccessException e) { + // ignore empty result scenario + } catch (DataAccessException e) { + throw e; + } + + return ret; + } + + @Override + public List retrieveSavingsIdsPendingEscheat(LocalDate tenantLocalDate) { + List ret = null; + StringBuilder sql = new StringBuilder("select sa.id "); + sql.append(" from m_savings_account as sa "); + sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) "); + sql.append(" where sa.status_enum = 300 "); + sql.append(" and sa.sub_status_enum = 200 "); + sql.append(" and " + sqlGenerator.dateDiff("?", + "(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)") + + " "); + sql.append(" >= sp.days_to_escheat "); + + try { + ret = this.jdbcTemplate.queryForList(sql.toString(), Long.class, new Object[] { tenantLocalDate }); + } catch (EmptyResultDataAccessException e) { + // ignore empty result scenario + } catch (DataAccessException e) { + throw e; + } + + return ret; + } + + @Override + public boolean isAccountBelongsToClient(final Long clientId, final Long accountId, final DepositAccountType depositAccountType, + final String currencyCode) { + try { + final StringBuilder buff = new StringBuilder("select count(*) from m_savings_account sa "); + buff.append( + " where sa.id = ? and sa.client_id = ? and sa.deposit_type_enum = ? and sa.currency_code = ? and sa.status_enum = 300"); + return this.jdbcTemplate.queryForObject(buff.toString(), Integer.class, accountId, clientId, depositAccountType.getValue(), + currencyCode) > 0; + } catch (final EmptyResultDataAccessException e) { + throw new SavingsAccountNotFoundException(accountId, e); + } + } + /* + * private static final class SavingsAccountAnnualFeeMapper implements RowMapper { + * + * private final String schemaSql; + * + * public SavingsAccountAnnualFeeMapper() { final StringBuilder sqlBuilder = new StringBuilder(200); + * sqlBuilder.append("sa.id as id, sa.account_no as accountNo, "); sqlBuilder + * .append("sa.annual_fee_next_due_date as annualFeeNextDueDate "); sqlBuilder.append("from m_savings_account sa "); + * + * this.schemaSql = sqlBuilder.toString(); } + * + * public String schema() { return this.schemaSql; } + * + * @Override public SavingsAccountAnnualFeeData mapRow(final ResultSet rs, + * + * @SuppressWarnings("unused") final int rowNum) throws SQLException { + * + * final Long id = rs.getLong("id"); final String accountNo = rs.getString("accountNo"); final LocalDate + * annualFeeNextDueDate = JdbcSupport.getLocalDate(rs, "annualFeeNextDueDate"); + * + * return SavingsAccountAnnualFeeData.instance(id, accountNo, annualFeeNextDueDate); } } + */ + + @Override + public String retrieveAccountNumberByAccountId(Long accountId) { + try { + final String sql = "select s.account_no from m_savings_account s where s.id = ?"; + return this.jdbcTemplate.queryForObject(sql, String.class, accountId); + } catch (final EmptyResultDataAccessException e) { + throw new SavingsAccountNotFoundException(accountId, e); + } + } + + @Override + public List getAccountsIdsByStatusPaged(Integer status, int pageSize, Long maxSavingsIdInList) { + String sql = new StringBuilder().append(" SELECT sa.id FROM m_savings_account sa ") + .append(" where sa.id > ? and sa.status_enum = ? ").append(" order by sa.id limit ?").toString(); + + try { + return this.jdbcTemplate.queryForList(sql, Long.class, new Object[] { maxSavingsIdInList, status, pageSize }); + } catch (EmptyResultDataAccessException e) { + return new ArrayList<>(); + } + } + + @Override + public Long retrieveAccountIdByExternalId(final ExternalId externalId) { + return savingsAccountRepositoryWrapper.findIdByExternalId(externalId); + } + + @Override + public List retrievePeriodicAccrualData(LocalDate tillDate, SavingsAccount savings) { + Long savingsId = (savings != null) ? savings.getId() : null; + Integer status = SavingsAccountStatusType.ACTIVE.getValue(); + Integer accountingRule = AccountingRuleType.ACCRUAL_PERIODIC.getValue(); + + return this.savingsAccountRepositoryWrapper.findAccrualData(tillDate, savingsId, status, accountingRule); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 212c9732c..8afd636c7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -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.GSIMRepositoy; 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.SavingsAccountAssembler; import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; @@ -166,6 +168,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi private final AppUserRepositoryWrapper appuserRepository; private final StandingInstructionRepository standingInstructionRepository; private final BusinessEventNotifierService businessEventNotifierService; + private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator; + private final SavingsDailyInterestService savingsDailyInterestService; private final GSIMRepositoy gsimRepository; private final SavingsAccountInterestPostingService savingsAccountInterestPostingService; private final ErrorHandler errorHandler; @@ -232,6 +236,17 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false); 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() // .withEntityId(savingsId) // .withOfficeId(account.officeId()) // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java index 2663310e8..fc1ffe061 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductReadPlatformServiceImpl.java @@ -140,7 +140,6 @@ public class SavingsProductReadPlatformServiceImpl implements SavingsProductRead facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility()); facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility()); facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility()); - facilityConfigMap.put("withdrawalFacility", facilityConfig.getWithdrawalFacility()); facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc()); facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard()); product.setAccountFacilityConfig(facilityConfigMap); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java index e45876e82..54d4f96b7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java @@ -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.service.DateUtils; 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.SavingsProductDepositLimitKycRepository; import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig; @@ -103,6 +104,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi private final ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository; private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository; private final ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository; + private final SavingsProductTermsCascadeService termsCascadeService; /* * 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); } + // 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() // .withEntityId(product.getId()) // .with(changes).build(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 40f9f42c8..2cfc99881 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -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.FixedDepositAccountRepository; 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.RecurringDepositAccountRepository; import org.apache.fineract.portfolio.savings.domain.RecurringDepositProductRepository; @@ -473,6 +475,7 @@ public class SavingsConfiguration { DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository, StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService, + SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator, SavingsDailyInterestService savingsDailyInterestService, GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService, ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) { return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer, @@ -482,7 +485,8 @@ public class SavingsConfiguration { savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService, chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService, depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository, - standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService, + standingInstructionRepository, businessEventNotifierService, savingsDailyInterestOrchestrator, + savingsDailyInterestService, gsimRepository, savingsAccountInterestPostingService, errorHandler, denominationBreakdownWriter); } @@ -576,12 +580,13 @@ public class SavingsConfiguration { org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository pendingEditChargeRepository, org.apache.fineract.portfolio.savings.domain.ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository, 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, savingsProductAssembler, accountMappingWritePlatformService, fineractEntityAccessUtil, facilityConfigRepository, facilityLimitRepository, depositLimitKycRepository, productPendingEditRepository, fromApiJsonHelper, commandSourceRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository, pendingEditGlMappingRepository, - pendingEditSubmittedFieldRepository); + pendingEditSubmittedFieldRepository, termsCascadeService); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java index a2d1dcd0c..ac77c6bb0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java @@ -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.SponsorDDSlipLine; 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.service.SponsorDDAmendmentRequestWritePlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService; import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService; @@ -70,6 +72,7 @@ public class SponsorDDApiResource { private final SponsorDDReconciliationService reconciliationService; private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService; private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService; + private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService; private final PlatformSecurityContext context; private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private final Gson gson = new GsonBuilder().serializeNulls().create(); @@ -233,6 +236,18 @@ public class SponsorDDApiResource { 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) ── @POST @@ -260,7 +275,8 @@ public class SponsorDDApiResource { request.getCurrentAccountId(), request.getChequeNumber(), request.getChequeBankName(), - request.getCashierDepositRequestId() + request.getCashierDepositRequestId(), + request.getDenominationData() )); } @@ -410,6 +426,48 @@ public class SponsorDDApiResource { 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 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 ─────────────────────────────── @GET @@ -568,6 +626,7 @@ public class SponsorDDApiResource { // 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. private Long cashierDepositRequestId; + private String denominationData; } @lombok.Data @@ -602,6 +661,20 @@ public class SponsorDDApiResource { 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 public static class GlMappingRequest { private Long sponsorBankId; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDAmendmentRequestData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDAmendmentRequestData.java new file mode 100644 index 000000000..38433434e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDAmendmentRequestData.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java index 04a78d787..7cc8065d5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDGlMappingData.java @@ -35,16 +35,22 @@ public class SponsorDDGlMappingData implements Serializable { private String sponsorBankName; private Long ddPayableGlAccountId; private String ddPayableGlAccountName; + private String ddPayableGlCode; private Long chequeClearingGlAccountId; private String chequeClearingGlAccountName; + private String chequeClearingGlCode; private Long cashGlAccountId; private String cashGlAccountName; + private String cashGlCode; private Long chargesIncomeGlAccountId; private String chargesIncomeGlAccountName; + private String chargesIncomeGlCode; private Long cashSuspenseGlAccountId; private String cashSuspenseGlAccountName; + private String cashSuspenseGlCode; private BigDecimal gstPercentage; private Long gstPayableGlAccountId; private String gstPayableGlAccountName; + private String gstPayableGlCode; private BigDecimal ddLimitAmount; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java index 8e1e75f71..ac63e0d96 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/data/SponsorDDLeafletData.java @@ -57,4 +57,6 @@ public class SponsorDDLeafletData implements Serializable { private Long approvedUnconsumedReprintRequestId; private Long approverUserId; private String approverUsername; + /** Set while a correction to this draft is awaiting a manager. */ + private Long pendingAmendmentRequestId; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformService.java new file mode 100644 index 000000000..8dc093a7c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformServiceImpl.java new file mode 100644 index 000000000..513c1dfc5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDAmendmentRequestWritePlatformServiceImpl.java @@ -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 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 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 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 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 findLeafletOrThrow(final Long leafletId) { + final List> 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 findAmendmentRequestOrThrow(final Long amendmentRequestId) { + final List> 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."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java index b5dcc10c4..498e8fc05 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java @@ -42,7 +42,7 @@ public interface SponsorDDIssueRequestWritePlatformService { String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch, String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate, 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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index e27e53176..d8febf463 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -101,7 +101,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI 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 currentAccountId, final String chequeNumber, final String chequeBankName, - final Long cashierDepositRequestId) { + final Long cashierDepositRequestId, final String denominationData) { final AppUser currentUser = this.context.authenticatedUser(); @@ -138,13 +138,45 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI String walkinPurchaserAadhaarMasked = 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)) { walkinPurchaserAadhaarMasked = maskAadhaar(walkinPurchaserAadhaar); - final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); final BigDecimal expectedTotal = faceValue.add(effectiveCharges).add(gstAmount); - final Map depositRequest = validateCashierDepositRequestOrThrow(cashierDepositRequestId, expectedTotal, - cashSuspenseGlAccountId); - cashReceiptJournalEntryId = findJournalEntryIdForCashierDeposit(depositRequest, cashSuspenseGlAccountId); + final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); + if (cashierDepositRequestId != null) { + final Map depositRequest = validateCashierDepositRequestOrThrow(cashierDepositRequestId, expectedTotal, + 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 { validateClientExists(clientId); 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, " + "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, " - + "cashier_deposit_request_id, cash_receipt_journal_entry_id) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + + "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", new String[] { "id" }); ps.setLong(1, leafletId); 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); 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); + ps.setString(25, denominationData); return ps; }, kh); 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(); } @@ -500,9 +541,20 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // and what is still unclaimed as two separate balances rather than only the net. drGlAccountId = resolveDividendFundingGlAccount(faceValue, issuingBranchId, issuedDate, ddNumber); } else { - // Cash was already recorded (Dr Cash at Tellers / Cr this suspense account) when the - // request was submitted, so approval clears the suspense rather than debiting cash again. - drGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(); + // Cash payment. The money was taken at submission and is sitting in the cash-suspense + // 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(); + } 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; @@ -523,6 +575,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, 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)) { 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 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 BigDecimal refundTotal = faceValue.add(effectiveCharges).add(effectiveGst); 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()); @@ -782,21 +842,15 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI return requests; } - // ── GL mapping configuration ─────────────────────────────────────────── - @Override @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 cashSuspenseGlAccountId, final BigDecimal gstPercentage, final Long gstPayableGlAccountId, final BigDecimal ddLimitAmount) { - final AppUser currentUser = this.context.authenticatedUser(); - validateCheckerRole(currentUser); - if (gstPercentage != null && (gstPercentage.compareTo(BigDecimal.ZERO) < 0 || gstPercentage.compareTo(BigDecimal.valueOf(100)) > 0)) { - 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."); + + if (ddPayableGlAccountId == null) { + throw new IllegalArgumentException("Sponsor DD Payable GL account is required."); } final BigDecimal effectiveGstPercentage = gstPercentage != null ? gstPercentage : BigDecimal.valueOf(18); @@ -839,6 +893,82 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // ── 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 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 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 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 findLeafletOrThrow(final Long leafletId) { final List> rows = this.jdbcTemplate.queryForList( "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"); if (rows.isEmpty()) { 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 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) { 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) { 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."); - } - 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."); + "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."); } final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0; if (hasCharges && mapping.get("charges_income_gl_account_id") == null) { 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"); if (hasCharges && gstPercentage != null && gstPercentage.compareTo(BigDecimal.ZERO) > 0 @@ -1217,6 +1347,72 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI currentAccountId); 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 lines = new ArrayList<>(); + + final Map 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 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 lines = new ArrayList<>(); + + final Map 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 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. * @@ -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 * 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. + * + * 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, final Long ddPayableGlAccountId, final BigDecimal faceValue, final BigDecimal chargesAmount, final Long chargesGlAccountId, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java index b013e284b..d427ab959 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformService.java @@ -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.SponsorDDIssueRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData; +import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData; public interface SponsorDDReadPlatformService { @@ -58,6 +59,11 @@ public interface SponsorDDReadPlatformService { Collection retrieveAllReprintRequests(String status); + /** DD amendment requests for the approval queue, optionally narrowed to one status. */ + Collection 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 * Suspense GL account, and not yet linked to any Sponsor DD issue request - candidates a clerk diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java index 010b6bee4..471e767d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java @@ -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.SponsorDDIssueRequestData; 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.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.EmptyResultDataAccessException; @@ -81,7 +82,8 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe + "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.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 " + "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 " @@ -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 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_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 public Collection 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 approverUserId = rs.getObject("approver_user_id") != null ? rs.getLong("approver_user_id") : null; 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, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch, payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate, 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 = "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, " - + "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.cash_suspense_gl_account_id, suspense.name as cash_suspense_gl_account_name, " - + "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, " + + "payable.name as dd_payable_gl_account_name, payable.gl_code as dd_payable_gl_code, " + + "m.cheque_clearing_gl_account_id, clearing.name as cheque_clearing_gl_account_name, clearing.gl_code as cheque_clearing_gl_code, " + + "m.cash_gl_account_id, cash.name as cash_gl_account_name, cash.gl_code as cash_gl_code, " + + "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 " + "from m_sponsor_dd_gl_mapping m " + "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 Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id"); 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 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 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 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 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 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 gstPayableGlCode = rs.getString("gst_payable_gl_code"); final BigDecimal ddLimitAmount = rs.getBigDecimal("dd_limit_amount"); - return new SponsorDDGlMappingData(id, sponsorBankId, sponsorBankName, ddPayableGlAccountId, ddPayableGlAccountName, - chequeClearingGlAccountId, chequeClearingGlAccountName, cashGlAccountId, cashGlAccountName, - chargesIncomeGlAccountId, chargesIncomeGlAccountName, cashSuspenseGlAccountId, cashSuspenseGlAccountName, - gstPercentage, gstPayableGlAccountId, gstPayableGlAccountName, ddLimitAmount); + return new SponsorDDGlMappingData(id, sponsorBankId, sponsorBankName, ddPayableGlAccountId, ddPayableGlAccountName, ddPayableGlCode, + chequeClearingGlAccountId, chequeClearingGlAccountName, chequeClearingGlCode, cashGlAccountId, cashGlAccountName, cashGlCode, + chargesIncomeGlAccountId, chargesIncomeGlAccountName, chargesIncomeGlCode, cashSuspenseGlAccountId, cashSuspenseGlAccountName, cashSuspenseGlCode, + 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 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 { + + @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 public Collection retrieveLeaflets(final Long sponsorBankId, final String status, final String adviceStatus) { final List params = new ArrayList<>(); @@ -440,6 +506,35 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe 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 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 public Collection retrieveAvailableCashierDepositRequests() { final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java index ab901f3b3..75c44c2d5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -434,7 +434,8 @@ public class VoucherVerificationApiResource { @GET @Path("report") 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 boolean seesEveryone = isCheckerRole(currentUser); @@ -460,7 +461,8 @@ public class VoucherVerificationApiResource { ver.status AS verification_status, dr.party_name AS debit_party, dr.account_no AS debit_account, cr.party_name AS credit_party, cr.account_no AS credit_account, - (SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count + (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 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 @@ -472,6 +474,52 @@ public class VoucherVerificationApiResource { SELECT party_name, account_no FROM m_voucher_line WHERE voucher_no = v.voucher_no AND entry_type = 'CR' ORDER BY line_no LIMIT 1) cr ON TRUE + -- 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 < ? """); @@ -489,6 +537,32 @@ public class VoucherVerificationApiResource { sql.append(" AND v.voucher_type = ?"); 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"); final List> 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. final int lineCount = ((Number) r.get("line_count")).intValue(); 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); } @@ -528,6 +606,7 @@ public class VoucherVerificationApiResource { // Tells the screen whether to offer the user filter at all, rather than having it guess // from the caller's roles a second time. res.put("canSeeAllUsers", seesEveryone); + res.put("cashOnly", restrictToCash); res.put("vouchers", out); res.put("count", out.size()); res.put("totalAmount", total); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 472524953..09f6075d8 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -1,6 +1,5 @@ - + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml index 33482f5f2..6c415241d 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0518_create_savings_interest_posting_schedule.xml @@ -108,6 +108,11 @@ 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 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. --> diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_add_kyc_expiry_restrictions_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_add_kyc_expiry_restrictions_job.xml new file mode 100644 index 000000000..527c0e0e1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_add_kyc_expiry_restrictions_job.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_rbi_dormancy_defaults.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_rbi_dormancy_defaults.xml new file mode 100644 index 000000000..7f39ff6ae --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0521_rbi_dormancy_defaults.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + 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 + + + + + + + + + + + + + + (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 + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_create_deaf_remittance.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_create_deaf_remittance.xml new file mode 100644 index 000000000..c28c2a810 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0522_create_deaf_remittance.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_dbt_dormancy_exemption.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_dbt_dormancy_exemption.xml new file mode 100644 index 000000000..fffcb9421 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0523_dbt_dormancy_exemption.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + is_dbt_beneficiary = true AND sub_status_enum IN (100, 200) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_rbi_dormancy_periods_all_products.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_rbi_dormancy_periods_all_products.xml new file mode 100644 index 000000000..f79bf8a10 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0524_rbi_dormancy_periods_all_products.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + deposit_type_enum = 100 + + + + + + + + + + + + + + + + + + + + sub_status_enum IN (100, 200) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml new file mode 100644 index 000000000..d73074fd4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml new file mode 100644 index 000000000..b09709538 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml @@ -0,0 +1,97 @@ + + + + + + + + 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 + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml new file mode 100644 index 000000000..a093b16d1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + 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) + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0528_fix_escheat_liability_mapping.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0528_fix_escheat_liability_mapping.xml new file mode 100644 index 000000000..1dfeba576 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0528_fix_escheat_liability_mapping.xml @@ -0,0 +1,51 @@ + + + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '200904' + + + + 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') + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0529_add_denomination_data_to_sponsor_dd_issue_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0529_add_denomination_data_to_sponsor_dd_issue_request.xml new file mode 100644 index 000000000..4d082bd4e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0529_add_denomination_data_to_sponsor_dd_issue_request.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0530_drop_dbt_dormancy_exemption.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0530_drop_dbt_dormancy_exemption.xml new file mode 100644 index 000000000..9ecd0b75d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0530_drop_dbt_dormancy_exemption.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0531_disable_stock_savings_interest_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0531_disable_stock_savings_interest_job.xml new file mode 100644 index 000000000..d0b6010b2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0531_disable_stock_savings_interest_job.xml @@ -0,0 +1,47 @@ + + + + + + + + + name = 'Post Interest For Savings' + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0532_sponsor_dd_amendment_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0532_sponsor_dd_amendment_request.xml new file mode 100644 index 000000000..0f3287b98 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0532_sponsor_dd_amendment_request.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java index 809c69456..772c96b2a 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java @@ -56,8 +56,9 @@ public class SavingsProductAccountingDataValidator { baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()) .value(savingsReferenceAccountId).notNull().integerGreaterThanZero(); } - if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element) - || ignoreExistenceValidation) { + // Transfers In Suspense is not offered by the savings product screen, so it is validated only + // when actually supplied — ignoreExistenceValidation must not make it mandatory on create. + if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) { final Long transfersInSuspenseAccountId = fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue()) @@ -85,22 +86,21 @@ public class SavingsProductAccountingDataValidator { .value(incomeFromPenaltyId).notNull().integerGreaterThanZero(); } if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) { - if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element) - || ignoreExistenceValidation) { + // Overdraft Control / Income From Interest / Write Off are likewise not offered by the + // 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 .extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue()) .value(overdraftControlAccountId).notNull().integerGreaterThanZero(); } - if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element) - || ignoreExistenceValidation) { + if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) { final Long incomeFromInterest = fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()) .value(incomeFromInterest).notNull().integerGreaterThanZero(); } - if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element) - || ignoreExistenceValidation) { + if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) { final Long writtenoff = fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenoff) diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittance.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittance.java new file mode 100644 index 000000000..1375764e6 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittance.java @@ -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. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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; + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittanceRepository.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittanceRepository.java new file mode 100644 index 000000000..3a61ecca4 --- /dev/null +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DeafRemittanceRepository.java @@ -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 { + + Optional findBySavingsAccountId(Long savingsAccountId); + + boolean existsBySavingsAccountId(Long savingsAccountId); + + List findBySavingsAccountIdIn(List savingsAccountIds); +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java index 7f9069144..6faafff41 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FacilityConfigEditFields.java @@ -68,9 +68,6 @@ public class FacilityConfigEditFields { @Column(name = "fc_inter_bank_total_cap_facility") private Boolean interBankTotalCapFacility; - @Column(name = "fc_withdrawal_facility") - private Boolean withdrawalFacility; - @Column(name = "fc_deposit_facility_kyc") private Boolean depositFacilityKyc; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java index e7cfd0d76..9fa9fe453 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java @@ -417,7 +417,6 @@ public final class ProductPendingEditFieldMapper { f.setNeftFacility(booleanOf(command, "accountFacilityConfig", "neftFacility")); f.setRtgsFacility(booleanOf(command, "accountFacilityConfig", "rtgsFacility")); f.setInterBankTotalCapFacility(booleanOf(command, "accountFacilityConfig", "interBankTotalCapFacility")); - f.setWithdrawalFacility(booleanOf(command, "accountFacilityConfig", "withdrawalFacility")); f.setDepositFacilityKyc(booleanOf(command, "accountFacilityConfig", "depositFacilityKyc")); f.setVirtualDebitCard(booleanOf(command, "accountFacilityConfig", "virtualDebitCard")); return true; @@ -441,7 +440,6 @@ public final class ProductPendingEditFieldMapper { config.addProperty("neftFacility", f.getNeftFacility()); config.addProperty("rtgsFacility", f.getRtgsFacility()); config.addProperty("interBankTotalCapFacility", f.getInterBankTotalCapFacility()); - config.addProperty("withdrawalFacility", f.getWithdrawalFacility()); config.addProperty("depositFacilityKyc", f.getDepositFacilityKyc()); config.addProperty("virtualDebitCard", f.getVirtualDebitCard()); root.add("accountFacilityConfig", config); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 039ca6d90..1a0057291 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -1,4615 +1,4687 @@ -/** - * 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 static java.time.temporal.ChronoUnit.DAYS; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.allowOverdraftParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.dueAsOfDateParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lienAllowedParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.localeParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPeriodFrequencyParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPeriodFrequencyTypeParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.maxAllowedLienLimitParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minOverdraftForInterestCalculationParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequiredBalanceParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withdrawalFeeForTransfersParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.introducerRequiredParamName; -import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareLinkingEnabledParamName; - -import com.google.gson.JsonArray; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.DiscriminatorColumn; -import jakarta.persistence.DiscriminatorType; -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Embedded; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.Inheritance; -import jakarta.persistence.InheritanceType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import jakarta.persistence.OrderBy; -import jakarta.persistence.Table; -import jakarta.persistence.Transient; -import jakarta.persistence.UniqueConstraint; -import jakarta.persistence.Version; -import java.math.BigDecimal; -import java.math.MathContext; -import java.time.LocalDate; -import java.time.YearMonth; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; -import org.apache.commons.lang3.StringUtils; -import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; -import org.apache.fineract.infrastructure.configuration.service.TemporaryConfigurationServiceContainer; -import org.apache.fineract.infrastructure.core.api.JsonCommand; -import org.apache.fineract.infrastructure.core.data.ApiParameterError; -import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; -import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; -import org.apache.fineract.infrastructure.core.domain.ExternalId; -import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; -import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; -import org.apache.fineract.infrastructure.core.service.MathUtil; -import org.apache.fineract.infrastructure.security.service.RandomPasswordGenerator; -import org.apache.fineract.interoperation.domain.InteropIdentifier; -import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; -import org.apache.fineract.organisation.monetary.domain.Money; -import org.apache.fineract.organisation.monetary.domain.MoneyHelper; -import org.apache.fineract.organisation.office.domain.Office; -import org.apache.fineract.organisation.staff.domain.Staff; -import org.apache.fineract.portfolio.accountdetails.domain.AccountType; -import org.apache.fineract.portfolio.charge.domain.Charge; -import org.apache.fineract.portfolio.charge.exception.SavingsAccountChargeNotFoundException; -import org.apache.fineract.portfolio.client.domain.Client; -import org.apache.fineract.portfolio.client.domain.ClientKyc; -import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; -import org.apache.fineract.portfolio.group.domain.Group; -import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; -import org.apache.fineract.portfolio.savings.DepositAccountType; -import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; -import org.apache.fineract.portfolio.savings.SavingsApiConstants; -import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; -import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; -import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; -import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; -import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; -import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; -import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; -import org.apache.fineract.portfolio.savings.domain.interest.SavingsAccountTransactionDetailsForPostingPeriod; -import org.apache.fineract.portfolio.savings.exception.InsufficientAccountBalanceException; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountBlockedException; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountCreditsBlockedException; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountDebitsBlockedException; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; -import org.apache.fineract.portfolio.savings.exception.SavingsActivityPriorToClientTransferException; -import org.apache.fineract.portfolio.savings.exception.SavingsOfficerAssignmentDateException; -import org.apache.fineract.portfolio.savings.exception.SavingsOfficerUnassignmentDateException; -import org.apache.fineract.portfolio.savings.exception.SavingsTransferTransactionsCannotBeUndoneException; -import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; -import org.apache.fineract.portfolio.tax.domain.TaxComponent; -import org.apache.fineract.portfolio.tax.domain.TaxGroup; -import org.apache.fineract.portfolio.tax.service.TaxUtils; -import org.apache.fineract.useradministration.domain.AppUser; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.util.CollectionUtils; - -@Entity -@Table(name = "m_savings_account", uniqueConstraints = { @UniqueConstraint(columnNames = { "account_no" }, name = "sa_account_no_UNIQUE"), - @UniqueConstraint(columnNames = { "external_id" }, name = "sa_external_id_UNIQUE") }) -@Inheritance(strategy = InheritanceType.SINGLE_TABLE) -@DiscriminatorColumn(name = "deposit_type_enum", discriminatorType = DiscriminatorType.INTEGER) -@DiscriminatorValue("100") -@SuppressWarnings({ "MemberName" }) -public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom implements IDepositAccountType { - - private static final Logger LOG = LoggerFactory.getLogger(SavingsAccount.class); - - @Version - int version; - - @Column(name = "account_no", length = 20, unique = true, nullable = false) - protected String accountNumber; - - @Column(name = "external_id", nullable = true) - protected ExternalId externalId; - - @ManyToOne(optional = true) - @JoinColumn(name = "client_id", nullable = true) - protected Client client; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "group_id", nullable = true) - protected Group group; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "gsim_id", nullable = true) - private GroupSavingsIndividualMonitoring gsim; - - @ManyToOne - @JoinColumn(name = "product_id", nullable = false) - protected SavingsProduct product; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "field_officer_id", nullable = true) - protected Staff savingsOfficer; - - @Column(name = "status_enum", nullable = false) - protected Integer status; - - @Column(name = "sub_status_enum", nullable = false) - protected Integer sub_status = 0; - - @Column(name = "account_type_enum", nullable = false) - protected Integer accountType; - - @Column(name = "submittedon_date", nullable = true) - protected LocalDate submittedOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "submittedon_userid", nullable = true) - protected AppUser submittedBy; - - @Column(name = "rejectedon_date") - protected LocalDate rejectedOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "rejectedon_userid", nullable = true) - protected AppUser rejectedBy; - - @Column(name = "withdrawnon_date") - protected LocalDate withdrawnOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "withdrawnon_userid", nullable = true) - protected AppUser withdrawnBy; - - @Column(name = "approvedon_date") - protected LocalDate approvedOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "approvedon_userid", nullable = true) - protected AppUser approvedBy; - - @Column(name = "activatedon_date", nullable = true) - protected LocalDate activatedOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "activatedon_userid", nullable = true) - protected AppUser activatedBy; - - @Column(name = "closedon_date") - protected LocalDate closedOnDate; - - @ManyToOne(optional = true, fetch = FetchType.LAZY) - @JoinColumn(name = "closedon_userid", nullable = true) - protected AppUser closedBy; - - @Column(name = "reason_for_block", nullable = true) - protected String reasonForBlock; - - @Embedded - protected MonetaryCurrency currency; - - @Column(name = "nominal_annual_interest_rate", scale = 6, precision = 19, nullable = false) - protected BigDecimal nominalAnnualInterestRate; - - /** - * The interest period is the span of time at the end of which savings in a client's account earn interest. - * - * A value from the {@link SavingsCompoundingInterestPeriodType} enumeration. - */ - @Column(name = "interest_compounding_period_enum", nullable = false) - protected Integer interestCompoundingPeriodType; - - /** - * A value from the {@link SavingsPostingInterestPeriodType} enumeration. - */ - @Column(name = "interest_posting_period_enum", nullable = false) - protected Integer interestPostingPeriodType; - - /** - * A value from the {@link SavingsInterestCalculationType} enumeration. - */ - @Column(name = "interest_calculation_type_enum", nullable = false) - protected Integer interestCalculationType; - - /** - * A value from the {@link SavingsInterestCalculationDaysInYearType} enumeration. - */ - @Column(name = "interest_calculation_days_in_year_type_enum", nullable = false) - protected Integer interestCalculationDaysInYearType; - - @Column(name = "min_required_opening_balance", scale = 6, precision = 19, nullable = true) - protected BigDecimal minRequiredOpeningBalance; - - @Column(name = "lockin_period_frequency", nullable = true) - protected Integer lockinPeriodFrequency; - - @Column(name = "lockin_period_frequency_enum", nullable = true) - protected Integer lockinPeriodFrequencyType; - - /** - * When account becomes active this field is derived if lockinPeriodFrequency and - * lockinPeriodFrequencyType details are present. - */ - @Column(name = "lockedin_until_date_derived", nullable = true) - protected LocalDate lockedInUntilDate; - - @Column(name = "withdrawal_fee_for_transfer", nullable = true) - protected boolean withdrawalFeeApplicableForTransfer; - - @Column(name = "allow_overdraft") - private boolean allowOverdraft; - - @Column(name = "overdraft_limit", scale = 6, precision = 19, nullable = true) - private BigDecimal overdraftLimit; - - @Column(name = "nominal_annual_interest_rate_overdraft", scale = 6, precision = 19, nullable = true) - protected BigDecimal nominalAnnualInterestRateOverdraft; - - @Column(name = "min_overdraft_for_interest_calculation", scale = 6, precision = 19, nullable = true) - private BigDecimal minOverdraftForInterestCalculation; - - @Column(name = "enforce_min_required_balance") - private boolean enforceMinRequiredBalance; - - @Column(name = "min_required_balance", scale = 6, precision = 19, nullable = true) - private BigDecimal minRequiredBalance; - - @Column(name = "is_lien_allowed", nullable = false) - private boolean lienAllowed; - - @Column(name = "max_allowed_lien_limit", scale = 6, precision = 19, nullable = true) - private BigDecimal maxAllowedLienLimit; - - @Column(name = "on_hold_funds_derived", scale = 6, precision = 19, nullable = true) - private BigDecimal onHoldFunds; - - @Column(name = "start_interest_calculation_date") - protected LocalDate startInterestCalculationDate; - - @Embedded - protected SavingsAccountSummary summary; - - @OrderBy(value = "dateOf, createdDate, id") - @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) - protected List transactions = new ArrayList<>(); - - @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) - protected Set charges = new HashSet<>(); - - @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) - private Set savingsOfficerHistory = new HashSet<>(); - - @Transient - protected boolean accountNumberRequiresAutoGeneration = false; - @Transient - protected SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper; - @Transient - protected SavingsHelper savingsHelper; - @Transient - protected List savingsAccountTransactions = new ArrayList<>(); - - @Column(name = "deposit_type_enum", insertable = false, updatable = false) - private Integer depositType; - - @Column(name = "min_balance_for_interest_calculation", scale = 6, precision = 19, nullable = true) - private BigDecimal minBalanceForInterestCalculation; - - @Column(name = "withhold_tax", nullable = false) - protected boolean withHoldTax; - - @ManyToOne - @JoinColumn(name = "tax_group_id") - private TaxGroup taxGroup; - - @Column(name = "accrued_till_date") - private LocalDate accruedTillDate; - - @Column(name = "last_closed_business_date") - private LocalDate lastClosedBusinessDate; - - @Column(name = "total_savings_amount_on_hold", scale = 6, precision = 19, nullable = true) - private BigDecimal savingsOnHoldAmount; - - /** A hold placed because a cheque/DD/instrument is still in the clearing pipeline (a deposit not yet - * 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 - * rather than one bucket doing double duty. */ - @Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = true) - private BigDecimal savingsOnHoldAmountClearing = BigDecimal.ZERO; - - @OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY) - protected List identifiers = new ArrayList<>(); - - public transient ConfigurationDomainService configurationDomainService; - - protected SavingsAccount() { - // - } - - public static SavingsAccount createNewApplicationForSubmittal(final Client client, final Group group, final SavingsProduct product, - final Staff fieldOfficer, final String accountNo, final ExternalId externalId, final AccountType accountType, - final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal interestRate, - final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, - final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, - final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, - final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, - final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, - final boolean allowOverdraft, final BigDecimal overdraftLimit, final boolean enforceMinRequiredBalance, - final BigDecimal minRequiredBalance, final BigDecimal maxAllowedLienLimit, final boolean lienAllowed, - final BigDecimal nominalAnnualInterestRateOverdraft, final BigDecimal minOverdraftForInterestCalculation, - final boolean withHoldTax) { - - final SavingsAccountStatusType status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL; - return new SavingsAccount(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, submittedOnDate, - submittedBy, interestRate, interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, - interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, - withdrawalFeeApplicableForTransfer, savingsAccountCharges, allowOverdraft, overdraftLimit, enforceMinRequiredBalance, - minRequiredBalance, maxAllowedLienLimit, lienAllowed, nominalAnnualInterestRateOverdraft, - minOverdraftForInterestCalculation, withHoldTax); - } - - protected SavingsAccount(final Client client, final Group group, final SavingsProduct product, final Staff fieldOfficer, - final String accountNo, final ExternalId externalId, final SavingsAccountStatusType status, final AccountType accountType, - final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal nominalAnnualInterestRate, - final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, - final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, - final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, - final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, - final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, - final boolean allowOverdraft, final BigDecimal overdraftLimit, boolean withHoldTax) { - this(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, submittedOnDate, submittedBy, - nominalAnnualInterestRate, interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, - interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, - withdrawalFeeApplicableForTransfer, savingsAccountCharges, allowOverdraft, overdraftLimit, false, null, null, false, null, - null, withHoldTax); - } - - protected SavingsAccount(final Client client, final Group group, final SavingsProduct product, final Staff savingsOfficer, - final String accountNo, final ExternalId externalId, final SavingsAccountStatusType status, final AccountType accountType, - final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal nominalAnnualInterestRate, - final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, - final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, - final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, - final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, - final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, - final boolean allowOverdraft, final BigDecimal overdraftLimit, final boolean enforceMinRequiredBalance, - final BigDecimal minRequiredBalance, final BigDecimal maxAllowedLienLimit, final boolean lienAllowed, - final BigDecimal nominalAnnualInterestRateOverdraft, final BigDecimal minOverdraftForInterestCalculation, boolean withHoldTax) { - this.client = client; - this.group = group; - this.product = product; - this.savingsOfficer = savingsOfficer; - if (StringUtils.isBlank(accountNo)) { - this.accountNumber = new RandomPasswordGenerator(19).generate(); - this.accountNumberRequiresAutoGeneration = true; - } else { - this.accountNumber = accountNo; - } - - this.currency = product.currency(); - this.externalId = externalId; - this.status = status.getValue(); - this.accountType = accountType.getValue(); - this.submittedOnDate = submittedOnDate; - this.submittedBy = submittedBy; - this.nominalAnnualInterestRate = nominalAnnualInterestRate; - this.interestCompoundingPeriodType = interestCompoundingPeriodType.getValue(); - this.interestPostingPeriodType = interestPostingPeriodType.getValue(); - this.interestCalculationType = interestCalculationType.getValue(); - this.interestCalculationDaysInYearType = interestCalculationDaysInYearType.getValue(); - this.minRequiredOpeningBalance = minRequiredOpeningBalance; - this.lockinPeriodFrequency = lockinPeriodFrequency; - if (lockinPeriodFrequencyType != null) { - this.lockinPeriodFrequencyType = lockinPeriodFrequencyType.getValue(); - } - this.withdrawalFeeApplicableForTransfer = withdrawalFeeApplicableForTransfer; - - if (!CollectionUtils.isEmpty(savingsAccountCharges)) { - this.charges = associateChargesWithThisSavingsAccount(savingsAccountCharges); - } - - this.summary = new SavingsAccountSummary(); - this.allowOverdraft = allowOverdraft; - this.overdraftLimit = overdraftLimit; - this.nominalAnnualInterestRateOverdraft = nominalAnnualInterestRateOverdraft; - this.minOverdraftForInterestCalculation = minOverdraftForInterestCalculation; - esnureOverdraftLimitsSetForOverdraftAccounts(); - - this.enforceMinRequiredBalance = enforceMinRequiredBalance; - this.minRequiredBalance = minRequiredBalance; - this.lienAllowed = lienAllowed; - this.maxAllowedLienLimit = maxAllowedLienLimit; - this.minBalanceForInterestCalculation = product.minBalanceForInterestCalculation(); - // this.savingsOfficerHistory = null; - this.withHoldTax = withHoldTax; - this.taxGroup = product.getTaxGroup(); - } - - /** - * Used after fetching/hydrating a {@link SavingsAccount} object to inject helper services/components used for - * update summary details after events/transactions on a {@link SavingsAccount}. - */ - public void setHelpers(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper, - final SavingsHelper savingsHelper) { - this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper; - this.savingsHelper = savingsHelper; - } - - public void setSavingsAccountTransactions(final List savingsAccountTransactions) { - this.savingsAccountTransactions.addAll(savingsAccountTransactions); - } - - public List getSavingsAccountTransactionsWithPivotConfig() { - return this.savingsAccountTransactions; - } - - public ExternalId getExternalId() { - return externalId; - } - - public boolean isNotActive() { - return !isActive(); - } - - public boolean isActive() { - return SavingsAccountStatusType.fromInt(this.status).isActive(); - } - - public boolean isNotSubmittedAndPendingApproval() { - return !isSubmittedAndPendingApproval(); - } - - public boolean isSubmittedAndPendingApproval() { - return SavingsAccountStatusType.fromInt(this.status).isSubmittedAndPendingApproval(); - } - - public boolean isApproved() { - return SavingsAccountStatusType.fromInt(this.status).isApproved(); - } - - public boolean isActivated() { - boolean isActive = false; - if (this.activatedOnDate != null) { - isActive = true; - } - return isActive; - } - - public boolean isClosed() { - return SavingsAccountStatusType.fromInt(this.status).isClosed(); - } - - public List getIdentifiers() { - return identifiers; - } - - /** - * Extension point for the interest-posting correction loop in {@link #postInterest}: lets a subclass veto - * reversing/reposting an already-existing posting transaction for a period. Defaults to always allowing the - * correction, so plain savings and recurring deposit behaviour is unchanged. - */ - protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) { - return true; - } - - /** - * Extension point checked by {@code SavingsAccountDomainServiceJpa#handleWithdrawal} before it re-triggers an - * interest recalculation right after creating a withdrawal. Defaults to always requiring the full recalculation - * (via {@link #postInterest} / {@link #calculateInterestUsing}), so plain savings and recurring deposit - * behaviour is unchanged. When this returns false, {@link #refreshBalancesAndSummaryAfterTransfer} is called - * instead. - */ - public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) { - return true; - } - - /** - * Lightweight alternative to {@link #postInterest} used right after a withdrawal when the full interest - * recalculation is skipped (see {@link #isPostWithdrawalInterestRecalculationRequired}). Refreshes running - * balances and the account summary -- the bookkeeping {@link #postInterest} always performs at its tail, - * regardless of whether any posting correction was needed -- without re-running the interest - * posting/correction loop that recalculation drives. - */ - public void refreshBalancesAndSummaryAfterTransfer(final LocalDate interestPostingUpToDate, final boolean backdatedTxnsAllowedTill, - final boolean postReversals) { - Money openingAccountBalance = Money.zero(this.currency); - if (backdatedTxnsAllowedTill) { - if (this.summary.getLastInterestCalculationDate() == null) { - openingAccountBalance = Money.zero(this.currency); - } else { - openingAccountBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); - } - } - recalculateDailyBalances(openingAccountBalance, interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); - if (!backdatedTxnsAllowedTill) { - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } else { - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, - this.savingsAccountTransactions); - } - } - - public void postInterest(final MathContext mc, final LocalDate interestPostingUpToDate, final boolean isInterestTransfer, - final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, - final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { - final List postingPeriods = calculateInterestUsing(mc, interestPostingUpToDate, isInterestTransfer, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, - postReversals); - if (postingPeriods.isEmpty()) { - return; - } - - Money interestPostedToDate = Money.zero(this.currency); - - if (backdatedTxnsAllowedTill) { - interestPostedToDate = Money.of(this.currency, this.summary.getTotalInterestPosted()); - } - - boolean recalucateDailyBalanceDetails = false; - boolean applyWithHoldTax = isWithHoldTaxApplicableForInterestPosting(); - final List withholdTransactions = new ArrayList<>(); - - if (backdatedTxnsAllowedTill) { - withholdTransactions.addAll(findWithHoldSavingsTransactionsWithPivotConfig()); - } else { - withholdTransactions.addAll(findWithHoldTransactions()); - } - - // Reverse manual interest postings that no longer line up with any period this run computed, - // BEFORE the periods below are applied. - // - // A user posting ("post interest as on D") lands its transaction on D-1, because - // SavingsHelper#determineInterestPostingPeriods closes the period at D-1 when it meets D in - // postedAsOnDates. But getManualPostingDates() then feeds that transaction's own date -- D-1 - // -- back in as a boundary on every later run, which closes a period at D-2 instead. So the - // posting's date matches no period end the second time around: findInterestPostingTransactionFor - // below misses it, it is never reversed or corrected, and it survives alongside a new posting - // covering the very same days. The member is credited twice, and total_interest_posted_derived - // drifts above what was actually earned. - // - // A posting is only treated as orphaned, and only reversed, when BOTH hold: - // - its date is not any period's posting date, so the loop below will never find and correct it; - // - some period that WILL be posted in this run (posting date on or before - // interestPostingUpToDate) covers that date, so the days it credited are being credited again. - // The second condition is what keeps this from ever destroying interest. A run whose remaining - // period only matures in the future posts nothing for those days -- the recalculation that - // follows a withdrawal is exactly that case -- and reversing there would silently take earned - // interest back off the member instead of de-duplicating it. - // Automatic postings are left alone: they sit on natural period ends, which always recur. - final Set periodPostingDates = new HashSet<>(); - final List repostedIntervals = new ArrayList<>(); - for (final PostingPeriod period : postingPeriods) { - periodPostingDates.add(period.dateOfPostingTransaction()); - if (!DateUtils.isAfter(period.dateOfPostingTransaction(), interestPostingUpToDate)) { - repostedIntervals.add(period.getPeriodInterval()); - } - } - final List postingsInScope = backdatedTxnsAllowedTill - ? getSavingsAccountTransactionsWithPivotConfig() - : getTransactions(); - for (final SavingsAccountTransaction transaction : new ArrayList<>(postingsInScope)) { - final LocalDate transactionDate = transaction.getTransactionDate(); - if (!transaction.isInterestPosting() || !transaction.isNotReversed() || transaction.isReversalTransaction() - || !transaction.isManualTransaction() || periodPostingDates.contains(transactionDate)) { - continue; - } - boolean coveredByAPostedPeriod = false; - for (final LocalDateInterval interval : repostedIntervals) { - if (interval.contains(transactionDate)) { - coveredByAPostedPeriod = true; - break; - } - } - if (!coveredByAPostedPeriod) { - continue; - } - transaction.reverse(); - final SavingsAccountTransaction strandedWithholdTransaction = findTransactionFor(transactionDate, withholdTransactions); - if (strandedWithholdTransaction != null) { - strandedWithholdTransaction.reverse(); - } - recalucateDailyBalanceDetails = true; - } - - for (final PostingPeriod interestPostingPeriod : postingPeriods) { - final LocalDate interestPostingTransactionDate = interestPostingPeriod.dateOfPostingTransaction(); - final Money interestEarnedToBePostedForPeriod = interestPostingPeriod.getInterestEarned(); - - if (!DateUtils.isAfter(interestPostingTransactionDate, interestPostingUpToDate)) { - interestPostedToDate = interestPostedToDate.plus(interestEarnedToBePostedForPeriod); - - SavingsAccountTransaction postingTransaction = null; - if (backdatedTxnsAllowedTill) { - postingTransaction = findInterestPostingSavingsTransactionWithPivotConfig(interestPostingTransactionDate); - } else { - postingTransaction = findInterestPostingTransactionFor(interestPostingTransactionDate); - } - if (postingTransaction == null) { - SavingsAccountTransaction newPostingTransaction; - if (interestEarnedToBePostedForPeriod.isGreaterThanOrEqualTo(Money.zero(currency))) { - - newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), interestPostingTransactionDate, - interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting()); - } else { - newPostingTransaction = SavingsAccountTransaction.overdraftInterest(this, office(), interestPostingTransactionDate, - interestEarnedToBePostedForPeriod.negated(), interestPostingPeriod.isUserPosting()); - } - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(newPostingTransaction); - } else { - addTransaction(newPostingTransaction); - } - if (applyWithHoldTax) { - createWithHoldTransaction(interestEarnedToBePostedForPeriod.getAmount(), interestPostingTransactionDate, - backdatedTxnsAllowedTill); - } - recalucateDailyBalanceDetails = true; - } else { - boolean correctionRequired = false; - if (postingTransaction.isInterestPostingAndNotReversed()) { - correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod); - } else { - correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated()); - } - if (correctionRequired && isInterestPostingCorrectionAllowed(postingTransaction)) { - boolean applyWithHoldTaxForOldTransaction = false; - postingTransaction.reverse(); - SavingsAccountTransaction reversal = null; - if (postReversals) { - reversal = SavingsAccountTransaction.reversal(postingTransaction); - } - final SavingsAccountTransaction withholdTransaction = findTransactionFor(interestPostingTransactionDate, - withholdTransactions); - if (withholdTransaction != null) { - withholdTransaction.reverse(); - applyWithHoldTaxForOldTransaction = true; - } - SavingsAccountTransaction newPostingTransaction; - if (interestEarnedToBePostedForPeriod.isGreaterThanOrEqualTo(Money.zero(currency))) { - newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), - interestPostingTransactionDate, interestEarnedToBePostedForPeriod, - interestPostingPeriod.isUserPosting()); - } else { - newPostingTransaction = SavingsAccountTransaction.overdraftInterest(this, office(), - interestPostingTransactionDate, interestEarnedToBePostedForPeriod.negated(), - interestPostingPeriod.isUserPosting()); - } - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(newPostingTransaction); - if (reversal != null) { - addTransactionToExisting(reversal); - } - } else { - addTransaction(newPostingTransaction); - if (reversal != null) { - addTransaction(reversal); - } - } - if (applyWithHoldTaxForOldTransaction) { - createWithHoldTransaction(interestEarnedToBePostedForPeriod.getAmount(), interestPostingTransactionDate, - backdatedTxnsAllowedTill); - } - recalucateDailyBalanceDetails = true; - } - } - } - } - - if (recalucateDailyBalanceDetails) { - // no openingBalance concept supported yet but probably will to - // allow - // for migrations. - Money openingAccountBalance = Money.zero(this.currency); - - if (backdatedTxnsAllowedTill) { - if (this.summary.getLastInterestCalculationDate() == null) { - openingAccountBalance = Money.zero(this.currency); - } else { - openingAccountBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); - } - } - - // update existing transactions so derived balance fields are - // correct. - recalculateDailyBalances(openingAccountBalance, interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); - } - - if (!backdatedTxnsAllowedTill) { - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } else { - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, - this.savingsAccountTransactions); - } - } - - protected List findWithHoldTransactions() { - final List withholdTransactions = new ArrayList<>(); - List trans = getTransactions(); - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isWithHoldTaxAndNotReversed()) { - withholdTransactions.add(transaction); - } - } - return withholdTransactions; - } - - protected List findWithHoldSavingsTransactionsWithPivotConfig() { - final List withholdTransactions = new ArrayList<>(); - List trans = getSavingsAccountTransactionsWithPivotConfig(); - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isWithHoldTaxAndNotReversed() && !transaction.isReversalTransaction()) { - withholdTransactions.add(transaction); - } - } - return withholdTransactions; - } - - private boolean isWithHoldTaxApplicableForInterestPosting() { - return false; - } - - protected SavingsAccountTransaction findInterestPostingTransactionFor(final LocalDate postingDate) { - SavingsAccountTransaction postingTransation = null; - List trans = getTransactions(); - for (final SavingsAccountTransaction transaction : trans) { - if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.occursOn(postingDate) && !transaction.isReversalTransaction()) { - postingTransation = transaction; - break; - } - } - return postingTransation; - } - - protected SavingsAccountTransaction findInterestPostingSavingsTransactionWithPivotConfig(final LocalDate postingDate) { - SavingsAccountTransaction postingTransation = null; - List trans = getSavingsAccountTransactionsWithPivotConfig(); - for (final SavingsAccountTransaction transaction : trans) { - if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.occursOn(postingDate) && !transaction.isReversalTransaction()) { - postingTransation = transaction; - break; - } - } - return postingTransation; - } - - protected SavingsAccountTransaction findTransactionFor(final LocalDate postingDate, - final List transactions) { - SavingsAccountTransaction transaction = null; - for (final SavingsAccountTransaction savingsAccountTransaction : transactions) { - if (savingsAccountTransaction.occursOn(postingDate)) { - transaction = savingsAccountTransaction; - break; - } - } - return transaction; - } - - private Map calculateTaxSplit(final BigDecimal amount, final LocalDate date, final int scale) { - if (this.taxGroup == null || amount.compareTo(BigDecimal.ZERO) <= 0) { - return new HashMap<>(); - } - ClientKyc kyc = this.client != null ? this.client.getClientKyc() : null; - - // 1. Form 15G/15H Waiver Check - boolean waiveTds = false; - if (kyc != null && Boolean.TRUE.equals(kyc.getForm15Applicable())) { - String fy = kyc.getForm15Fy(); - if (isDateInFinancialYear(date, fy)) { - waiveTds = true; - } - } - - if (waiveTds) { - return new HashMap<>(); - } - - // 2. Determine if 20% higher rate applies (missing/unlinked PAN) - boolean applyHigherTdsRate = false; - if (kyc != null) { - boolean hasVerifiedPan = kyc.isPanVerified() || Boolean.TRUE.equals(kyc.getPanDeclarationTaken()); - String seedingStatus = kyc.getPanAadhaarSeedingStatus(); - - if (!hasVerifiedPan || "N".equalsIgnoreCase(seedingStatus)) { - applyHigherTdsRate = true; - } - } else { - // Default to higher rate (20%) if no KYC record is found - applyHigherTdsRate = true; - } - - // 3. Split tax - if (applyHigherTdsRate) { - return TaxUtils.splitTaxWithOverride(amount, date, this.taxGroup.getTaxGroupMappings(), scale, new BigDecimal("20.00")); - } else { - return TaxUtils.splitTax(amount, date, this.taxGroup.getTaxGroupMappings(), scale); - } - } - - private boolean isDateInFinancialYear(final LocalDate date, final String fy) { - if (fy == null || !fy.matches("\\d{4}-\\d{2}")) { - return false; - } - try { - int startYear = Integer.parseInt(fy.substring(0, 4)); - LocalDate start = LocalDate.of(startYear, 4, 1); - LocalDate end = LocalDate.of(startYear + 1, 3, 31); - return !date.isBefore(start) && !date.isAfter(end); - } catch (Exception e) { - return false; - } - } - - protected boolean createWithHoldTransaction(final BigDecimal amount, final LocalDate date, final boolean backdatedTxnsAllowedTill) { - boolean isTaxAdded = false; - if (this.taxGroup != null && amount.compareTo(BigDecimal.ZERO) > 0) { - Map taxSplit = calculateTaxSplit(amount, date, amount.scale()); - BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit); - if (totalTax.compareTo(BigDecimal.ZERO) > 0) { - SavingsAccountTransaction withholdTransaction = SavingsAccountTransaction.withHoldTax(this, office(), date, - Money.of(currency, totalTax), taxSplit); - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(withholdTransaction); - } else { - addTransaction(withholdTransaction); - } - isTaxAdded = true; - } - } - return isTaxAdded; - } - - protected boolean updateWithHoldTransaction(final BigDecimal amount, final SavingsAccountTransaction withholdTransaction) { - boolean isTaxAdded = false; - if (this.taxGroup != null && amount.compareTo(BigDecimal.ZERO) > 0) { - Map taxSplit = calculateTaxSplit(amount, withholdTransaction.getTransactionDate(), amount.scale()); - BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit); - if (totalTax.compareTo(BigDecimal.ZERO) > 0) { - if (withholdTransaction.getId() == null) { - withholdTransaction.setAmount(Money.of(currency, totalTax)); - withholdTransaction.getTaxDetails().clear(); - SavingsAccountTransaction.updateTaxDetails(taxSplit, withholdTransaction); - isTaxAdded = true; - } else if (totalTax.compareTo(withholdTransaction.getAmount()) != 0) { - withholdTransaction.reverse(); - SavingsAccountTransaction newWithholdTransaction = SavingsAccountTransaction.withHoldTax(this, office(), - withholdTransaction.getTransactionDate(), Money.of(currency, totalTax), taxSplit); - addTransaction(newWithholdTransaction); - isTaxAdded = true; - } - } - } - return isTaxAdded; - } - - // Determine the last transaction for given day - protected SavingsAccountTransaction findLastTransaction(final LocalDate date) { - - SavingsAccountTransaction savingsTransaction = null; - List trans = getTransactions(); - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isNotReversed() && !transaction.isReversalTransaction() && transaction.occursOn(date)) { - savingsTransaction = transaction; - break; - } - } - - return savingsTransaction; - } - - /** - * The last non-reversed transaction dated strictly BEFORE {@code date}, or null when none exists. - *

- * Distinct from {@link #findLastTransaction(LocalDate)}, which matches the date exactly. Used to seed the opening - * balance of an interest calculation that starts at a pivot date: a transaction dated ON that date falls inside the - * first posting period and is applied there, so seeding from it would count it twice, while requiring an exact match - * would leave the opening balance at zero on any pivot date that happens to carry no transaction. - *

- * Relies on {@code transactions} being ordered ascending by date, as its {@code @OrderBy} declares, so the final - * match is the latest one. - */ - protected SavingsAccountTransaction findLastTransactionBefore(final LocalDate date) { - SavingsAccountTransaction candidate = null; - for (final SavingsAccountTransaction transaction : getTransactions()) { - if (transaction.isNotReversed() && !transaction.isReversalTransaction() - && DateUtils.isBefore(transaction.getTransactionDate(), date)) { - candidate = transaction; - } - } - return candidate; - } - - protected SavingsAccountTransaction findLastFilteredTransactionWithPivotConfig(final LocalDate date) { - SavingsAccountTransaction savingsTransaction = null; - List trans = getSavingsAccountTransactionsWithPivotConfig(); - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isNotReversed() && !transaction.isReversalTransaction() && transaction.occursOn(date)) { - savingsTransaction = transaction; - break; - } - } - return savingsTransaction; - } - - public List getManualPostingDates() { - List transactions = new ArrayList<>(); - for (SavingsAccountTransaction trans : this.transactions) { - if (trans.isInterestPosting() && trans.isNotReversed() && !trans.isReversalTransaction() && trans.isManualTransaction()) { - transactions.add(trans.getTransactionDate()); - } - } - return transactions; - } - - public List getManualPostingDatesWithPivotConfig() { - List transactions = new ArrayList<>(); - for (SavingsAccountTransaction trans : this.savingsAccountTransactions) { - if (trans.isInterestPosting() && trans.isNotReversed() && trans.isManualTransaction()) { - transactions.add(trans.getTransactionDate()); - } - } - return transactions; - } - - /** - * All interest calculation based on END-OF-DAY-BALANCE. - * - * Interest calculation is performed on-the-fly over all account transactions. - * - * - * 1. Calculate Interest From Beginning Of Account 1a. determine the 'crediting' periods that exist for this savings - * acccount 1b. determine the 'compounding' periods that exist within each 'crediting' period calculate the amount - * of interest due at the end of each 'crediting' period check if an existing 'interest posting' transaction exists - * for date and matches the amount posted - * - * @param isInterestTransfer - * TODO - */ - - public List calculateInterestUsing(final MathContext mc, final LocalDate upToInterestCalculationDate, - boolean isInterestTransfer, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, - final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { - - // no openingBalance concept supported yet but probably will to allow for migrations. - // Check global configurations and 'pivot' date is null - Money openingAccountBalance = backdatedTxnsAllowedTill ? Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()) - : Money.zero(this.currency); - - // update existing transactions so derived balance fields are correct. - recalculateDailyBalances(openingAccountBalance, upToInterestCalculationDate, backdatedTxnsAllowedTill, postReversals); - - final List allPostingPeriods = new ArrayList<>(); - if (hasInterestCalculation() || hasOverdraftInterestCalculation()) { - // 1. default to calculate interest based on entire history OR - // 2. determine latest 'posting period' and find interest credited to that period - - // A generate list of EndOfDayBalances (not including interest postings) - final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType - .fromInt(this.interestPostingPeriodType); - - final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType - .fromInt(this.interestCompoundingPeriodType); - - final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType - .fromInt(this.interestCalculationDaysInYearType); - List postedAsOnDates = null; - if (backdatedTxnsAllowedTill) { - postedAsOnDates = getManualPostingDatesWithPivotConfig(); - } else { - postedAsOnDates = getManualPostingDates(); - } - if (postInterestOnDate != null) { - postedAsOnDates.add(postInterestOnDate); - } - final List postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods( - getStartInterestCalculationDate(), upToInterestCalculationDate, postingPeriodType, financialYearBeginningMonth, - postedAsOnDates); - - Money periodStartingBalance; - if (this.startInterestCalculationDate != null && !this.getStartInterestCalculationDate().equals(this.getActivationDate())) { - LocalDate startInterestCalculationDate = this.startInterestCalculationDate; - SavingsAccountTransaction transaction = null; - if (backdatedTxnsAllowedTill) { - transaction = findLastFilteredTransactionWithPivotConfig(startInterestCalculationDate); - } else { - // Strictly before the pivot, not on it: anything dated on the pivot date belongs to the - // first posting period and is applied there, so seeding from it would apply it twice -- - // and an exact-date match would leave this at zero whenever the pivot date carries no - // transaction of its own, silently accruing the whole term on a zero balance. - transaction = findLastTransactionBefore(startInterestCalculationDate); - } - - if (transaction == null) { - periodStartingBalance = Money.zero(this.currency); - } else if (backdatedTxnsAllowedTill) { - periodStartingBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); - } else { - // summary.runningBalanceOnPivotDate is @Transient and only ever populated by the - // assembler on the backdated-transactions path, so reading it here returned zero and - // silently wiped the opening balance. The balance carried by the last transaction - // before the start date is the same figure, and is always present. - periodStartingBalance = transaction.getRunningBalance(this.currency); - } - } else { - periodStartingBalance = Money.zero(this.currency); - } - - final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType - .fromInt(this.interestCalculationType); - final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, upToInterestCalculationDate); - final BigDecimal overdraftInterestRateAsFraction = getEffectiveOverdraftInterestRateAsFraction(mc); - final Collection interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(getId()); - final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); - final Money minOverdraftForInterestCalculation = Money.of(getCurrency(), this.minOverdraftForInterestCalculation); - - for (final LocalDateInterval periodInterval : postingPeriodIntervals) { - - boolean isUserPosting = false; - if (postedAsOnDates.contains(periodInterval.endDate().plusDays(1))) { - isUserPosting = true; - } - - PostingPeriod postingPeriod = null; - List orderedNonInterestPostingTransactions = null; - if (backdatedTxnsAllowedTill) { - orderedNonInterestPostingTransactions = retreiveOrderedNonInterestPostingSavingsTransactionsWithPivotConfig(); - } else { - orderedNonInterestPostingTransactions = retreiveOrderedNonInterestPostingTransactions(); - } - - List savingsAccountTransactionDetailsForPostingPeriod = toSavingsAccountTransactionDetailsForPostingPeriodList( - orderedNonInterestPostingTransactions); - - postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance, - savingsAccountTransactionDetailsForPostingPeriod, this.currency, compoundingPeriodType, interestCalculationType, - interestRateAsFraction, daysInYearType.getValue(), upToInterestCalculationDate, interestPostTransactions, - isInterestTransfer, minBalanceForInterestCalculation, isSavingsInterestPostingAtCurrentPeriodEnd, - overdraftInterestRateAsFraction, minOverdraftForInterestCalculation, isUserPosting, financialYearBeginningMonth); - - periodStartingBalance = postingPeriod.closingBalance(); - - allPostingPeriods.add(postingPeriod); - } - - this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, getLockedInUntilDate(), - isTransferInterestToOtherAccount()); - - this.summary.updateFromInterestPeriodSummaries(this.currency, allPostingPeriods); - } - - if (backdatedTxnsAllowedTill) { - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, - this.savingsAccountTransactions); - } else { - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } - - return allPostingPeriods; - } - - private BigDecimal getEffectiveOverdraftInterestRateAsFraction(MathContext mc) { - if (this.nominalAnnualInterestRateOverdraft == null) { - return BigDecimal.ZERO; - } - return this.nominalAnnualInterestRateOverdraft.divide(BigDecimal.valueOf(100L), mc); - } - - public BigDecimal getEffectiveInterestRateAsFractionAccrual(final MathContext mc, final LocalDate upToInterestCalculationDate) { - return this.nominalAnnualInterestRate.divide(BigDecimal.valueOf(100L), mc); - } - - @SuppressWarnings("unused") - protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate upToInterestCalculationDate) { - return this.nominalAnnualInterestRate.divide(BigDecimal.valueOf(100L), mc); - } - - private boolean hasInterestCalculation() { - return !MathUtil.isEmpty(nominalAnnualInterestRate); - } - - private boolean hasOverdraftInterestCalculation() { - return isAllowOverdraft() && !MathUtil.isEmpty(getOverdraftLimit()) && !MathUtil.isEmpty(nominalAnnualInterestRateOverdraft); - } - - public List retrieveOrderedAccrualTransactions() { - return retrieveListOfTransactions().stream().filter(SavingsAccountTransaction::isAccrual) - .sorted(new SavingsAccountTransactionComparator()).collect(Collectors.toList()); - } - - /** - * Transactions that should NOT count as part of this account's own balance history for interest - * calculation purposes -- excludes interest-posting credits (they're the thing being calculated, not an - * input to it) AND, for accounts that transfer their interest out to a linked account - * (isTransferInterestToOtherAccount(), e.g. FD Special), the paired WITHDRAWAL transaction that later - * sweeps that same interest back out (see TransferInterestToSavingsTasklet). - *

- * Without also excluding that withdrawal, only the credit half of the round-trip gets stripped out here - * -- the debit half stays, permanently understating this account's balance (and therefore every - * subsequent period's interest) by that amount from the first transfer onward, even though the real - * ledger balance never actually changed. fetchPostInterestTransactionIds(getId()) returns exactly the - * withdrawal-transaction ids that are the FROM side of a real INTEREST_TRANSFER account transfer on this - * account, so excluding those ids here closes that gap. - */ - protected Collection interestTransferWithdrawalTransactionIds() { - if (getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount()) { - return Collections.emptyList(); - } - return this.savingsHelper.fetchPostInterestTransactionIds(getId()); - } - - /** - * The given transactions minus the interest-transfer withdrawal legs, exactly as - * {@link #retreiveOrderedNonInterestPostingTransactions()} removes them before the interest engine ever - * sees them. - *

- * Those two sets MUST agree. {@link #resetAccountTransactionsEndOfDayBalances} derives each transaction's - * balance segment by walking backwards and ending the previous segment one day before the next - * transaction -- so a transaction present there but stripped out here truncates the preceding segment by a - * day, with nothing left in the engine's own list to account for that day. On an FD Special, whose - * transfer-out withdrawal always carries the same date as the interest posting it pays out, that silently - * cost the very first period one earning day. - */ - protected List excludeInterestTransferWithdrawals( - final List transactions) { - final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); - if (interestTransferWithdrawalIds.isEmpty()) { - return transactions; - } - return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId())) - .collect(Collectors.toList()); - } - - protected List retreiveOrderedNonInterestPostingTransactions() { - final List listOfTransactionsSorted = retrieveListOfTransactions(); - - final List orderedNonInterestPostingTransactions = new ArrayList<>(); - final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); - - for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { - if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isNotReversed() && !transaction.isReversalTransaction() - && !interestTransferWithdrawalIds.contains(transaction.getId())) { - orderedNonInterestPostingTransactions.add(transaction); - } - } - orderedNonInterestPostingTransactions.sort(new SavingsAccountTransactionComparator()); - return orderedNonInterestPostingTransactions; - } - - protected List retreiveOrderedNonInterestPostingSavingsTransactionsWithPivotConfig() { - final List listOfTransactionsSorted = retrieveSortedTransactions(); - - final List orderedNonInterestPostingTransactions = new ArrayList<>(); - final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); - - for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { - if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isNotReversed() && !transaction.isReversalTransaction() - && !interestTransferWithdrawalIds.contains(transaction.getId())) { - orderedNonInterestPostingTransactions.add(transaction); - } - } - orderedNonInterestPostingTransactions.sort(new SavingsAccountTransactionComparator()); - return orderedNonInterestPostingTransactions; - } - - protected List retrieveSortedTransactions() { - final List listOfTransactionsSorted = new ArrayList<>(); - listOfTransactionsSorted.addAll(this.savingsAccountTransactions); - final SavingsAccountTransactionComparator transactionComparator = new SavingsAccountTransactionComparator(); - Collections.sort(listOfTransactionsSorted, transactionComparator); - return listOfTransactionsSorted; - } - - protected List retrieveListOfTransactions() { - final List listOfTransactionsSorted = new ArrayList<>(); - listOfTransactionsSorted.addAll(this.transactions); - - final SavingsAccountTransactionComparator transactionComparator = new SavingsAccountTransactionComparator(); - Collections.sort(listOfTransactionsSorted, transactionComparator); - return listOfTransactionsSorted; - } - - protected void recalculateDailyBalances(final Money openingAccountBalance, final LocalDate interestPostingUpToDate, - final boolean backdatedTxnsAllowedTill, boolean postReversals) { - Money runningBalance = openingAccountBalance; - boolean calculateInterest = hasInterestCalculation() || hasOverdraftInterestCalculation(); - - List accountTransactionsSorted = null; - - if (backdatedTxnsAllowedTill) { - accountTransactionsSorted = retrieveSortedTransactions(); - } else { - accountTransactionsSorted = retrieveListOfTransactions(); - } - - boolean isTransactionsModified = false; - for (final SavingsAccountTransaction transaction : accountTransactionsSorted) { - if (transaction.isReversed() || transaction.isReversalTransaction()) { - transaction.zeroBalanceFields(); - } else { - Money overdraftAmount = Money.zero(this.currency); - Money transactionAmount = Money.zero(this.currency); - if ((transaction.isCredit() || transaction.isAmountRelease())) { - if (runningBalance.isLessThanZero()) { - Money diffAmount = transaction.getAmount(this.currency).plus(runningBalance); - if (diffAmount.isGreaterThanZero()) { - overdraftAmount = transaction.getAmount(this.currency).minus(diffAmount); - } else { - overdraftAmount = transaction.getAmount(this.currency); - } - } - transactionAmount = transactionAmount.plus(transaction.getAmount(this.currency)); - } else if (transaction.isDebit() || transaction.isAmountOnHold()) { - if (runningBalance.isLessThanZero()) { - overdraftAmount = transaction.getAmount(this.currency); - } - transactionAmount = transactionAmount.minus(transaction.getAmount(this.currency)); - } - - runningBalance = runningBalance.plus(transactionAmount); - transaction.setRunningBalance(runningBalance); - - if (MathUtil.isEmpty(overdraftAmount) && runningBalance.isLessThanZero() && !transaction.isAmountOnHold()) { - overdraftAmount = runningBalance.negated(); - } - if (!calculateInterest || transaction.getId() == null || transaction.getOverdraftAmount(this.currency).isZero()) { - transaction.setOverdraftAmount(overdraftAmount); - } else if (!MathUtil.isEqualTo(overdraftAmount, transaction.getOverdraftAmount(this.currency)) - && !transaction.isAccrual()) { - SavingsAccountTransaction accountTransaction = SavingsAccountTransaction.copyTransaction(transaction); - if (transaction.isChargeTransaction()) { - Set chargesPaidBy = transaction.getSavingsAccountChargesPaid(); - final Set newChargePaidBy = new HashSet<>(); - chargesPaidBy.forEach(x -> newChargePaidBy - .add(SavingsAccountChargePaidBy.instance(accountTransaction, x.getSavingsAccountCharge(), x.getAmount()))); - accountTransaction.getSavingsAccountChargesPaid().addAll(newChargePaidBy); - } - SavingsAccountTransaction reversal = null; - transaction.reverse(); - if (postReversals) { - reversal = SavingsAccountTransaction.reversal(transaction); - } - if (MathUtil.isGreaterThanZero(overdraftAmount)) { - accountTransaction.setOverdraftAmount(overdraftAmount); - } - accountTransaction.setRunningBalance(runningBalance); - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(accountTransaction); - if (reversal != null) { - addTransactionToExisting(reversal); - } - } else { - addTransaction(accountTransaction); - if (reversal != null) { - addTransaction(reversal); - } - } - isTransactionsModified = true; - } - - } - } - - if (isTransactionsModified) { - if (backdatedTxnsAllowedTill) { - accountTransactionsSorted = retrieveSortedTransactions(); - } else { - accountTransactionsSorted = retrieveListOfTransactions(); - } - } - resetAccountTransactionsEndOfDayBalances(accountTransactionsSorted, interestPostingUpToDate); - } - - protected void resetAccountTransactionsEndOfDayBalances(final List accountTransactionsSorted, - final LocalDate interestPostingUpToDate) { - // loop over transactions in reverse - LocalDate endOfBalanceDate = interestPostingUpToDate; - for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) { - final SavingsAccountTransaction transaction = accountTransactionsSorted.get(i); - if (transaction.isNotReversed() && !transaction.isReversalTransaction() - && !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) { - transaction.updateCumulativeBalanceAndDates(this.currency, endOfBalanceDate); - // this transactions transaction date is end of balance date for - // previous transaction. - endOfBalanceDate = transaction.getTransactionDate().minusDays(1); - } - } - } - - public SavingsAccountTransaction deposit(final SavingsAccountTransactionDTO transactionDTO, final boolean backdatedTxnsAllowedTill, - final Long relaxingDaysConfigForPivotDate, final String refNo) { - return deposit(transactionDTO, SavingsAccountTransactionType.DEPOSIT, backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, - refNo); - } - - public SavingsAccountTransaction dividendPayout(final SavingsAccountTransactionDTO transactionDTO, - final boolean backdatedTxnsAllowedTill, final Long relaxingDaysConfigForPivotDate) { - String refNo = null; - return deposit(transactionDTO, SavingsAccountTransactionType.DIVIDEND_PAYOUT, backdatedTxnsAllowedTill, - relaxingDaysConfigForPivotDate, refNo); - } - - public SavingsAccountTransaction deposit(final SavingsAccountTransactionDTO transactionDTO, - final SavingsAccountTransactionType savingsAccountTransactionType, final boolean backdatedTxnsAllowedTill, - final Long relaxingDaysConfigForPivotDate, final String refNo) { - final String resourceTypeName = depositAccountType().resourceName(); - if (isNotActive()) { - final String defaultUserMessage = "Transaction is not allowed. Account is not active."; - final ApiParameterError error = ApiParameterError.parameterError( - "error.msg." + resourceTypeName + ".transaction.account.is.not.active", defaultUserMessage, "transactionDate", - transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isDateInTheFuture(transactionDTO.getTransactionDate())) { - final String defaultUserMessage = "Transaction date cannot be in the future."; - final ApiParameterError error = ApiParameterError.parameterError("error.msg." + resourceTypeName + ".transaction.in.the.future", - defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isBefore(transactionDTO.getTransactionDate(), getActivationDate())) { - final Object[] defaultUserArgs = Arrays.asList(transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), - getActivationDate().format(transactionDTO.getFormatter())).toArray(); - final String defaultUserMessage = "Transaction date cannot be before accounts activation date."; - final ApiParameterError error = ApiParameterError.parameterError( - "error.msg." + resourceTypeName + ".transaction.before.activation.date", defaultUserMessage, "transactionDate", - defaultUserArgs); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - validatePivotDateTransaction(transactionDTO.getTransactionDate(), backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, - resourceTypeName); - - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_DEPOSIT, transactionDTO.getTransactionDate()); - - final Money amount = Money.of(this.currency, transactionDTO.getTransactionAmount()); - - final SavingsAccountTransaction transaction = SavingsAccountTransaction.deposit(this, office(), transactionDTO.getPaymentDetail(), - transactionDTO.getTransactionDate(), amount, savingsAccountTransactionType, refNo); - - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(transaction); - } else { - this.accrualsForSavingsReverse(transactionDTO, backdatedTxnsAllowedTill); - addTransaction(transaction); - } - - if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue()) - || this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) { - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - } - - if (backdatedTxnsAllowedTill) { - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, - this.savingsAccountTransactions); - } - - return transaction; - } - - public void validatePivotDateTransaction(LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, - final Long relaxingDaysConfigForPivotDate, final String resourceTypeName) { - if (backdatedTxnsAllowedTill) { - if (this.getSummary().getInterestPostedTillDate() != null && DateUtils.isBefore(transactionDate, - getSummary().getInterestPostedTillDate().minusDays(relaxingDaysConfigForPivotDate))) { - final Object[] defaultUserArgs = Arrays.asList(transactionDate, getActivationDate()).toArray(); - final String defaultUserMessage = "Transaction date cannot be before transactions pivot date."; - final ApiParameterError error = ApiParameterError.parameterError( - "error.msg." + resourceTypeName + ".transaction.before.pivot.date", defaultUserMessage, "transactionDate", - defaultUserArgs); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - } - - public LocalDate getActivationDate() { - return this.activatedOnDate; - } - - public AppUser getActivatedBy() { - return this.activatedBy; - } - - public LocalDate getWithdrawnOnDate() { - return this.withdrawnOnDate; - } - - public AppUser getWithdrawnBy() { - return this.withdrawnBy; - } - - // startInterestCalculationDate is set during migration so that there is no - // interference with interest posting of previous system - public LocalDate getStartInterestCalculationDate() { - LocalDate startInterestCalculationLocalDate = null; - if (this.startInterestCalculationDate != null) { - startInterestCalculationLocalDate = this.startInterestCalculationDate; - } else { - startInterestCalculationLocalDate = getActivationDate(); - } - return startInterestCalculationLocalDate; - } - - public void setStartInterestCalculationDate(LocalDate startInterestCalculationDate) { - this.startInterestCalculationDate = startInterestCalculationDate; - } - - public SavingsAccountTransaction withdraw(final SavingsAccountTransactionDTO transactionDTO, final boolean applyWithdrawFee, - final boolean backdatedTxnsAllowedTill, final Long relaxingDaysConfigForPivotDate, String refNo) { - if (!isTransactionsAllowed()) { - - final String defaultUserMessage = "Transaction is not allowed. Account is not active."; - final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.account.is.not.active", - defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isDateInTheFuture(transactionDTO.getTransactionDate())) { - final String defaultUserMessage = "Transaction date cannot be in the future."; - final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.in.the.future", - defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isBefore(transactionDTO.getTransactionDate(), getActivationDate())) { - final Object[] defaultUserArgs = Arrays.asList(transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), - getActivationDate().format(transactionDTO.getFormatter())).toArray(); - final String defaultUserMessage = "Transaction date cannot be before accounts activation date."; - final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.before.activation.date", - defaultUserMessage, "transactionDate", defaultUserArgs); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (isAccountLocked(transactionDTO.getTransactionDate())) { - final String defaultUserMessage = "Withdrawal is not allowed. No withdrawals are allowed until after " - + getLockedInUntilDate().format(transactionDTO.getFormatter()); - final ApiParameterError error = ApiParameterError.parameterError( - "error.msg.savingsaccount.transaction.withdrawals.blocked.during.lockin.period", defaultUserMessage, "transactionDate", - transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), - getLockedInUntilDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - validatePivotDateTransaction(transactionDTO.getTransactionDate(), backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, - "savingsaccount"); - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_WITHDRAWAL, transactionDTO.getTransactionDate()); - - if (applyWithdrawFee) { - // auto pay withdrawal fee - payWithdrawalFee(transactionDTO.getTransactionAmount(), transactionDTO.getTransactionDate(), transactionDTO.getPaymentDetail(), - backdatedTxnsAllowedTill, refNo); - } - - final Money transactionAmountMoney = Money.of(this.currency, transactionDTO.getTransactionAmount()); - final SavingsAccountTransaction transaction = SavingsAccountTransaction.withdrawal(this, office(), - transactionDTO.getPaymentDetail(), transactionDTO.getTransactionDate(), transactionAmountMoney, refNo); - - if (backdatedTxnsAllowedTill) { - addTransactionToExisting(transaction); - } else { - this.accrualsForSavingsReverse(transactionDTO, backdatedTxnsAllowedTill); - addTransaction(transaction); - } - - if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue()) - || this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) { - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - } - if (backdatedTxnsAllowedTill) { - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, - this.savingsAccountTransactions); - } - return transaction; - } - - public BigDecimal calculateWithdrawalFee(final BigDecimal transactionAmount) { - BigDecimal result = BigDecimal.ZERO; - if (isWithdrawalFeeApplicableForTransfer()) { - for (SavingsAccountCharge charge : this.charges()) { - if (charge.isWithdrawalFee() && charge.isActive()) { - result = result.add(charge.calculateWithdralFeeAmount(transactionAmount), MoneyHelper.getMathContext()); - } - } - } - return result; - } - - private void payWithdrawalFee(final BigDecimal transactionAmount, final LocalDate transactionDate, final PaymentDetail paymentDetail, - final boolean backdatedTxnsAllowedTill, final String refNo) { - for (SavingsAccountCharge charge : this.charges()) { - if (charge.isWithdrawalFee() && charge.isActive()) { - - if (charge.getFreeWithdrawalCount() == null) { - charge.setFreeWithdrawalCount(0); - } - - if (charge.isEnablePaymentType() && charge.isEnableFreeWithdrawal()) { // discount transaction to - // specific paymentType - if (paymentDetail != null && paymentDetail.getPaymentType() != null - && paymentDetail.getPaymentType().getName().equals(charge.getCharge().getPaymentType().getName())) { - resetFreeChargeDaysCount(charge, transactionAmount, transactionDate, refNo); - } - } else if (charge.isEnablePaymentType()) { // normal charge-transaction to specific paymentType - if (paymentDetail != null && paymentDetail.getPaymentType() != null - && paymentDetail.getPaymentType().getName().equals(charge.getCharge().getPaymentType().getName())) { - charge.updateWithdralFeeAmount(transactionAmount); - this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, - refNo); - } - } else if (!charge.isEnablePaymentType() && charge.isEnableFreeWithdrawal()) { // discount transaction - // irrespective of - // PaymentTypes. - resetFreeChargeDaysCount(charge, transactionAmount, transactionDate, refNo); - - } else { // normal-withdraw - charge.updateWithdralFeeAmount(transactionAmount); - this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, - refNo); - } - } - } - } - - private void resetFreeChargeDaysCount(SavingsAccountCharge charge, final BigDecimal transactionAmount, final LocalDate transactionDate, - final String refNo) { - LocalDate resetDate = charge.getResetChargeDate(); - - Integer restartPeriod = charge.getRestartFrequency(); - if (charge.getRestartFrequencyEnum() == 2) { // calculation for months - LocalDate localDate = DateUtils.getBusinessLocalDate(); - - LocalDate resetLocalDate; - if (resetDate == null) { - resetLocalDate = this.activatedOnDate; - } else { - resetLocalDate = resetDate; - } - - LocalDate gapIntervalMonth = resetLocalDate.plusMonths(restartPeriod); - - YearMonth gapYearMonth = YearMonth.from(gapIntervalMonth); - YearMonth localYearMonth = YearMonth.from(localDate); - if (localYearMonth.isBefore(gapYearMonth)) { - countValidation(charge, transactionAmount, transactionDate, refNo); - } else { - discountCharge(1, charge); - } - } else { // calculation for days - long completedDays; - - if (resetDate == null) { - completedDays = DAYS.between(DateUtils.getBusinessLocalDate(), this.activatedOnDate); - - } else { - completedDays = DAYS.between(DateUtils.getBusinessLocalDate(), resetDate); - } - - int totalDays = (int) completedDays; - - if (totalDays < restartPeriod) { - countValidation(charge, transactionAmount, transactionDate, refNo); - } else { - discountCharge(1, charge); - } - } - } - - private void countValidation(SavingsAccountCharge charge, final BigDecimal transactionAmount, final LocalDate transactionDate, - final String refNo) { - boolean backdatedTxnsAllowedTill = false; - if (charge.getFreeWithdrawalCount() < charge.getFrequencyFreeWithdrawalCharge()) { - final Integer count = charge.getFreeWithdrawalCount() + 1; - charge.setFreeWithdrawalCount(count); - charge.updateNoWithdrawalFee(); - } else { - charge.updateWithdralFeeAmount(transactionAmount); - this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, refNo); - } - } - - private void discountCharge(Integer freeWithdrawalCount, SavingsAccountCharge charge) { - charge.setFreeWithdrawalCount(freeWithdrawalCount); - charge.setDiscountDueDate(DateUtils.getBusinessLocalDate()); - charge.updateNoWithdrawalFee(); - } - - public boolean isBeforeLastPostingPeriod(final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill) { - - boolean transactionBeforeLastInterestPosting = false; - - if (!backdatedTxnsAllowedTill) { - for (final SavingsAccountTransaction transaction : retrieveListOfTransactions()) { - if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isAfter(transactionDate) && !transaction.isReversalTransaction()) { - transactionBeforeLastInterestPosting = true; - break; - } - } - } else { - if (this.summary.getInterestPostedTillDate() == null) { - return false; - } - transactionBeforeLastInterestPosting = DateUtils.isBefore(transactionDate, this.summary.getInterestPostedTillDate()); - } - - return transactionBeforeLastInterestPosting; - } - - public void validateAccountBalanceDoesNotBecomeNegative(final BigDecimal transactionAmount, final boolean isException, - final List depositAccountOnHoldTransactions, final boolean backdatedTxnsAllowedTill) { - - List transactionsSortedByDate = null; - - if (backdatedTxnsAllowedTill) { - transactionsSortedByDate = retrieveSortedTransactions(); - } else { - transactionsSortedByDate = retrieveListOfTransactions(); - } - - Money runningBalance = Money.zero(this.currency); - if (backdatedTxnsAllowedTill) { - runningBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); - } - - Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); - LocalDate lastSavingsDate = null; - final BigDecimal withdrawalFee = null; - for (final SavingsAccountTransaction transaction : transactionsSortedByDate) { - if (transaction.isNotReversed() && transaction.isCredit() && !transaction.isReversalTransaction()) { - runningBalance = runningBalance.plus(transaction.getAmount(this.currency)); - } else if (transaction.isNotReversed() && transaction.isDebit() && !transaction.isReversalTransaction()) { - runningBalance = runningBalance.minus(transaction.getAmount(this.currency)); - } else { - continue; - } - - /* - * Loop through the onHold funds and see if we need to deduct or add to minimum required balance and the - * point in time the transaction was made: - */ - if (depositAccountOnHoldTransactions != null) { - for (final DepositAccountOnHoldTransaction onHoldTransaction : depositAccountOnHoldTransactions) { - // Compare the balance of the on hold: - if (!DateUtils.isAfter(onHoldTransaction.getTransactionDate(), transaction.getTransactionDate()) - && (lastSavingsDate == null || DateUtils.isAfter(onHoldTransaction.getTransactionDate(), lastSavingsDate))) { - if (onHoldTransaction.getTransactionType().isHold()) { - minRequiredBalance = minRequiredBalance.plus(onHoldTransaction.getAmount(this.currency)); - } else { - minRequiredBalance = minRequiredBalance.minus(onHoldTransaction.getAmount(this.currency)); - } - } - } - } - - // deal with potential minRequiredBalance and - // enforceMinRequiredBalance - if (!isException && transaction.canProcessBalanceCheck() && !isOverdraft()) { - if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, - transactionAmount); - } - } - lastSavingsDate = transaction.getTransactionDate(); - - } - - // In overdraft cases, minRequiredBalance can be in violation after - // interest posting - // and should be checked after processing all transactions - if (isOverdraft()) { - if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); - } - } - - if (this.getSavingsHoldAmount().compareTo(BigDecimal.ZERO) > 0) { - if (this.enforceMinRequiredBalance) { - if (runningBalance.minus(minRequiredBalance.plus(this.getSavingsHoldAmount())).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, - transactionAmount); - } - } else { - if (runningBalance.minus(this.getSavingsHoldAmount()).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, - transactionAmount); - } - } - } - } - - public void validateAccountBalanceDoesNotBecomeNegative(final String transactionAction, - final List depositAccountOnHoldTransactions, final boolean backdatedTxnsAllowedTill) { - - List transactionsSortedByDate = null; - BigDecimal transactionAmount = null; - if (backdatedTxnsAllowedTill) { - transactionsSortedByDate = retrieveSortedTransactions(); - } else { - transactionsSortedByDate = retrieveListOfTransactions(); - } - Money runningBalance = Money.zero(this.currency); - - if (backdatedTxnsAllowedTill) { - runningBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); - } - - Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); - LocalDate lastSavingsDate = null; - for (final SavingsAccountTransaction transaction : transactionsSortedByDate) { - - transactionAmount = transaction.getAmount(); - - if (transaction.isNotReversed() && transaction.isCredit()) { - runningBalance = runningBalance.plus(transaction.getAmount(this.currency)); - } else if (transaction.isNotReversed() && transaction.isDebit()) { - runningBalance = runningBalance.minus(transaction.getAmount(this.currency)); - } - - /* - * Loop through the onHold funds and see if we need to deduct or add to minimum required balance and the - * point in time the transaction was made: - */ - if (depositAccountOnHoldTransactions != null) { - for (final DepositAccountOnHoldTransaction onHoldTransaction : depositAccountOnHoldTransactions) { - // Compare the balance of the on hold: - if (!DateUtils.isAfter(onHoldTransaction.getTransactionDate(), transaction.getTransactionDate()) - && (lastSavingsDate == null || DateUtils.isAfter(onHoldTransaction.getTransactionDate(), lastSavingsDate))) { - if (onHoldTransaction.getTransactionType().isHold()) { - minRequiredBalance = minRequiredBalance.plus(onHoldTransaction.getAmount(this.currency)); - } else { - minRequiredBalance = minRequiredBalance.minus(onHoldTransaction.getAmount(this.currency)); - } - } - } - } - - // enforceMinRequiredBalance - if (transaction.canProcessBalanceCheck()) { - if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + transactionAction); - if (!this.allowOverdraft) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("results.in.balance.going.negative"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - } - lastSavingsDate = transaction.getTransactionDate(); - } - - BigDecimal withdrawalFee = null; - if (isOverdraft()) { - if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); - } - } - } - - public void validateAccountBalanceDoesNotViolateOverdraft(final List savingsAccountTransaction, - final BigDecimal amountPaid) { - if (savingsAccountTransaction != null && savingsAccountTransaction.size() > 0) { - SavingsAccountTransaction savingsAccountTransactionFirst = savingsAccountTransaction.get(0); - if (!this.allowOverdraft) { - if (savingsAccountTransactionFirst.getRunningBalance(this.currency).minus(amountPaid).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), null, amountPaid); - } - } - - } else { - if (!this.allowOverdraft) { - throw new InsufficientAccountBalanceException("transactionAmount", BigDecimal.ZERO, null, amountPaid); - } - } - } - - protected boolean isAccountLocked(final LocalDate transactionDate) { - return DateUtils.isBefore(transactionDate, getLockedInUntilDate()); - } - - public LocalDate getLockedInUntilDate() { - return this.lockedInUntilDate; - } - - public BigDecimal getAccountBalance() { - return this.summary.getAccountBalance(this.currency).getAmount(); - } - - public void modifyApplication(final JsonCommand command, final Map actualChanges) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.modifyApplicationAction); - this.modifyApplication(command, actualChanges, baseDataValidator); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - public void modifyApplication(final JsonCommand command, final Map actualChanges, - final DataValidatorBuilder baseDataValidator) { - - final SavingsAccountStatusType currentStatus = SavingsAccountStatusType.fromInt(this.status); - final boolean isAllowedState = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus) - || SavingsAccountStatusType.REJECTED.hasStateOf(currentStatus); - if (!isAllowedState) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); - return; - } - // If modifying a rejected application, reset it back to pending approval - if (SavingsAccountStatusType.REJECTED.hasStateOf(currentStatus)) { - this.status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue(); - this.rejectedOnDate = null; - this.rejectedBy = null; - actualChanges.put("status", SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue()); - } - - final String localeAsInput = command.locale(); - final String dateFormat = command.dateFormat(); - - if (command.isChangeInLocalDateParameterNamed(SavingsApiConstants.submittedOnDateParamName, getSubmittedOnDate())) { - final String newValueAsString = command.stringValueOfParameterNamed(SavingsApiConstants.submittedOnDateParamName); - actualChanges.put(SavingsApiConstants.submittedOnDateParamName, newValueAsString); - actualChanges.put(SavingsApiConstants.localeParamName, localeAsInput); - actualChanges.put(SavingsApiConstants.dateFormatParamName, dateFormat); - this.submittedOnDate = command.localDateValueOfParameterNamed(SavingsApiConstants.submittedOnDateParamName); - } - if (command.isChangeInStringParameterNamed(SavingsApiConstants.accountNoParamName, this.accountNumber)) { - final String newValue = command.stringValueOfParameterNamed(SavingsApiConstants.accountNoParamName); - actualChanges.put(SavingsApiConstants.accountNoParamName, newValue); - this.accountNumber = StringUtils.defaultIfEmpty(newValue, null); - } - if (command.isChangeInStringParameterNamed(SavingsApiConstants.externalIdParamName, this.externalId.getValue())) { - final String newValue = command.stringValueOfParameterNamed(SavingsApiConstants.externalIdParamName); - actualChanges.put(SavingsApiConstants.externalIdParamName, newValue); - this.externalId = ExternalIdFactory.produce(newValue); - if (this.externalId.isEmpty() && TemporaryConfigurationServiceContainer.isExternalIdAutoGenerationEnabled()) { - this.externalId = ExternalId.generate(); - } - } - if (command.isChangeInLongParameterNamed(SavingsApiConstants.clientIdParamName, clientId())) { - final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.clientIdParamName); - actualChanges.put(SavingsApiConstants.clientIdParamName, newValue); - } - if (command.isChangeInLongParameterNamed(SavingsApiConstants.groupIdParamName, groupId())) { - final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.groupIdParamName); - actualChanges.put(SavingsApiConstants.groupIdParamName, newValue); - } - if (command.isChangeInLongParameterNamed(SavingsApiConstants.productIdParamName, this.product.getId())) { - final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.productIdParamName); - actualChanges.put(SavingsApiConstants.productIdParamName, newValue); - } - if (command.isChangeInLongParameterNamed(SavingsApiConstants.fieldOfficerIdParamName, hasSavingsOfficerId())) { - final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.fieldOfficerIdParamName); - actualChanges.put(SavingsApiConstants.fieldOfficerIdParamName, newValue); - } - if (command.isChangeInBigDecimalParameterNamed(SavingsApiConstants.nominalAnnualInterestRateParamName, - this.nominalAnnualInterestRate)) { - final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(SavingsApiConstants.nominalAnnualInterestRateParamName); - actualChanges.put(SavingsApiConstants.nominalAnnualInterestRateParamName, newValue); - this.nominalAnnualInterestRate = newValue; - } - if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCompoundingPeriodTypeParamName, - this.interestCompoundingPeriodType)) { - final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCompoundingPeriodTypeParamName); - this.interestCompoundingPeriodType = newValue != null ? SavingsCompoundingInterestPeriodType.fromInt(newValue).getValue() - : newValue; - actualChanges.put(SavingsApiConstants.interestCompoundingPeriodTypeParamName, this.interestCompoundingPeriodType); - } - if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestPostingPeriodTypeParamName, - this.interestPostingPeriodType)) { - final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestPostingPeriodTypeParamName); - this.interestPostingPeriodType = newValue != null ? SavingsPostingInterestPeriodType.fromInt(newValue).getValue() : newValue; - actualChanges.put(SavingsApiConstants.interestPostingPeriodTypeParamName, this.interestPostingPeriodType); - } - if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCalculationTypeParamName, this.interestCalculationType)) { - final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCalculationTypeParamName); - this.interestCalculationType = newValue != null ? SavingsInterestCalculationType.fromInt(newValue).getValue() : newValue; - actualChanges.put(SavingsApiConstants.interestCalculationTypeParamName, this.interestCalculationType); - } - if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCalculationDaysInYearTypeParamName, - this.interestCalculationDaysInYearType)) { - final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCalculationDaysInYearTypeParamName); - this.interestCalculationDaysInYearType = newValue != null - ? SavingsInterestCalculationDaysInYearType.fromInt(newValue).getValue() - : newValue; - actualChanges.put(SavingsApiConstants.interestCalculationDaysInYearTypeParamName, this.interestCalculationDaysInYearType); - } - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(SavingsApiConstants.minRequiredOpeningBalanceParamName, - this.minRequiredOpeningBalance)) { - final BigDecimal newValue = command - .bigDecimalValueOfParameterNamedDefaultToNullIfZero(SavingsApiConstants.minRequiredOpeningBalanceParamName); - actualChanges.put(SavingsApiConstants.minRequiredOpeningBalanceParamName, newValue); - actualChanges.put("locale", localeAsInput); - this.minRequiredOpeningBalance = Money.of(this.currency, newValue).getAmount(); - } - if (command.isChangeInIntegerParameterNamedDefaultingZeroToNull(SavingsApiConstants.lockinPeriodFrequencyParamName, - this.lockinPeriodFrequency)) { - final Integer newValue = command - .integerValueOfParameterNamedDefaultToNullIfZero(SavingsApiConstants.lockinPeriodFrequencyParamName); - actualChanges.put(SavingsApiConstants.lockinPeriodFrequencyParamName, newValue); - actualChanges.put("locale", localeAsInput); - this.lockinPeriodFrequency = newValue; - } - - if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.lockinPeriodFrequencyTypeParamName, - this.lockinPeriodFrequencyType)) { - final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.lockinPeriodFrequencyTypeParamName); - actualChanges.put(SavingsApiConstants.lockinPeriodFrequencyTypeParamName, newValue); - this.lockinPeriodFrequencyType = newValue != null ? SavingsPeriodFrequencyType.fromInt(newValue).getValue() : newValue; - } - - // set period type to null if frequency is null - if (this.lockinPeriodFrequency == null) { - this.lockinPeriodFrequencyType = null; - } - - if (command.isChangeInBooleanParameterNamed(withdrawalFeeForTransfersParamName, this.withdrawalFeeApplicableForTransfer)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(withdrawalFeeForTransfersParamName); - actualChanges.put(withdrawalFeeForTransfersParamName, newValue); - this.withdrawalFeeApplicableForTransfer = newValue; - } - - // charges - final String chargesParamName = "charges"; - if (command.hasParameter(chargesParamName)) { - final JsonArray jsonArray = command.arrayOfParameterNamed(chargesParamName); - if (jsonArray != null) { - actualChanges.put(chargesParamName, command.jsonFragment(chargesParamName)); - } - } - - if (command.isChangeInBooleanParameterNamed(allowOverdraftParamName, this.allowOverdraft)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(allowOverdraftParamName); - actualChanges.put(allowOverdraftParamName, newValue); - this.allowOverdraft = newValue; - } - - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(overdraftLimitParamName, this.overdraftLimit)) { - final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(overdraftLimitParamName); - actualChanges.put(overdraftLimitParamName, newValue); - actualChanges.put(localeParamName, localeAsInput); - this.overdraftLimit = newValue; - } - - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(nominalAnnualInterestRateOverdraftParamName, - this.nominalAnnualInterestRateOverdraft)) { - final BigDecimal newValue = command - .bigDecimalValueOfParameterNamedDefaultToNullIfZero(nominalAnnualInterestRateOverdraftParamName); - actualChanges.put(nominalAnnualInterestRateOverdraftParamName, newValue); - actualChanges.put(localeParamName, localeAsInput); - this.nominalAnnualInterestRateOverdraft = newValue; - } - - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(minOverdraftForInterestCalculationParamName, - this.minOverdraftForInterestCalculation)) { - final BigDecimal newValue = command - .bigDecimalValueOfParameterNamedDefaultToNullIfZero(minOverdraftForInterestCalculationParamName); - actualChanges.put(minOverdraftForInterestCalculationParamName, newValue); - actualChanges.put(localeParamName, localeAsInput); - this.minOverdraftForInterestCalculation = newValue; - } - - if (!this.allowOverdraft) { - this.overdraftLimit = null; - this.nominalAnnualInterestRateOverdraft = null; - this.minOverdraftForInterestCalculation = null; - } - - if (command.isChangeInBooleanParameterNamed(enforceMinRequiredBalanceParamName, this.enforceMinRequiredBalance)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(enforceMinRequiredBalanceParamName); - actualChanges.put(enforceMinRequiredBalanceParamName, newValue); - this.enforceMinRequiredBalance = newValue; - } - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(minRequiredBalanceParamName, this.minRequiredBalance)) { - final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(minRequiredBalanceParamName); - actualChanges.put(minRequiredBalanceParamName, newValue); - actualChanges.put(localeParamName, localeAsInput); - this.minRequiredBalance = newValue; - } - if (command.isChangeInBooleanParameterNamed(lienAllowedParamName, this.lienAllowed)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(lienAllowedParamName); - actualChanges.put(lienAllowedParamName, newValue); - this.lienAllowed = newValue; - } - if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(maxAllowedLienLimitParamName, this.maxAllowedLienLimit)) { - final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(maxAllowedLienLimitParamName); - actualChanges.put(maxAllowedLienLimitParamName, newValue); - actualChanges.put(localeParamName, localeAsInput); - this.maxAllowedLienLimit = newValue; - } - if (command.isChangeInBooleanParameterNamed(withHoldTaxParamName, this.withHoldTax)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName); - actualChanges.put(withHoldTaxParamName, newValue); - this.withHoldTax = newValue; - if (this.withHoldTax && this.taxGroup == null) { - baseDataValidator.reset().parameter(withHoldTaxParamName).failWithCode("not.supported.for.this.account"); - } - } - - if (command.isChangeInBooleanParameterNamed(introducerRequiredParamName, this.introducerRequired)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(introducerRequiredParamName); - actualChanges.put(introducerRequiredParamName, newValue); - this.introducerRequired = newValue; - } - - if (command.isChangeInBooleanParameterNamed(shareLinkingEnabledParamName, this.shareLinkingEnabled)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(shareLinkingEnabledParamName); - actualChanges.put(shareLinkingEnabledParamName, newValue); - this.shareLinkingEnabled = newValue; - } - - // CBS extended fields mapping during modify application - final String accountTypeParam = "accountType"; - if (command.isChangeInStringParameterNamed(accountTypeParam, this.cbsAccountType)) { - final String newValue = command.stringValueOfParameterNamed(accountTypeParam); - actualChanges.put(accountTypeParam, newValue); - this.cbsAccountType = newValue; - } - - final String customerSegmentParam = "customerSegment"; - if (command.isChangeInStringParameterNamed(customerSegmentParam, this.customerSegment)) { - final String newValue = command.stringValueOfParameterNamed(customerSegmentParam); - actualChanges.put(customerSegmentParam, newValue); - this.customerSegment = newValue; - } - - final String accountStatusParam = "accountStatus"; - if (command.isChangeInStringParameterNamed(accountStatusParam, this.accountStatus)) { - final String newValue = command.stringValueOfParameterNamed(accountStatusParam); - actualChanges.put(accountStatusParam, newValue); - this.accountStatus = newValue; - } - - final String modeOfOperationParam = "modeOfOperation"; - if (command.isChangeInStringParameterNamed(modeOfOperationParam, this.modeOfOperation)) { - final String newValue = command.stringValueOfParameterNamed(modeOfOperationParam); - actualChanges.put(modeOfOperationParam, newValue); - this.modeOfOperation = newValue; - } - - final String specialCategoryParam = "specialCategory"; - if (command.isChangeInStringParameterNamed(specialCategoryParam, this.specialCategory)) { - final String newValue = command.stringValueOfParameterNamed(specialCategoryParam); - actualChanges.put(specialCategoryParam, newValue); - this.specialCategory = newValue; - } - - final String weakerSectionParam = "weakerSection"; - if (command.isChangeInStringParameterNamed(weakerSectionParam, this.weakerSection)) { - final String newValue = command.stringValueOfParameterNamed(weakerSectionParam); - actualChanges.put(weakerSectionParam, newValue); - this.weakerSection = newValue; - } - - final String nominationFacilityParam = "nominationFacility"; - if (command.isChangeInBooleanParameterNamed(nominationFacilityParam, this.nominationFacility)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(nominationFacilityParam); - actualChanges.put(nominationFacilityParam, newValue); - this.nominationFacility = newValue; - } - - final String autoDebitFacilityParam = "autoDebitFacility"; - if (command.isChangeInBooleanParameterNamed(autoDebitFacilityParam, this.autoDebitFacility)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(autoDebitFacilityParam); - actualChanges.put(autoDebitFacilityParam, newValue); - this.autoDebitFacility = newValue; - } - - final String isSeniorCitizenParam = "isSeniorCitizen"; - if (command.isChangeInBooleanParameterNamed(isSeniorCitizenParam, this.isSeniorCitizen)) { - final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(isSeniorCitizenParam); - actualChanges.put(isSeniorCitizenParam, newValue); - this.isSeniorCitizen = newValue; - } - - final String seniorCitizenInterestRateParam = "seniorCitizenInterestRate"; - if (command.isChangeInBigDecimalParameterNamed(seniorCitizenInterestRateParam, this.seniorCitizenInterestRate)) { - final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(seniorCitizenInterestRateParam); - actualChanges.put(seniorCitizenInterestRateParam, newValue); - this.seniorCitizenInterestRate = newValue; - if (newValue != null && newValue.compareTo(BigDecimal.ZERO) > 0) { - updateNominalAnnualInterestRate(newValue); - } - } - - final String guardianNameParam = "guardianName"; - if (command.isChangeInStringParameterNamed(guardianNameParam, this.guardianName)) { - final String newValue = command.stringValueOfParameterNamed(guardianNameParam); - actualChanges.put(guardianNameParam, newValue); - this.guardianName = newValue; - } - - final String guardianCifNumberParam = "guardianCifNumber"; - if (command.isChangeInStringParameterNamed(guardianCifNumberParam, this.guardianCifNumber)) { - final String newValue = command.stringValueOfParameterNamed(guardianCifNumberParam); - actualChanges.put(guardianCifNumberParam, newValue); - this.guardianCifNumber = newValue; - } - - final String guardianRelationshipParam = "guardianRelationship"; - if (command.isChangeInStringParameterNamed(guardianRelationshipParam, this.guardianRelationship)) { - final String newValue = command.stringValueOfParameterNamed(guardianRelationshipParam); - actualChanges.put(guardianRelationshipParam, newValue); - this.guardianRelationship = newValue; - } - - final String minorDateOfBirthParam = "minorDateOfBirth"; - if (command.isChangeInLocalDateParameterNamed(minorDateOfBirthParam, this.minorDateOfBirth)) { - final LocalDate newValue = command.localDateValueOfParameterNamed(minorDateOfBirthParam); - actualChanges.put(minorDateOfBirthParam, newValue); - this.minorDateOfBirth = newValue; - } - - final String guardianClientIdParam = "guardianClientId"; - if (command.isChangeInLongParameterNamed(guardianClientIdParam, this.guardianClientId)) { - final Long newValue = command.longValueOfParameterNamed(guardianClientIdParam); - actualChanges.put(guardianClientIdParam, newValue); - this.guardianClientId = newValue; - } - - final String introducerIdParam = "introducerId"; - if (command.isChangeInLongParameterNamed(introducerIdParam, this.introducerClientId)) { - final Long newValue = command.longValueOfParameterNamed(introducerIdParam); - actualChanges.put(introducerIdParam, newValue); - this.introducerClientId = newValue; - } - - final String introducerNameParam = "introducerName"; - if (command.isChangeInStringParameterNamed(introducerNameParam, this.introducerName)) { - final String newValue = command.stringValueOfParameterNamed(introducerNameParam); - actualChanges.put(introducerNameParam, newValue); - this.introducerName = newValue; - } - - final String introducerCifNumberParam = "introducerCifNumber"; - if (command.isChangeInStringParameterNamed(introducerCifNumberParam, this.introducerCifNumber)) { - final String newValue = command.stringValueOfParameterNamed(introducerCifNumberParam); - actualChanges.put(introducerCifNumberParam, newValue); - this.introducerCifNumber = newValue; - } - - final String linkedAccountIdParam = "linkedAccountId"; - if (command.isChangeInLongParameterNamed(linkedAccountIdParam, this.linkedAccountId)) { - final Long newValue = command.longValueOfParameterNamed(linkedAccountIdParam); - actualChanges.put(linkedAccountIdParam, newValue); - this.linkedAccountId = newValue; - } - - final String linkedAccountTypeParam = "linkedAccountType"; - if (command.isChangeInStringParameterNamed(linkedAccountTypeParam, this.linkedAccountType)) { - final String newValue = command.stringValueOfParameterNamed(linkedAccountTypeParam); - actualChanges.put(linkedAccountTypeParam, newValue); - this.linkedAccountType = newValue; - } - - final String linkedAccountNoParam = "linkedAccountNo"; - if (command.isChangeInStringParameterNamed(linkedAccountNoParam, this.linkedAccountNo)) { - final String newValue = command.stringValueOfParameterNamed(linkedAccountNoParam); - actualChanges.put(linkedAccountNoParam, newValue); - this.linkedAccountNo = newValue; - } - - final String accountFacilityConfigParam = "accountFacilityConfig"; - if (command.hasParameter(accountFacilityConfigParam)) { - final com.google.gson.JsonElement parsedJson = command.parsedJson(); - if (parsedJson != null && parsedJson.isJsonObject()) { - final com.google.gson.JsonObject jsonObj = parsedJson.getAsJsonObject(); - if (jsonObj.has(accountFacilityConfigParam) && !jsonObj.get(accountFacilityConfigParam).isJsonNull() - && jsonObj.get(accountFacilityConfigParam).isJsonObject()) { - final com.google.gson.JsonObject configObj = jsonObj.get(accountFacilityConfigParam).getAsJsonObject(); - if (this.facilityConfig == null) { - this.facilityConfig = new SavingsAccountFacilityConfig(this); - } - this.facilityConfig.updateFrom(configObj); - actualChanges.put(accountFacilityConfigParam, configObj.toString()); - } - } - } - - final String jointHoldersParam = "jointHolders"; - if (command.hasParameter(jointHoldersParam)) { - final com.google.gson.JsonElement parsedJson = command.parsedJson(); - if (parsedJson != null && parsedJson.isJsonObject()) { - final com.google.gson.JsonObject jsonObj = parsedJson.getAsJsonObject(); - if (jsonObj.has(jointHoldersParam) && !jsonObj.get(jointHoldersParam).isJsonNull() - && jsonObj.get(jointHoldersParam).isJsonArray()) { - final com.google.gson.JsonArray holdersArray = jsonObj.get(jointHoldersParam).getAsJsonArray(); - final List holders = new ArrayList<>(); - for (int i = 0; i < holdersArray.size(); i++) { - final com.google.gson.JsonObject h = holdersArray.get(i).getAsJsonObject(); - final Long clientId = h.has("clientId") && !h.get("clientId").isJsonNull() - ? h.get("clientId").getAsLong() : null; - final String cifNumber = h.has("cifNumber") && !h.get("cifNumber").isJsonNull() - ? h.get("cifNumber").getAsString() : null; - final String displayName = h.has("displayName") && !h.get("displayName").isJsonNull() - ? h.get("displayName").getAsString() : null; - final BigDecimal sharePercentage = h.has("sharePercentage") && !h.get("sharePercentage").isJsonNull() - ? h.get("sharePercentage").getAsBigDecimal() : null; - final boolean isPrimary = h.has("isPrimary") && h.get("isPrimary").getAsBoolean(); - String signingAuthority = h.has("signingAuthority") && !h.get("signingAuthority").isJsonNull() - ? h.get("signingAuthority").getAsString() : null; - String role = h.has("role") && !h.get("role").isJsonNull() - ? h.get("role").getAsString() : null; - - if (signingAuthority == null || signingAuthority.trim().isEmpty()) { - signingAuthority = isPrimary ? "Mandatory" : "Optional"; - } - if (role == null || role.trim().isEmpty()) { - role = isPrimary ? "Primary Account Holder" : "Co-Holder"; - } - - final boolean isMandatory = h.has("isMandatory") && h.get("isMandatory").getAsBoolean(); - holders.add(new SavingsAccountJointHolder(this, clientId, cifNumber, displayName, - sharePercentage, signingAuthority, role, isPrimary, isMandatory)); - } - this.jointHolders.clear(); - this.jointHolders.addAll(holders); - actualChanges.put(jointHoldersParam, holdersArray.toString()); - } - } - } - - validateLockinDetails(baseDataValidator); - esnureOverdraftLimitsSetForOverdraftAccounts(); - } - - /** - * If overdrafts are allowed and the overdraft limit is not set, set the same to Zero - **/ - private void esnureOverdraftLimitsSetForOverdraftAccounts() { - - this.overdraftLimit = this.overdraftLimit == null ? BigDecimal.ZERO : this.overdraftLimit; - this.nominalAnnualInterestRateOverdraft = this.nominalAnnualInterestRateOverdraft == null ? BigDecimal.ZERO - : this.nominalAnnualInterestRateOverdraft; - this.minOverdraftForInterestCalculation = this.minOverdraftForInterestCalculation == null ? BigDecimal.ZERO - : this.minOverdraftForInterestCalculation; - } - - private void validateLockinDetails(final DataValidatorBuilder baseDataValidator) { - - /* - * final List dataValidationErrors = new ArrayList(); final - * DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - * .resource(resourceName); - */ - - if (this.lockinPeriodFrequency == null) { - baseDataValidator.reset().parameter(lockinPeriodFrequencyTypeParamName).value(this.lockinPeriodFrequencyType).ignoreIfNull() - .inMinMaxRange(0, 3); - - if (this.lockinPeriodFrequencyType != null) { - baseDataValidator.reset().parameter(lockinPeriodFrequencyParamName).value(this.lockinPeriodFrequency).ignoreIfNull() - .integerZeroOrGreater(); - } - } else { - baseDataValidator.reset().parameter(lockinPeriodFrequencyParamName).value(this.lockinPeriodFrequencyType) - .integerZeroOrGreater(); - baseDataValidator.reset().parameter(lockinPeriodFrequencyTypeParamName).value(this.lockinPeriodFrequencyType).ignoreIfNull() - .inMinMaxRange(0, 3); - } - } - - public Map deriveAccountingBridgeData(final String currencyCode, final Set existingTransactionIds, - final Set existingReversedTransactionIds, boolean isAccountTransfer, final boolean backdatedTxnsAllowedTill) { - - final Map accountingBridgeData = new LinkedHashMap<>(); - accountingBridgeData.put("savingsId", getId()); - accountingBridgeData.put("savingsProductId", productId()); - accountingBridgeData.put("currencyCode", currencyCode); - accountingBridgeData.put("officeId", officeId()); - accountingBridgeData.put("cashBasedAccountingEnabled", isCashBasedAccountingEnabledOnSavingsProduct()); - accountingBridgeData.put("accrualBasedAccountingEnabled", isAccrualBasedAccountingEnabledOnSavingsProduct()); - accountingBridgeData.put("isAccountTransfer", isAccountTransfer); - accountingBridgeData.put("depositAccountType", depositAccountType().getValue()); - - final List> newSavingsTransactions = new ArrayList<>(); - - List trans = null; - - if (backdatedTxnsAllowedTill) { - trans = getSavingsAccountTransactionsWithPivotConfig(); - } else { - trans = getTransactions(); - } - - // Adding new transactions to the array - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isReversed() && !existingReversedTransactionIds.contains(transaction.getId())) { - newSavingsTransactions.add(transaction.toMapData(currencyCode)); - } else if (!existingTransactionIds.contains(transaction.getId())) { - newSavingsTransactions.add(transaction.toMapData(currencyCode)); - } - } - - accountingBridgeData.put("newSavingsTransactions", newSavingsTransactions); - return accountingBridgeData; - } - - public Collection findExistingTransactionIds() { - final Collection ids = new ArrayList<>(); - List trans = getTransactions(); - for (final SavingsAccountTransaction transaction : trans) { - ids.add(transaction.getId()); - } - return ids; - } - - public Collection findCurrentTransactionIdsWithPivotDateConfig() { - - final Collection ids = new ArrayList<>(); - List trans = getSavingsAccountTransactionsWithPivotConfig(); - for (final SavingsAccountTransaction transaction : trans) { - ids.add(transaction.getId()); - } - return ids; - } - - public Collection findExistingReversedTransactionIds() { - - final Collection ids = new ArrayList<>(); - List trans = getTransactions(); - // time consuming - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isReversed()) { - ids.add(transaction.getId()); - } - } - - return ids; - } - - public Collection findCurrentReversedTransactionIdsWithPivotDateConfig() { - final Collection ids = new ArrayList<>(); - List trans = getSavingsAccountTransactionsWithPivotConfig(); - // time consuming - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isReversed()) { - ids.add(transaction.getId()); - } - } - return ids; - } - - public void update(final Client client) { - this.client = client; - } - - public void update(final Group group) { - this.group = group; - } - - public void update(final SavingsProduct product) { - this.product = product; - this.minBalanceForInterestCalculation = product.minBalanceForInterestCalculation(); - } - - public void update(final Staff savingsOfficer) { - this.savingsOfficer = savingsOfficer; - } - - public void updateAccountNo(final String newAccountNo) { - this.accountNumber = newAccountNo; - this.accountNumberRequiresAutoGeneration = false; - } - - public boolean isAccountNumberRequiresAutoGeneration() { - return this.accountNumberRequiresAutoGeneration; - } - - public Long productId() { - return this.product.getId(); - } - - public SavingsProduct savingsProduct() { - return this.product; - } - - private Boolean isCashBasedAccountingEnabledOnSavingsProduct() { - return this.product.isCashBasedAccountingEnabled(); - } - - private Boolean isAccrualBasedAccountingEnabledOnSavingsProduct() { - return this.product.isAccrualBasedAccountingEnabled(); - } - - public Long officeId() { - Long officeId = null; - if (this.client != null) { - officeId = this.client.officeId(); - } else if (this.group != null) { - officeId = this.group.officeId(); - } - return officeId; - } - - public Office office() { - Office office = null; - if (this.client != null) { - office = this.client.getOffice(); - } else if (this.group != null) { - office = this.group.getOffice(); - } - return office; - } - - public Staff getSavingsOfficer() { - return this.savingsOfficer; - } - - public Boolean getEnforceMinRequiredBalance() { - return this.enforceMinRequiredBalance; - } - - public Boolean getLienAllowed() { - return this.lienAllowed; - } - - public void unassignSavingsOfficer() { - this.savingsOfficer = null; - } - - public void assignSavingsOfficer(final Staff fieldOfficer) { - this.savingsOfficer = fieldOfficer; - } - - public Long clientId() { - Long id = null; - if (this.client != null) { - id = this.client.getId(); - } - return id; - } - - public Long groupId() { - Long id = null; - if (this.group != null) { - id = this.group.getId(); - } - return id; - } - - public GroupSavingsIndividualMonitoring getGsim() { - return gsim; - } - - public Long getSavingsProductId() { - return this.savingsProduct().getId(); - } - - public void setGsim(GroupSavingsIndividualMonitoring gsim) { - this.gsim = gsim; - } - - public Long hasSavingsOfficerId() { - Long id = null; - if (this.savingsOfficer != null) { - id = this.savingsOfficer.getId(); - } - return id; - } - - public boolean hasSavingsOfficer(final Staff fromSavingsOfficer) { - - boolean matchesCurrentSavingsOfficer = false; - if (this.savingsOfficer != null) { - matchesCurrentSavingsOfficer = this.savingsOfficer.identifiedBy(fromSavingsOfficer); - } else { - matchesCurrentSavingsOfficer = fromSavingsOfficer == null; - } - return matchesCurrentSavingsOfficer; - } - - public void reassignSavingsOfficer(final Staff newSavingsOfficer, final LocalDate assignmentDate) { - final SavingsOfficerAssignmentHistory latestHistoryRecord = findLatestIncompleteHistoryRecord(); - final SavingsOfficerAssignmentHistory lastAssignmentRecord = findLastAssignmentHistoryRecord(newSavingsOfficer); - - // assignment date should not be less than savings account submitted date - if (DateUtils.isBefore(assignmentDate, getSubmittedOnDate())) { - final String errorMessage = "The Savings Officer assignment date (" + assignmentDate.toString() - + ") cannot be before savings submitted date (" + getSubmittedOnDate().toString() + ")."; - throw new SavingsOfficerAssignmentDateException("cannot.be.before.savings.submitted.date", errorMessage, assignmentDate, - getSubmittedOnDate()); - } else if (lastAssignmentRecord != null && lastAssignmentRecord.isBeforeEndDate(assignmentDate)) { - final String errorMessage = "The Savings Officer assignment date (" + assignmentDate - + ") cannot be before previous Savings Officer unassigned date (" + lastAssignmentRecord.getEndDate() + ")."; - throw new SavingsOfficerAssignmentDateException("cannot.be.before.previous.unassignement.date", errorMessage, assignmentDate, - lastAssignmentRecord.getEndDate()); - } else if (DateUtils.isDateInTheFuture(assignmentDate)) { - final String errorMessage = "The Savings Officer assignment date (" + assignmentDate + ") cannot be in the future."; - throw new SavingsOfficerAssignmentDateException("cannot.be.a.future.date", errorMessage, assignmentDate); - } else if (latestHistoryRecord != null && this.savingsOfficer.identifiedBy(newSavingsOfficer)) { - latestHistoryRecord.setStartDate(assignmentDate); - } else if (latestHistoryRecord != null && latestHistoryRecord.matchesStartDateOf(assignmentDate)) { - latestHistoryRecord.setSavingsOfficer(newSavingsOfficer); - this.savingsOfficer = newSavingsOfficer; - } else if (latestHistoryRecord != null && latestHistoryRecord.isBeforeStartDate(assignmentDate)) { - final String errorMessage = "Savings account with identifier " + getId() + " was already assigned before date " - + assignmentDate; - throw new SavingsOfficerAssignmentDateException("is.before.last.assignment.date", errorMessage, getId(), assignmentDate); - } else { - if (latestHistoryRecord != null) { - // savings officer correctly changed from previous savings - // officer to - // new savings officer - latestHistoryRecord.setEndDate(assignmentDate); - } - this.savingsOfficer = newSavingsOfficer; - if (isNotSubmittedAndPendingApproval()) { - final SavingsOfficerAssignmentHistory savingsOfficerAssignmentHistory = SavingsOfficerAssignmentHistory.createNew(this, - this.savingsOfficer, assignmentDate); - this.savingsOfficerHistory.add(savingsOfficerAssignmentHistory); - } - } - } - - private SavingsOfficerAssignmentHistory findLastAssignmentHistoryRecord(final Staff newSavingsOfficer) { - SavingsOfficerAssignmentHistory lastAssignmentRecordLatestEndDate = null; - for (final SavingsOfficerAssignmentHistory historyRecord : this.savingsOfficerHistory) { - if (historyRecord.isCurrentRecord() && !historyRecord.isSameSavingsOfficer(newSavingsOfficer)) { - lastAssignmentRecordLatestEndDate = historyRecord; - break; - } - - if (lastAssignmentRecordLatestEndDate == null) { - lastAssignmentRecordLatestEndDate = historyRecord; - } else if (historyRecord.isBeforeEndDate(lastAssignmentRecordLatestEndDate.getEndDate()) - && !historyRecord.isSameSavingsOfficer(newSavingsOfficer)) { - lastAssignmentRecordLatestEndDate = historyRecord; - } - } - return lastAssignmentRecordLatestEndDate; - } - - public LocalDate getSubmittedOnDate() { - return submittedOnDate; - } - - public LocalDate getApprovedOnDate() { - return approvedOnDate; - } - - public LocalDate getRejectedOnDate() { - return rejectedOnDate; - } - - public AppUser getRejectedBy() { - return this.rejectedBy; - } - - public void removeSavingsOfficer(final LocalDate unassignDate) { - final SavingsOfficerAssignmentHistory latestHistoryRecord = findLatestIncompleteHistoryRecord(); - - if (latestHistoryRecord != null) { - validateUnassignDate(latestHistoryRecord, unassignDate); - latestHistoryRecord.setEndDate(unassignDate); - } - this.savingsOfficer = null; - } - - private SavingsOfficerAssignmentHistory findLatestIncompleteHistoryRecord() { - SavingsOfficerAssignmentHistory latestRecordWithNoEndDate = null; - for (final SavingsOfficerAssignmentHistory historyRecord : this.savingsOfficerHistory) { - if (historyRecord.isCurrentRecord()) { - latestRecordWithNoEndDate = historyRecord; - break; - } - } - return latestRecordWithNoEndDate; - } - - private void validateUnassignDate(final SavingsOfficerAssignmentHistory latestHistoryRecord, final LocalDate unassignDate) { - if (DateUtils.isBefore(unassignDate, latestHistoryRecord.getStartDate())) { - final String errorMessage = "The Savings officer Unassign date(" + unassignDate + ") cannot be before its assignment date (" - + latestHistoryRecord.getStartDate() + ")."; - throw new SavingsOfficerUnassignmentDateException("cannot.be.before.assignment.date", errorMessage, getId(), - getSavingsOfficer().getId(), latestHistoryRecord.getStartDate(), unassignDate); - } else if (DateUtils.isDateInTheFuture(unassignDate)) { - final String errorMessage = "The Savings Officer Unassign date (" + unassignDate + ") cannot be in the future."; - throw new SavingsOfficerUnassignmentDateException("cannot.be.a.future.date", errorMessage, unassignDate); - } - } - - public MonetaryCurrency getCurrency() { - return this.currency; - } - - public void validateNewApplicationState(final String resourceName) { - // validateWithdrawalFeeDetails(); - // validateAnnualFeeDetails(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(resourceName + SavingsApiConstants.summitalAction); - - validateLockinDetails(baseDataValidator); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - final LocalDate submittedOn = getSubmittedOnDate(); - if (DateUtils.isDateInTheFuture(submittedOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(submittedOn) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); - } - - if (this.client != null && this.client.isActivatedAfter(submittedOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(this.client.getActivationDate()) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); - } else if (this.group != null && this.group.isActivatedAfter(submittedOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(this.group.getActivationDate()) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); - } - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - public Client getClient() { - return this.client; - } - - public BigDecimal getNominalAnnualInterestRate() { - return this.nominalAnnualInterestRate; - } - - public Integer getInterestCompoundingPeriodType() { - return this.interestCompoundingPeriodType; - } - - public Integer getInterestPostingPeriodType() { - return this.interestPostingPeriodType; - } - - public Integer getInterestCalculationType() { - return this.interestCalculationType; - } - - public BigDecimal getNominalAnnualInterestRateOverdraft() { - return this.nominalAnnualInterestRateOverdraft; - } - - public Map approveApplication(final AppUser currentUser, final JsonCommand command) { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.approvalAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { - baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.status = SavingsAccountStatusType.APPROVED.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - - // only do below if status has changed in the 'approval' case - final LocalDate approvedOn = command.localDateValueOfParameterNamed(SavingsApiConstants.approvedOnDateParamName); - final String approvedOnDateChange = command.stringValueOfParameterNamed(SavingsApiConstants.approvedOnDateParamName); - - this.approvedOnDate = approvedOn; - this.approvedBy = currentUser; - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.approvedOnDateParamName, approvedOnDateChange); - - final LocalDate submittalDate = getSubmittedOnDate(); - if (DateUtils.isBefore(approvedOn, submittalDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String submittalDateAsString = formatter.format(submittalDate); - - baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName).value(submittalDateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (DateUtils.isAfterBusinessDate(approvedOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_APPROVED, approvedOn); - - // FIXME - kw - support field officer history for savings accounts - // if (this.fieldOfficer != null) { - // final LoanOfficerAssignmentHistory loanOfficerAssignmentHistory = - // LoanOfficerAssignmentHistory.createNew(this, - // this.fieldOfficer, approvedOn); - // this.loanOfficerHistory.add(loanOfficerAssignmentHistory); - // } - if (this.savingsOfficer != null) { - final SavingsOfficerAssignmentHistory savingsOfficerAssignmentHistory = SavingsOfficerAssignmentHistory.createNew(this, - this.savingsOfficer, approvedOn); - this.savingsOfficerHistory.add(savingsOfficerAssignmentHistory); - } - return actualChanges; - } - - public Map undoApplicationApproval() { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.undoApprovalAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.APPROVED.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.approved.state"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - - this.approvedOnDate = null; - this.approvedBy = null; - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = null; - this.closedBy = null; - actualChanges.put(SavingsApiConstants.approvedOnDateParamName, ""); - - // FIXME - kw - support field officer history for savings accounts - // this.loanOfficerHistory.clear(); - - return actualChanges; - } - - public void undoTransaction(final Long transactionId) { - - SavingsAccountTransaction transactionToUndo = null; - for (final SavingsAccountTransaction transaction : this.transactions) { - if (transaction.isIdentifiedBy(transactionId)) { - transactionToUndo = transaction; - } - } - - if (transactionToUndo == null) { - throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionId); - } - - validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); - transactionToUndo.reverse(); - if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { - // undo charge - final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); - for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { - final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); - if (transactionToUndo.isChargeTransaction()) { - chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.getCurrency())); - } else if (transactionToUndo.isWaiveCharge()) { - chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.getCurrency())); - } - } - } - } - - public void undoSavingsTransaction(final Long transactionId) { - - SavingsAccountTransaction transactionToUndo = null; - for (final SavingsAccountTransaction transaction : this.savingsAccountTransactions) { - if (transaction.isIdentifiedBy(transactionId)) { - transactionToUndo = transaction; - } - } - - if (transactionToUndo == null) { - throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionId); - } - - validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); - transactionToUndo.reverse(); - if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { - // undo charge - final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); - for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { - final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); - if (transactionToUndo.isChargeTransaction()) { - chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.currency)); - } else if (transactionToUndo.isWaiveCharge()) { - chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.currency)); - } - } - } - } - - public void undoTransaction(final SavingsAccountTransaction transactionToUndo) { - - if (transactionToUndo.isReversed()) { - throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionToUndo.getId()); - } - - validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); - transactionToUndo.reverse(); - if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { - // undo charge - final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); - for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { - final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); - if (transactionToUndo.isChargeTransaction()) { - chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.currency)); - } else if (transactionToUndo.isWaiveCharge()) { - chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.currency)); - } - } - } - } - - private LocalDate findLatestAnnualFeeTransactionDueDate() { - LocalDate nextDueDate = null; - - LocalDate lastAnnualFeeTransactionDate = null; - for (final SavingsAccountTransaction transaction : retreiveOrderedNonInterestPostingTransactions()) { - if (transaction.isAnnualFeeAndNotReversed()) { - LocalDate transactionDate = transaction.getTransactionDate(); - if (lastAnnualFeeTransactionDate == null) { - lastAnnualFeeTransactionDate = transactionDate; - nextDueDate = lastAnnualFeeTransactionDate; - } - if (DateUtils.isAfter(transactionDate, lastAnnualFeeTransactionDate)) { - lastAnnualFeeTransactionDate = transactionDate; - nextDueDate = lastAnnualFeeTransactionDate; - } - } - } - return nextDueDate; - } - - public void validateAccountBalanceDoesNotBecomeNegativeMinimal(final BigDecimal transactionAmount, final boolean isException) { - // final List transactionsSortedByDate = retrieveListOfTransactions(); - Money runningBalance = this.summary.getAccountBalance(getCurrency()); - Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); - final BigDecimal withdrawalFee = null; - - // check last txn date - - // In overdraft cases, minRequiredBalance can be in violation after interest posting - // and should be checked after processing all transactions - if (!isOverdraft()) { - if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); - } - } - - if (this.getSavingsHoldAmount().compareTo(BigDecimal.ZERO) > 0) { - if (runningBalance.minus(this.getSavingsHoldAmount()).minus(minRequiredBalance).isLessThanZero()) { - throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); - } - } - } - - public Map rejectApplication(final AppUser currentUser, final JsonCommand command) { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.rejectAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.status = SavingsAccountStatusType.REJECTED.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - - final LocalDate rejectedOn = command.localDateValueOfParameterNamed(SavingsApiConstants.rejectedOnDateParamName); - final String rejectedOnAsString = command.stringValueOfParameterNamed(SavingsApiConstants.rejectedOnDateParamName); - - this.rejectedOnDate = rejectedOn; - this.rejectedBy = currentUser; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = rejectedOn; - this.closedBy = currentUser; - - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.rejectedOnDateParamName, rejectedOnAsString); - actualChanges.put(SavingsApiConstants.closedOnDateParamName, rejectedOnAsString); - - final LocalDate submittalDate = getSubmittedOnDate(); - if (DateUtils.isBefore(rejectedOn, submittalDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String submittalDateAsString = formatter.format(submittalDate); - - baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName).value(submittalDateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (DateUtils.isAfterBusinessDate(rejectedOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName).value(rejectedOn) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_REJECTED, rejectedOn); - - return actualChanges; - } - - public Map applicantWithdrawsFromApplication(final AppUser currentUser, final JsonCommand command) { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.withdrawnByApplicantAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.status = SavingsAccountStatusType.WITHDRAWN_BY_APPLICANT.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - - final LocalDate withdrawnOn = command.localDateValueOfParameterNamed(SavingsApiConstants.withdrawnOnDateParamName); - final String withdrawnOnAsString = command.stringValueOfParameterNamed(SavingsApiConstants.withdrawnOnDateParamName); - - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = withdrawnOn; - this.withdrawnBy = currentUser; - this.closedOnDate = withdrawnOn; - this.closedBy = currentUser; - - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.withdrawnOnDateParamName, withdrawnOnAsString); - actualChanges.put(SavingsApiConstants.closedOnDateParamName, withdrawnOnAsString); - - final LocalDate submittalDate = getSubmittedOnDate(); - if (DateUtils.isBefore(withdrawnOn, submittalDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String submittalDateAsString = formatter.format(submittalDate); - - baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName).value(submittalDateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (DateUtils.isAfterBusinessDate(withdrawnOn)) { - baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName).value(withdrawnOn) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_WITHDRAWAL_BY_CUSTOMER, withdrawnOn); - - return actualChanges; - } - - public Map activate(final AppUser currentUser, final JsonCommand command) { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.activateAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.APPROVED.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.approved.state"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final Locale locale = command.extractLocale(); - final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); - final LocalDate activationDate = command.localDateValueOfParameterNamed(SavingsApiConstants.activatedOnDateParamName); - - this.status = SavingsAccountStatusType.ACTIVE.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.activatedOnDateParamName, activationDate.format(fmt)); - - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = null; - this.closedBy = null; - this.activatedOnDate = activationDate; - this.activatedBy = currentUser; - this.lockedInUntilDate = calculateDateAccountIsLockedUntil(getActivationDate()); - - /* - * if (annualFeeSettingsSet()) { updateToNextAnnualFeeDueDateFrom(getActivationLocalDate()); } - */ - if (this.client != null && this.client.isActivatedAfter(activationDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String dateAsString = formatter.format(this.client.getActivationDate()); - baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (this.group != null && this.group.isActivatedAfter(activationDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String dateAsString = formatter.format(this.client.getActivationDate()); - baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.group.activation.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final LocalDate approvalDate = getApprovedOnDate(); - if (DateUtils.isBefore(activationDate, approvalDate)) { - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String dateAsString = formatter.format(approvalDate); - - baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.approval.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (DateUtils.isAfterBusinessDate(activationDate)) { - baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(activationDate) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_ACTIVATE, activationDate); - - return actualChanges; - } - - public void processAccountUponActivation(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, - final Integer financialYearBeginningMonth) { - // update annual fee due date - for (SavingsAccountCharge charge : this.charges()) { - charge.updateToNextDueDateFrom(getActivationDate()); - } - - // auto pay the activation time charges (No need of checking the pivot date config) - this.payActivationCharges(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, false); - // TODO : AA add activation charges to actual changes list - } - - public Money activateWithBalance() { - // Activation never deposits money -- funding happens strictly through a separate deposit - // transaction (posted by a clerk) after the account is already active, regardless of any - // minimum required opening balance the product has configured. - return Money.zero(this.currency); - } - - public void approveAndActivateApplication(final LocalDate appliedonDate, final AppUser appliedBy) { - this.status = SavingsAccountStatusType.ACTIVE.getValue(); - this.approvedOnDate = appliedonDate; - this.approvedBy = appliedBy; - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = null; - this.closedBy = null; - this.activatedOnDate = appliedonDate; - this.activatedBy = appliedBy; - this.lockedInUntilDate = calculateDateAccountIsLockedUntil(getActivationDate()); - } - - private void payActivationCharges(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, - final boolean backdatedTxnsAllowedTill) { - boolean isSavingsChargeApplied = false; - boolean postReversals = false; - UUID refNo = UUID.randomUUID(); - for (SavingsAccountCharge savingsAccountCharge : this.charges()) { - if (savingsAccountCharge.isSavingsActivation()) { - isSavingsChargeApplied = true; - payCharge(savingsAccountCharge, savingsAccountCharge.getAmountOutstanding(getCurrency()), getActivationDate(), - backdatedTxnsAllowedTill, refNo.toString()); - } - } - - if (isSavingsChargeApplied) { - final MathContext mc = MathContext.DECIMAL64; - boolean isInterestTransfer = false; - LocalDate postInterestAsOnDate = null; - if (this.isBeforeLastPostingPeriod(getActivationDate(), backdatedTxnsAllowedTill)) { - final LocalDate today = DateUtils.getBusinessLocalDate(); - this.postInterest(mc, today, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, - postInterestAsOnDate, backdatedTxnsAllowedTill, postReversals); - } else { - final LocalDate today = DateUtils.getBusinessLocalDate(); - this.calculateInterestUsing(mc, today, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, - financialYearBeginningMonth, postInterestAsOnDate, backdatedTxnsAllowedTill, postReversals); - } - } - } - - public Map close(final AppUser currentUser, final JsonCommand command) { - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.closeAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.active.state"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final Locale locale = command.extractLocale(); - final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); - final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); - - if (DateUtils.isBefore(closedDate, getActivationDate())) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("must.be.after.activation.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (DateUtils.isAfterBusinessDate(closedDate)) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("cannot.be.a.future.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - final List savingsAccountTransactions = retrieveListOfTransactions(); - if (savingsAccountTransactions.size() > 0) { - final SavingsAccountTransaction accountTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1); - if (accountTransaction.isAfter(closedDate)) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("must.be.after.last.transaction.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - } - if (getAccountBalance().doubleValue() != 0) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("results.in.balance.not.zero"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_CLOSE_ACCOUNT, closedDate); - this.status = SavingsAccountStatusType.CLOSED.getValue(); - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.closedOnDateParamName, closedDate.format(fmt)); - - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = closedDate; - this.closedBy = currentUser; - - return actualChanges; - } - - protected void validateActivityNotBeforeClientOrGroupTransferDate(final SavingsEvent event, final LocalDate activityDate) { - if (this.client != null) { - final LocalDate clientOfficeJoiningDate = this.client.getOfficeJoiningDate(); - if (DateUtils.isBefore(activityDate, clientOfficeJoiningDate)) { - throw new SavingsActivityPriorToClientTransferException(event.toString(), clientOfficeJoiningDate); - } - } - } - - private void validateAttemptToUndoTransferRelatedTransactions(final SavingsAccountTransaction savingsAccountTransaction) { - if (savingsAccountTransaction.isTransferRelatedTransaction()) { - throw new SavingsTransferTransactionsCannotBeUndoneException(savingsAccountTransaction.getId()); - } - } - - protected LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) { - - LocalDate lockedInUntilLocalDate = null; - final PeriodFrequencyType lockinPeriodFrequencyType = PeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); - switch (lockinPeriodFrequencyType) { - case INVALID: - break; - case DAYS: - lockedInUntilLocalDate = activationLocalDate.plusDays(this.lockinPeriodFrequency); - break; - case WEEKS: - lockedInUntilLocalDate = activationLocalDate.plusWeeks(this.lockinPeriodFrequency); - break; - case MONTHS: - lockedInUntilLocalDate = activationLocalDate.plusMonths(this.lockinPeriodFrequency); - break; - case YEARS: - lockedInUntilLocalDate = activationLocalDate.plusYears(this.lockinPeriodFrequency); - break; - case WHOLE_TERM: - LOG.error("TODO Implement calculateDateAccountIsLockedUntil for WHOLE_TERM"); - break; - } - - return lockedInUntilLocalDate; - } - - public Group group() { - return this.group; - } - - public boolean isWithdrawalFeeApplicableForTransfer() { - return this.withdrawalFeeApplicableForTransfer; - } - - public void activateAccountBasedOnBalance() { - if (getStatus().isClosed() && !this.summary.getAccountBalance(getCurrency()).isZero()) { - this.status = SavingsAccountStatusType.ACTIVE.getValue(); - } - } - - public LocalDate getClosedOnDate() { - return this.closedOnDate; - } - - public AppUser getClosedBy() { - return this.closedBy; - } - - public SavingsAccountSummary getSummary() { - return this.summary; - } - - public List getTransactions() { - return this.transactions; - } - - public void addTransaction(final SavingsAccountTransaction transaction) { - this.transactions.add(transaction); - } - - public void addTransactionToExisting(final SavingsAccountTransaction transaction) { - this.savingsAccountTransactions.add(transaction); - } - - public void setStatus(final Integer status) { - this.status = status; - } - - private Set associateChargesWithThisSavingsAccount(final Set savingsAccountCharges) { - for (final SavingsAccountCharge savingsAccountCharge : savingsAccountCharges) { - savingsAccountCharge.update(this); - } - return savingsAccountCharges; - } - - public boolean update(final Set newSavingsAccountCharges) { - if (newSavingsAccountCharges == null) { - return false; - } - - if (this.charges == null) { - this.charges = new HashSet<>(); - } - this.charges.clear(); - this.charges.addAll(associateChargesWithThisSavingsAccount(newSavingsAccountCharges)); - return true; - } - - public boolean hasCurrencyCodeOf(final String matchingCurrencyCode) { - if (this.currency == null) { - return false; - } - return this.currency.getCode().equalsIgnoreCase(matchingCurrencyCode); - } - - public void removeCharge(final SavingsAccountCharge charge) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (isClosed()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("delete.transaction.invalid.account.is.closed"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (isActive() || isApproved()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("delete.transaction.invalid.account.is.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.charges.remove(charge); - } - - public void waiveCharge(final Long savingsAccountChargeId, final boolean backdatedTxnsAllowedTill) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (isClosed()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (isNotActive()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final SavingsAccountCharge savingsAccountCharge = getCharge(savingsAccountChargeId); - - if (savingsAccountCharge.isNotActive()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("charge.is.not.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (savingsAccountCharge.isWithdrawalFee()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.waiver.of.withdrawal.fee.not.supported"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - // validate charge is not already paid or waived - if (savingsAccountCharge.isWaived()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.already.waived"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } else if (savingsAccountCharge.isPaid()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.paid"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - // waive charge - final Money amountWaived = savingsAccountCharge.waive(getCurrency()); - handleWaiverChargeTransactions(savingsAccountCharge, amountWaived, backdatedTxnsAllowedTill); - } - - public void addCharge(final DateTimeFormatter formatter, final SavingsAccountCharge savingsAccountCharge, - final Charge chargeDefinition) { - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (isClosed()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (!hasCurrencyCodeOf(chargeDefinition.getCurrencyCode())) { - baseDataValidator.reset() - .failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.currency.and.charge.currency.not.same"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final LocalDate chargeDueDate = savingsAccountCharge.getDueDate(); - - if (savingsAccountCharge.isOnSpecifiedDueDate()) { - if (DateUtils.isBefore(chargeDueDate, getActivationDate())) { - baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getActivationDate().format(formatter)) - .failWithCodeNoParameterAddedToErrorCode("before.activationDate"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } else if (DateUtils.isBefore(chargeDueDate, getSubmittedOnDate())) { - baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getSubmittedOnDate().format(formatter)) - .failWithCodeNoParameterAddedToErrorCode("before.submittedOnDate"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (savingsAccountCharge.isSavingsActivation() && !(isSubmittedAndPendingApproval() || (isApproved() && isNotActive()))) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.valid.account.status.cannot.add.activation.time.charge"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - // Only one annual fee is supported per account - if (savingsAccountCharge.isAnnualFee()) { - if (this.isAnnualFeeExists()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("multiple.annual.fee.per.account.not.supported"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - } - - if (savingsAccountCharge.isAnnualFee() || savingsAccountCharge.isMonthlyFee() || savingsAccountCharge.isWeeklyFee()) { - // update due date - if (isActive()) { - savingsAccountCharge.updateToNextDueDateFrom(getActivationDate()); - } else if (isApproved()) { - savingsAccountCharge.updateToNextDueDateFrom(getApprovedOnDate()); - } - } - - // activation charge and withdrawal charges not required this validation - if (savingsAccountCharge.isOnSpecifiedDueDate()) { - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLY_CHARGE, chargeDueDate); - } - - // add new charge to savings account - this.charges.add(savingsAccountCharge); - - } - - private boolean isAnnualFeeExists() { - for (SavingsAccountCharge charge : this.charges()) { - if (charge.isAnnualFee()) { - return true; - } - } - return false; - } - - public SavingsAccountTransaction payCharge(final SavingsAccountCharge savingsAccountCharge, final BigDecimal amountPaid, - final LocalDate transactionDate, final DateTimeFormatter formatter, final boolean backdatedTxnsAllowedTill, - final String refNo) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (isClosed()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (isNotActive()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (savingsAccountCharge.isNotActive()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("charge.is.not.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (DateUtils.isBefore(transactionDate, getActivationDate())) { - baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getActivationDate().format(formatter)) - .failWithCodeNoParameterAddedToErrorCode("transaction.before.activationDate"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isDateInTheFuture(transactionDate)) { - baseDataValidator.reset().parameter(dueAsOfDateParamName).value(transactionDate.format(formatter)) - .failWithCodeNoParameterAddedToErrorCode("transaction.is.futureDate"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (savingsAccountCharge.isSavingsActivation()) { - baseDataValidator.reset() - .failWithCodeNoParameterAddedToErrorCode("transaction.not.valid.cannot.pay.activation.time.charge.is.automated"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (savingsAccountCharge.isAnnualFee()) { - final LocalDate annualFeeDueDate = savingsAccountCharge.getDueDate(); - if (annualFeeDueDate == null) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.annualfee.settings"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (DateUtils.isBefore(transactionDate, annualFeeDueDate)) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.before.dueDate"); - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - LocalDate currentAnnualFeeNextDueDate = findLatestAnnualFeeTransactionDueDate(); - if (DateUtils.isEqual(currentAnnualFeeNextDueDate, transactionDate)) { - baseDataValidator.reset().parameter("dueDate").value(transactionDate.format(formatter)) - .failWithCodeNoParameterAddedToErrorCode("transaction.exists.on.date"); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - // validate charge is not already paid or waived - if (savingsAccountCharge.isWaived()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.already.waived"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } else if (savingsAccountCharge.isPaid()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.paid"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final Money chargePaid = Money.of(currency, amountPaid); - if (!savingsAccountCharge.getAmountOutstanding(getCurrency()).isGreaterThanOrEqualTo(chargePaid)) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.charge.amount.paid.in.access"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - return this.payCharge(savingsAccountCharge, chargePaid, transactionDate, backdatedTxnsAllowedTill, refNo); - } - - public SavingsAccountTransaction payCharge(final SavingsAccountCharge savingsAccountCharge, final Money amountPaid, - final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, String refNo) { - savingsAccountCharge.pay(getCurrency(), amountPaid); - return handlePayChargeTransactions(savingsAccountCharge, amountPaid, transactionDate, backdatedTxnsAllowedTill, refNo); - } - - private SavingsAccountTransaction handlePayChargeTransactions(SavingsAccountCharge savingsAccountCharge, Money transactionAmount, - final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, final String refNo) { - SavingsAccountTransaction chargeTransaction; - - if (savingsAccountCharge.isWithdrawalFee()) { - chargeTransaction = SavingsAccountTransaction.withdrawalFee(this, office(), transactionDate, transactionAmount, refNo); - } else if (savingsAccountCharge.isAnnualFee()) { - chargeTransaction = SavingsAccountTransaction.annualFee(this, office(), transactionDate, transactionAmount); - } else { - chargeTransaction = SavingsAccountTransaction.charge(this, office(), transactionDate, transactionAmount); - } - - handleChargeTransactions(savingsAccountCharge, chargeTransaction, backdatedTxnsAllowedTill); - return chargeTransaction; - } - - private void handleWaiverChargeTransactions(SavingsAccountCharge savingsAccountCharge, Money transactionAmount, - boolean backdatedTxnsAllowedTill) { - final SavingsAccountTransaction chargeTransaction = SavingsAccountTransaction.waiver(this, office(), - DateUtils.getBusinessLocalDate(), transactionAmount); - handleChargeTransactions(savingsAccountCharge, chargeTransaction, backdatedTxnsAllowedTill); - } - - private void handleChargeTransactions(final SavingsAccountCharge savingsAccountCharge, final SavingsAccountTransaction transaction, - final boolean backdatedTxnsAllowedTill) { - // Provide a link between transaction and savings charge for which - // amount is waived. - final SavingsAccountChargePaidBy chargePaidBy = SavingsAccountChargePaidBy.instance(transaction, savingsAccountCharge, - transaction.getAmount(this.currency).getAmount()); - transaction.getSavingsAccountChargesPaid().add(chargePaidBy); - if (backdatedTxnsAllowedTill) { - this.savingsAccountTransactions.add(transaction); - this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, - this.savingsAccountTransactions); - } else { - this.transactions.add(transaction); - } - } - - private SavingsAccountCharge getCharge(final Long savingsAccountChargeId) { - SavingsAccountCharge charge = null; - for (final SavingsAccountCharge existingCharge : this.charges) { - if (existingCharge.getId().equals(savingsAccountChargeId)) { - charge = existingCharge; - break; - } - } - - if (charge == null) { - throw new SavingsAccountChargeNotFoundException(savingsAccountChargeId, getId()); - } - - return charge; - } - - public Set charges() { - return (this.charges == null) ? new HashSet() : this.charges; - } - - public void validateAccountValuesWithProduct() { - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (this.overdraftLimit != null && this.product.overdraftLimit() != null - && this.overdraftLimit.compareTo(this.product.overdraftLimit()) > 0) { - baseDataValidator.reset().parameter(SavingsApiConstants.overdraftLimitParamName).value(this.overdraftLimit) - .failWithCode("cannot.exceed.product.value"); - } - - validateInterestPostingAndCompoundingPeriodTypes(baseDataValidator); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - public boolean allowOverdraft() { - return this.allowOverdraft; - } - - public LocalDate accountSubmittedOrActivationDate() { - return getActivationDate() == null ? getSubmittedOnDate() : getActivationDate(); - } - - protected boolean isTransferInterestToOtherAccount() { - return false; - } - - public boolean accountSubmittedAndActivationOnSameDate() { - return getActivationDate() != null && DateUtils.isEqual(getActivationDate(), getSubmittedOnDate()); - - } - - public void validateInterestPostingAndCompoundingPeriodTypes(final DataValidatorBuilder baseDataValidator) { - Map> postingtoCompoundMap = new HashMap<>(); - - postingtoCompoundMap.put(SavingsPostingInterestPeriodType.DAILY, Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY)); - - postingtoCompoundMap.put(SavingsPostingInterestPeriodType.MONTHLY, - Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY)); - - postingtoCompoundMap.put(SavingsPostingInterestPeriodType.QUATERLY, Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, - SavingsCompoundingInterestPeriodType.MONTHLY, SavingsCompoundingInterestPeriodType.QUATERLY)); - - postingtoCompoundMap.put(SavingsPostingInterestPeriodType.BIANNUAL, - Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY, - SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL)); - - postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNUAL, - Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY, - SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL, - SavingsCompoundingInterestPeriodType.ANNUAL)); - - SavingsPostingInterestPeriodType savingsPostingInterestPeriodType = SavingsPostingInterestPeriodType - .fromInt(interestPostingPeriodType); - SavingsCompoundingInterestPeriodType savingsCompoundingInterestPeriodType = SavingsCompoundingInterestPeriodType - .fromInt(interestCompoundingPeriodType); - - if (postingtoCompoundMap.get(savingsPostingInterestPeriodType) == null) { - baseDataValidator.failWithCodeNoParameterAddedToErrorCode("posting.period.type.is.less.than.compound.period.type", - savingsPostingInterestPeriodType.name(), savingsCompoundingInterestPeriodType.name()); - - } - } - - public AppUser getSubmittedBy() { - return this.submittedBy; - } - - public AppUser getApprovedBy() { - return this.approvedBy; - } - - public boolean allowDeposit() { - return true; - } - - public boolean allowWithdrawal() { - return true; - } - - public boolean allowModify() { - return true; - } - - public boolean isTransactionsAllowed() { - return isActive(); - } - - public boolean isTransactionAllowed(SavingsAccountTransactionType transactionType, LocalDate transactionDate) { - if (!isTransactionsAllowed()) { - return false; - } - - Client client = getClient(); - if (client != null && !client.isActive()) { - return false; - } - Group group = group(); - if (group != null && !group.isActive()) { - return false; - } - - if (transactionDate == null) { - return true; - } - if (DateUtils.isDateInTheFuture(transactionDate) || DateUtils.isBefore(transactionDate, getActivationDate())) { - return false; - } - if (transactionType.isCredit()) { - return true; - } - return !isAccountLocked(transactionDate); - } - - public BigDecimal minBalanceForInterestCalculation() { - return this.minBalanceForInterestCalculation; - } - - public void inactivateCharge(SavingsAccountCharge savingsAccountCharge, LocalDate inactivationOnDate) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); - - if (isClosed()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (isNotActive()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - savingsAccountCharge.inactiavateCharge(inactivationOnDate); - } - - public SavingsAccountCharge getUpdatedChargeDetails(SavingsAccountCharge savingsAccountCharge) { - for (final SavingsAccountCharge charge : this.charges) { - if (charge.equals(savingsAccountCharge)) { - savingsAccountCharge = charge; - break; - } - } - return savingsAccountCharge; - } - - public String getAccountNumber() { - return this.accountNumber; - } - - private Money minRequiredBalanceDerived(final MonetaryCurrency currency) { - // Minimum required balance is not enforced -- withdrawals (and every other balance check - // that goes through this) must never be blocked by a configured minimum balance. Overdraft - // headroom is a separate concept and still applies. - Money minReqBalance = Money.zero(currency); - if (this.allowOverdraft) { - minReqBalance = minReqBalance.minus(this.overdraftLimit); - } - return minReqBalance; - } - - public BigDecimal getOnHoldFunds() { - return this.onHoldFunds == null ? BigDecimal.ZERO : this.onHoldFunds; - } - - public void holdFunds(BigDecimal onHoldFunds) { - this.onHoldFunds = getOnHoldFunds().add(onHoldFunds); - } - - public void releaseFunds(BigDecimal onHoldFunds) { - this.onHoldFunds = getOnHoldFunds().subtract(onHoldFunds); - } - - public BigDecimal getWithdrawableBalance() { - return getAccountBalance().subtract(minRequiredBalanceDerived(getCurrency()).getAmount()).subtract(this.getOnHoldFunds()) - .subtract(this.getSavingsHoldAmount()).subtract(this.getSavingsHoldAmountClearing()); - } - - public BigDecimal getWithdrawableBalanceWithLien() { - return getAccountBalance().subtract(minRequiredBalanceDerived(getCurrency()).getAmount()).subtract(this.getOnHoldFunds()) - .subtract(this.getSavingsHoldAmount()); - } - - public BigDecimal getWithdrawableBalanceWithoutMinimumBalance() { - return getAccountBalance().subtract(this.getOnHoldFunds()).subtract(this.getSavingsHoldAmount()); - } - - public TaxGroup getTaxGroup() { - return this.taxGroup; - } - - public boolean withHoldTax() { - return this.withHoldTax; - } - - public void setWithHoldTax(boolean withHoldTax) { - this.withHoldTax = withHoldTax; - } - - protected boolean applyWithholdTaxForDepositAccounts(final LocalDate interestPostingUpToDate, boolean recalucateDailyBalance, - final boolean backdatedTxnsAllowedTill) { - final List withholdTransactions = findWithHoldTransactions(); - SavingsAccountTransaction withholdTransaction = findTransactionFor(interestPostingUpToDate, withholdTransactions); - final BigDecimal totalInterestPosted = this.savingsAccountTransactionSummaryWrapper.calculateTotalInterestPosted(this.currency, - this.transactions); - if (withholdTransaction == null && this.withHoldTax()) { - boolean isWithholdTaxAdded = createWithHoldTransaction(totalInterestPosted, interestPostingUpToDate, backdatedTxnsAllowedTill); - recalucateDailyBalance = recalucateDailyBalance || isWithholdTaxAdded; - } else { - boolean isWithholdTaxAdded = updateWithHoldTransaction(totalInterestPosted, withholdTransaction); - recalucateDailyBalance = recalucateDailyBalance || isWithholdTaxAdded; - } - - return recalucateDailyBalance; - } - - public void setSubStatusInactive(final boolean backdatedTxnsAllowedTill) { - this.sub_status = SavingsAccountSubStatusEnum.INACTIVE.getValue(); - LocalDate transactionDate = DateUtils.getBusinessLocalDate(); - for (SavingsAccountCharge charge : this.charges()) { - if (charge.isSavingsNoActivity() && charge.isActive()) { - charge.updateWithdralFeeAmount(this.getAccountBalance()); - UUID refNo = UUID.randomUUID(); - this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, - refNo.toString()); - } - } - boolean postReversals = false; - recalculateDailyBalances(Money.zero(this.currency), transactionDate, backdatedTxnsAllowedTill, postReversals); - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } - - public void recalculateBalancesAfterFacilityCharges(final LocalDate transactionDate) { - recalculateDailyBalances(Money.zero(this.currency), transactionDate, false, false); - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } - - public void setSubStatusDormant() { - this.sub_status = SavingsAccountSubStatusEnum.DORMANT.getValue(); - } - - public void escheat(AppUser appUser) { - this.status = SavingsAccountStatusType.CLOSED.getValue(); - this.sub_status = SavingsAccountSubStatusEnum.ESCHEAT.getValue(); - this.closedOnDate = DateUtils.getBusinessLocalDate(); - this.closedBy = appUser; - boolean postInterestAsOnDate = false; - boolean postReversals = false; - LocalDate transactionDate = DateUtils.getBusinessLocalDate(); - if (this.getSummary().getAccountBalance(this.getCurrency()).isGreaterThanZero()) { - SavingsAccountTransaction transaction = SavingsAccountTransaction.escheat(this, transactionDate, postInterestAsOnDate); - this.transactions.add(transaction); - } - recalculateDailyBalances(Money.zero(this.currency), transactionDate, false, postReversals); - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } - - public void loadLazyCollections() { - transactions.size(); - charges.size(); - savingsOfficerHistory.size(); - if (group != null) { - Office dummyOffice = group.getOffice(); - } // Ensure lazy loading of group if set - } - - public void updateSavingsAccountSummary(final List transactions) { - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, transactions); - } - - public void updateReason(final String reasonForBlock) { - this.reasonForBlock = reasonForBlock; - } - - public Map block() { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.blockAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - - return actualChanges; - } - - public Map unblock() { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - - } - - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); - if (!SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { - baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) - .failWithCodeNoParameterAddedToErrorCode("not.in.blocked.state"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - return actualChanges; - } - - public Map blockCredits(Integer currentSubstatus) { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.blockCreditsAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - } - if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { - - baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) - .value(SavingsAccountSubStatusEnum.fromInt(currentSubstatus)).failWithCodeNoParameterAddedToErrorCode("currently.set"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - if (SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); - } else { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK_CREDIT.getValue(); - } - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - - return actualChanges; - } - - public Map unblockCredits() { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockCreditsAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - } - - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); - if (!(SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(currentSubStatus) - || SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus))) { - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode("credits.are.not.blocked"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK_DEBIT.getValue(); - } else { - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - } - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - return actualChanges; - } - - public Map blockDebits(Integer currentSubstatus) { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.blockDebitsAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - - } - if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { - - baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) - .value(SavingsAccountSubStatusEnum.fromInt(currentSubstatus)).failWithCodeNoParameterAddedToErrorCode("currently.set"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - if (SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); - } else { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK_DEBIT.getValue(); - } - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - - return actualChanges; - } - - public Map unblockDebits() { - - final Map actualChanges = new LinkedHashMap<>(); - - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockDebitsAction); - - final SavingsAccountStatusType currentStatus = getStatus(); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - - baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) - .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); - - } - - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); - if (!(SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(currentSubStatus) - || SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus))) { - baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) - .failWithCodeNoParameterAddedToErrorCode("debits.are.not.blocked"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { - this.sub_status = SavingsAccountSubStatusEnum.BLOCK_CREDIT.getValue(); - } else { - this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); - } - actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); - return actualChanges; - } - - public SavingsAccountStatusType getStatus() { - return SavingsAccountStatusType.fromInt(status); - } - - public Integer getSubStatus() { - return this.sub_status; - } - - public void validateForAccountBlock() { - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); - if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { - throw new SavingsAccountBlockedException(this.getId()); - } - } - - public void validateForDebitBlock() { - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); - if (SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(currentSubStatus)) { - throw new SavingsAccountDebitsBlockedException(this.getId()); - } - } - - public void validateForCreditBlock() { - final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); - if (SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(currentSubStatus)) { - throw new SavingsAccountCreditsBlockedException(this.getId()); - } - } - - public LocalDate retrieveLastTransactionDate() { - final List transactionsSortedByDate = retrieveListOfTransactions(); - SavingsAccountTransaction lastTransaction = null; - if (transactionsSortedByDate.size() > 0) { - lastTransaction = transactionsSortedByDate.get(transactionsSortedByDate.size() - 1); - } - LocalDate lastransactionDate = null; - if (lastTransaction != null) { - lastransactionDate = lastTransaction.getTransactionDate(); - } - return lastransactionDate; - } - - public LocalDate retrieveLastTransactionDateWithPivotConfig() { - final List transactionsSortedByDate = retrieveSortedTransactions(); - SavingsAccountTransaction lastTransaction = null; - if (transactionsSortedByDate.size() > 0) { - lastTransaction = transactionsSortedByDate.get(transactionsSortedByDate.size() - 1); - } - LocalDate lastransactionDate = null; - if (lastTransaction != null) { - lastransactionDate = lastTransaction.getTransactionDate(); - } - return lastransactionDate; - } - - /** A manual, staff-placed lien (court order, minimum-balance hold, etc.) -- distinct from a - * {@link #HOLD_CATEGORY_CLEARING} hold, which exists because of the clearing process itself rather - * than a decision someone made about this specific account. */ - public static final String HOLD_CATEGORY_CASH = "CASH"; - public static final String HOLD_CATEGORY_CLEARING = "CLEARING"; - - public BigDecimal getSavingsHoldAmount() { - return this.savingsOnHoldAmount == null ? BigDecimal.ZERO : this.savingsOnHoldAmount; - } - - public BigDecimal getSavingsHoldAmountClearing() { - return this.savingsOnHoldAmountClearing == null ? BigDecimal.ZERO : this.savingsOnHoldAmountClearing; - } - - /** Defaults to the manual-lien bucket -- every call site written before the two-category split - * keeps behaving exactly as it did before. */ - public void holdAmount(BigDecimal amount) { - holdAmount(amount, HOLD_CATEGORY_CASH); - } - - public void holdAmount(BigDecimal amount, String category) { - if (HOLD_CATEGORY_CLEARING.equals(category)) { - this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().add(amount); - } else { - this.savingsOnHoldAmount = getSavingsHoldAmount().add(amount); - } - } - - public void releaseOnHoldAmount(BigDecimal amount) { - releaseOnHoldAmount(amount, HOLD_CATEGORY_CASH); - } - - public void releaseOnHoldAmount(BigDecimal amount, String category) { - if (HOLD_CATEGORY_CLEARING.equals(category)) { - this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().subtract(amount); - } else { - this.savingsOnHoldAmount = getSavingsHoldAmount().subtract(amount); - } - } - - public AccountType getAccountType() { - return AccountType.fromInt(accountType); - } - - public Integer getAccountTypes() { - return accountType; - } - - public void setAccountType(Integer accountType) { - this.accountType = accountType; - } - - private boolean isOverdraft() { - return allowOverdraft; - } - - public Long getGroupId() { - return this.groupId(); - } - - public Integer getInterestCalculationDaysInYearType() { - return this.interestCalculationDaysInYearType; - } - - public BigDecimal getMinRequiredOpeningBalance() { - return this.minRequiredOpeningBalance; - } - - public Integer getLockinPeriodFrequency() { - return this.lockinPeriodFrequency; - } - - public Integer getLockinPeriodFrequencyType() { - return this.lockinPeriodFrequencyType; - } - - public boolean isWithdrawalFeeForTransfer() { - return this.withdrawalFeeApplicableForTransfer; - } - - public boolean isAllowOverdraft() { - return this.allowOverdraft; - } - - public BigDecimal getOverdraftLimit() { - return this.overdraftLimit; - } - - public BigDecimal getMinOverdraftForInterestCalculation() { - return this.minOverdraftForInterestCalculation; - } - - public BigDecimal getMinRequiredBalance() { - return this.minRequiredBalance; - } - - public boolean isEnforceMinRequiredBalance() { - return this.enforceMinRequiredBalance; - } - - public BigDecimal getMaxAllowedLienLimit() { - return this.maxAllowedLienLimit; - } - - public boolean isLienAllowed() { - return this.lienAllowed; - } - - public BigDecimal getMinBalanceForInterestCalculation() { - return this.minBalanceForInterestCalculation; - } - - public int getVersion() { - return this.version; - } - - public boolean isWithHoldTax() { - return this.withHoldTax; - } - - public void setAccruedTillDate(LocalDate accruedTillDate) { - this.accruedTillDate = accruedTillDate; - } - - public LocalDate getLastClosedBusinessDate() { - return this.lastClosedBusinessDate; - } - - public void setLastClosedBusinessDate(LocalDate lastClosedBusinessDate) { - this.lastClosedBusinessDate = lastClosedBusinessDate; - } - - public List toSavingsAccountTransactionDetailsForPostingPeriodList( - List transactions) { - return transactions.stream() - .map(transaction -> transaction.toSavingsAccountTransactionDetailsForPostingPeriod(this.currency, this.allowOverdraft)) - .toList(); - } - - public void accrualsForSavingsReverse(SavingsAccountTransactionDTO transactionDTO, final boolean backdatedTxnsAllowedTill) { - List accountTransactionsSorted = null; - - if (backdatedTxnsAllowedTill) { - accountTransactionsSorted = retrieveSortedTransactions(); - } else { - accountTransactionsSorted = retrieveListOfTransactions(); - } - for (final SavingsAccountTransaction transaction : accountTransactionsSorted) { - boolean typeTransaccionValidation = transaction.getTransactionType() == SavingsAccountTransactionType.ACCRUAL; - if (typeTransaccionValidation && (transaction.getDateOf().isAfter(transactionDTO.getTransactionDate()) - || transaction.getDateOf().isEqual(transactionDTO.getTransactionDate()))) { - transaction.reverse(); - } - } - } - - public List toSavingsAccountTransactionDetailsForPostingPeriodList() { - return retreiveOrderedNonInterestPostingTransactions().stream() - .map(transaction -> transaction.toSavingsAccountTransactionDetailsForPostingPeriod(this.currency, this.allowOverdraft)) - .toList(); - } - - @Override - public DepositAccountType depositAccountType() { - return DepositAccountType.fromInt(100); - } - - // ─── CBS extended fields ─────────────────────────────────────────────────── - - @Column(name = "cbs_account_type", length = 100) - private String cbsAccountType; - - @Column(name = "customer_segment", length = 100) - private String customerSegment; - - @Column(name = "account_status", length = 50) - private String accountStatus; - - @Column(name = "mode_of_operation", length = 50) - private String modeOfOperation; - - @Column(name = "special_category", length = 100) - private String specialCategory; - - @Column(name = "weaker_section", length = 100) - private String weakerSection; - - @Column(name = "guardian_name", length = 200) - private String guardianName; - - @Column(name = "guardian_cif_number", length = 50) - private String guardianCifNumber; - - @Column(name = "guardian_relationship", length = 100) - private String guardianRelationship; - - @Column(name = "minor_date_of_birth") - private LocalDate minorDateOfBirth; - - @Column(name = "guardian_client_id") - private Long guardianClientId; - - @Column(name = "introducer_client_id") - private Long introducerClientId; - - @Column(name = "introducer_name", length = 200) - private String introducerName; - - @Column(name = "introducer_cif_number", length = 50) - private String introducerCifNumber; - - @Column(name = "nominee_data", columnDefinition = "TEXT") - private String nomineeData; - - @Column(name = "linked_account_id") - private Long linkedAccountId; - - @Column(name = "linked_account_type", length = 20) - private String linkedAccountType; - - @Column(name = "linked_account_no", length = 50) - private String linkedAccountNo; - - @Column(name = "introducer_required", nullable = false, columnDefinition = "boolean default false") - private Boolean introducerRequired = false; - - @Column(name = "share_linking_enabled", nullable = false, columnDefinition = "boolean default false") - private Boolean shareLinkingEnabled = false; - - /** - * Plain id, not a JPA relationship - fineract-savings cannot depend on the shareaccounts domain - * class (it lives in fineract-provider, which depends on fineract-savings, not the other way - * round). The write side resolves and validates this id against m_share_account directly; this - * column just carries it back to the share account's own {@code savings_account_id}, mirroring - * the link from both ends. - */ - @Column(name = "share_account_id") - private Long shareAccountId; - - @Column(name = "nomination_facility", nullable = false, columnDefinition = "boolean default false") - private Boolean nominationFacility = false; - - @Column(name = "auto_debit_facility", nullable = false, columnDefinition = "boolean default false") - private Boolean autoDebitFacility = false; - - @Column(name = "is_senior_citizen", nullable = false, columnDefinition = "boolean default false") - private boolean isSeniorCitizen = false; - - @Column(name = "senior_citizen_interest_rate", scale = 6, precision = 19, nullable = true) - private BigDecimal seniorCitizenInterestRate; - - @Column(name = "terminate_true", nullable = false, columnDefinition = "boolean default false") - private boolean terminateTrue = false; - - public boolean isTerminateTrue() { return terminateTrue; } - public void setTerminateTrue(boolean terminateTrue) { this.terminateTrue = terminateTrue; } - - public Map terminate() { - final Map actualChanges = new LinkedHashMap<>(); - this.terminateTrue = true; - this.status = SavingsAccountStatusType.TERMINATED.getValue(); - actualChanges.put("terminateTrue", true); - actualChanges.put("status", SavingsAccountStatusType.TERMINATED.getValue()); - return actualChanges; - } - - - @jakarta.persistence.OneToOne(cascade = jakarta.persistence.CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) - private SavingsAccountFacilityConfig facilityConfig; - - @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) - private List jointHolders = new ArrayList<>(); - - public String getCbsAccountType() { return cbsAccountType; } - public void setCbsAccountType(String v) { this.cbsAccountType = v; } - - public String getCustomerSegment() { return customerSegment; } - public void setCustomerSegment(String v) { this.customerSegment = v; } - - public String getAccountStatus() { return accountStatus; } - public void setAccountStatus(String v) { this.accountStatus = v; } - - public String getModeOfOperation() { return modeOfOperation; } - public void setModeOfOperation(String v) { this.modeOfOperation = v; } - - public String getSpecialCategory() { return specialCategory; } - public void setSpecialCategory(String v) { this.specialCategory = v; } - - public String getWeakerSection() { return weakerSection; } - public void setWeakerSection(String v) { this.weakerSection = v; } - - public String getGuardianName() { return guardianName; } - public void setGuardianName(String v) { this.guardianName = v; } - - public String getGuardianCifNumber() { return guardianCifNumber; } - public void setGuardianCifNumber(String v) { this.guardianCifNumber = v; } - - public String getGuardianRelationship() { return guardianRelationship; } - public void setGuardianRelationship(String v) { this.guardianRelationship = v; } - - public LocalDate getMinorDateOfBirth() { return minorDateOfBirth; } - public void setMinorDateOfBirth(LocalDate v) { this.minorDateOfBirth = v; } - - public Long getGuardianClientId() { return guardianClientId; } - public void setGuardianClientId(Long v) { this.guardianClientId = v; } - - public Long getIntroducerClientId() { return introducerClientId; } - public void setIntroducerClientId(Long v) { this.introducerClientId = v; } - - public String getIntroducerName() { return introducerName; } - public void setIntroducerName(String v) { this.introducerName = v; } - - public String getIntroducerCifNumber() { return introducerCifNumber; } - public void setIntroducerCifNumber(String v) { this.introducerCifNumber = v; } - - public String getNomineeData() { return nomineeData; } - public void setNomineeData(String v) { this.nomineeData = v; } - - public Long getLinkedAccountId() { return linkedAccountId; } - public void setLinkedAccountId(Long v) { this.linkedAccountId = v; } - - public String getLinkedAccountType() { return linkedAccountType; } - public void setLinkedAccountType(String v) { this.linkedAccountType = v; } - - public String getLinkedAccountNo() { return linkedAccountNo; } - public void setLinkedAccountNo(String v) { this.linkedAccountNo = v; } - - public SavingsAccountFacilityConfig getFacilityConfig() { return facilityConfig; } - public void setFacilityConfig(SavingsAccountFacilityConfig v) { this.facilityConfig = v; } - - public List getJointHolders() { return jointHolders; } - public void setJointHolders(List v) { this.jointHolders = v; } - - public Boolean getNominationFacility() { return nominationFacility; } - public void setNominationFacility(Boolean v) { this.nominationFacility = v; } - - public Boolean getAutoDebitFacility() { return autoDebitFacility; } - public void setAutoDebitFacility(Boolean v) { this.autoDebitFacility = v; } - - public Boolean getIntroducerRequired() { return introducerRequired; } - public void setIntroducerRequired(Boolean v) { this.introducerRequired = v; } - - public Boolean getShareLinkingEnabled() { return shareLinkingEnabled; } - public void setShareLinkingEnabled(Boolean v) { this.shareLinkingEnabled = v; } - - public Long getShareAccountId() { return shareAccountId; } - public void setShareAccountId(Long v) { this.shareAccountId = v; } - - public boolean isSeniorCitizen() { return isSeniorCitizen; } - public void setSeniorCitizen(boolean v) { this.isSeniorCitizen = v; } - - public BigDecimal getSeniorCitizenInterestRate() { return seniorCitizenInterestRate; } - public void setSeniorCitizenInterestRate(BigDecimal v) { this.seniorCitizenInterestRate = v; } - - public void updateNominalAnnualInterestRate(BigDecimal rate) { - if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) { - this.nominalAnnualInterestRate = rate; - } - } -} +/** + * 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 static java.time.temporal.ChronoUnit.DAYS; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.allowOverdraftParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.dueAsOfDateParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lienAllowedParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.localeParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPeriodFrequencyParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPeriodFrequencyTypeParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.maxAllowedLienLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minOverdraftForInterestCalculationParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequiredBalanceParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withdrawalFeeForTransfersParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.introducerRequiredParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareLinkingEnabledParamName; + +import com.google.gson.JsonArray; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.DiscriminatorType; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import java.math.MathContext; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.configuration.service.TemporaryConfigurationServiceContainer; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.infrastructure.security.service.RandomPasswordGenerator; +import org.apache.fineract.interoperation.domain.InteropIdentifier; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.staff.domain.Staff; +import org.apache.fineract.portfolio.accountdetails.domain.AccountType; +import org.apache.fineract.portfolio.charge.domain.Charge; +import org.apache.fineract.portfolio.charge.exception.SavingsAccountChargeNotFoundException; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientKyc; +import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; +import org.apache.fineract.portfolio.group.domain.Group; +import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; +import org.apache.fineract.portfolio.savings.SavingsApiConstants; +import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; +import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; +import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType; +import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; +import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; +import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; +import org.apache.fineract.portfolio.savings.domain.interest.SavingsAccountTransactionDetailsForPostingPeriod; +import org.apache.fineract.portfolio.savings.exception.InsufficientAccountBalanceException; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountBlockedException; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountCreditsBlockedException; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountDebitsBlockedException; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; +import org.apache.fineract.portfolio.savings.exception.SavingsActivityPriorToClientTransferException; +import org.apache.fineract.portfolio.savings.exception.SavingsOfficerAssignmentDateException; +import org.apache.fineract.portfolio.savings.exception.SavingsOfficerUnassignmentDateException; +import org.apache.fineract.portfolio.savings.exception.SavingsTransferTransactionsCannotBeUndoneException; +import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; +import org.apache.fineract.portfolio.tax.domain.TaxComponent; +import org.apache.fineract.portfolio.tax.domain.TaxGroup; +import org.apache.fineract.portfolio.tax.service.TaxUtils; +import org.apache.fineract.useradministration.domain.AppUser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.CollectionUtils; + +@Entity +@Table(name = "m_savings_account", uniqueConstraints = { @UniqueConstraint(columnNames = { "account_no" }, name = "sa_account_no_UNIQUE"), + @UniqueConstraint(columnNames = { "external_id" }, name = "sa_external_id_UNIQUE") }) +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "deposit_type_enum", discriminatorType = DiscriminatorType.INTEGER) +@DiscriminatorValue("100") +@SuppressWarnings({ "MemberName" }) +public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom implements IDepositAccountType { + + private static final Logger LOG = LoggerFactory.getLogger(SavingsAccount.class); + + @Version + int version; + + @Column(name = "account_no", length = 20, unique = true, nullable = false) + protected String accountNumber; + + @Column(name = "external_id", nullable = true) + protected ExternalId externalId; + + @ManyToOne(optional = true) + @JoinColumn(name = "client_id", nullable = true) + protected Client client; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "group_id", nullable = true) + protected Group group; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "gsim_id", nullable = true) + private GroupSavingsIndividualMonitoring gsim; + + @ManyToOne + @JoinColumn(name = "product_id", nullable = false) + protected SavingsProduct product; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "field_officer_id", nullable = true) + protected Staff savingsOfficer; + + @Column(name = "status_enum", nullable = false) + protected Integer status; + + @Column(name = "sub_status_enum", nullable = false) + protected Integer sub_status = 0; + + @Column(name = "account_type_enum", nullable = false) + protected Integer accountType; + + @Column(name = "submittedon_date", nullable = true) + protected LocalDate submittedOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "submittedon_userid", nullable = true) + protected AppUser submittedBy; + + @Column(name = "rejectedon_date") + protected LocalDate rejectedOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "rejectedon_userid", nullable = true) + protected AppUser rejectedBy; + + @Column(name = "withdrawnon_date") + protected LocalDate withdrawnOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "withdrawnon_userid", nullable = true) + protected AppUser withdrawnBy; + + @Column(name = "approvedon_date") + protected LocalDate approvedOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "approvedon_userid", nullable = true) + protected AppUser approvedBy; + + @Column(name = "activatedon_date", nullable = true) + protected LocalDate activatedOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "activatedon_userid", nullable = true) + protected AppUser activatedBy; + + @Column(name = "closedon_date") + protected LocalDate closedOnDate; + + @ManyToOne(optional = true, fetch = FetchType.LAZY) + @JoinColumn(name = "closedon_userid", nullable = true) + protected AppUser closedBy; + + @Column(name = "reason_for_block", nullable = true) + protected String reasonForBlock; + + @Embedded + protected MonetaryCurrency currency; + + @Column(name = "nominal_annual_interest_rate", scale = 6, precision = 19, nullable = false) + protected BigDecimal nominalAnnualInterestRate; + + /** + * The interest period is the span of time at the end of which savings in a client's account earn interest. + * + * A value from the {@link SavingsCompoundingInterestPeriodType} enumeration. + */ + @Column(name = "interest_compounding_period_enum", nullable = false) + protected Integer interestCompoundingPeriodType; + + /** + * A value from the {@link SavingsPostingInterestPeriodType} enumeration. + */ + @Column(name = "interest_posting_period_enum", nullable = false) + protected Integer interestPostingPeriodType; + + /** + * A value from the {@link SavingsInterestCalculationType} enumeration. + */ + @Column(name = "interest_calculation_type_enum", nullable = false) + protected Integer interestCalculationType; + + /** + * A value from the {@link SavingsInterestCalculationDaysInYearType} enumeration. + */ + @Column(name = "interest_calculation_days_in_year_type_enum", nullable = false) + protected Integer interestCalculationDaysInYearType; + + @Column(name = "min_required_opening_balance", scale = 6, precision = 19, nullable = true) + protected BigDecimal minRequiredOpeningBalance; + + @Column(name = "lockin_period_frequency", nullable = true) + protected Integer lockinPeriodFrequency; + + @Column(name = "lockin_period_frequency_enum", nullable = true) + protected Integer lockinPeriodFrequencyType; + + /** + * When account becomes active this field is derived if lockinPeriodFrequency and + * lockinPeriodFrequencyType details are present. + */ + @Column(name = "lockedin_until_date_derived", nullable = true) + protected LocalDate lockedInUntilDate; + + @Column(name = "withdrawal_fee_for_transfer", nullable = true) + protected boolean withdrawalFeeApplicableForTransfer; + + @Column(name = "allow_overdraft") + private boolean allowOverdraft; + + @Column(name = "overdraft_limit", scale = 6, precision = 19, nullable = true) + private BigDecimal overdraftLimit; + + @Column(name = "nominal_annual_interest_rate_overdraft", scale = 6, precision = 19, nullable = true) + protected BigDecimal nominalAnnualInterestRateOverdraft; + + @Column(name = "min_overdraft_for_interest_calculation", scale = 6, precision = 19, nullable = true) + private BigDecimal minOverdraftForInterestCalculation; + + @Column(name = "enforce_min_required_balance") + private boolean enforceMinRequiredBalance; + + @Column(name = "min_required_balance", scale = 6, precision = 19, nullable = true) + private BigDecimal minRequiredBalance; + + @Column(name = "is_lien_allowed", nullable = false) + private boolean lienAllowed; + + @Column(name = "max_allowed_lien_limit", scale = 6, precision = 19, nullable = true) + private BigDecimal maxAllowedLienLimit; + + @Column(name = "on_hold_funds_derived", scale = 6, precision = 19, nullable = true) + private BigDecimal onHoldFunds; + + @Column(name = "start_interest_calculation_date") + protected LocalDate startInterestCalculationDate; + + @Embedded + protected SavingsAccountSummary summary; + + @OrderBy(value = "dateOf, createdDate, id") + @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) + protected List transactions = new ArrayList<>(); + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) + protected Set charges = new HashSet<>(); + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) + private Set savingsOfficerHistory = new HashSet<>(); + + @Transient + protected boolean accountNumberRequiresAutoGeneration = false; + @Transient + protected SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper; + @Transient + protected SavingsHelper savingsHelper; + @Transient + protected List savingsAccountTransactions = new ArrayList<>(); + + @Column(name = "deposit_type_enum", insertable = false, updatable = false) + private Integer depositType; + + @Column(name = "min_balance_for_interest_calculation", scale = 6, precision = 19, nullable = true) + private BigDecimal minBalanceForInterestCalculation; + + @Column(name = "withhold_tax", nullable = false) + protected boolean withHoldTax; + + @ManyToOne + @JoinColumn(name = "tax_group_id") + private TaxGroup taxGroup; + + @Column(name = "accrued_till_date") + private LocalDate accruedTillDate; + + @Column(name = "last_closed_business_date") + private LocalDate lastClosedBusinessDate; + + @Column(name = "total_savings_amount_on_hold", scale = 6, precision = 19, nullable = true) + private BigDecimal savingsOnHoldAmount; + + /** A hold placed because a cheque/DD/instrument is still in the clearing pipeline (a deposit not yet + * 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 + * rather than one bucket doing double duty. */ + /* + * 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; + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY) + protected List identifiers = new ArrayList<>(); + + public transient ConfigurationDomainService configurationDomainService; + + protected SavingsAccount() { + // + } + + public static SavingsAccount createNewApplicationForSubmittal(final Client client, final Group group, final SavingsProduct product, + final Staff fieldOfficer, final String accountNo, final ExternalId externalId, final AccountType accountType, + final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal interestRate, + final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, + final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, + final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, + final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, + final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, + final boolean allowOverdraft, final BigDecimal overdraftLimit, final boolean enforceMinRequiredBalance, + final BigDecimal minRequiredBalance, final BigDecimal maxAllowedLienLimit, final boolean lienAllowed, + final BigDecimal nominalAnnualInterestRateOverdraft, final BigDecimal minOverdraftForInterestCalculation, + final boolean withHoldTax) { + + final SavingsAccountStatusType status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL; + return new SavingsAccount(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, submittedOnDate, + submittedBy, interestRate, interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, + interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, + withdrawalFeeApplicableForTransfer, savingsAccountCharges, allowOverdraft, overdraftLimit, enforceMinRequiredBalance, + minRequiredBalance, maxAllowedLienLimit, lienAllowed, nominalAnnualInterestRateOverdraft, + minOverdraftForInterestCalculation, withHoldTax); + } + + protected SavingsAccount(final Client client, final Group group, final SavingsProduct product, final Staff fieldOfficer, + final String accountNo, final ExternalId externalId, final SavingsAccountStatusType status, final AccountType accountType, + final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal nominalAnnualInterestRate, + final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, + final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, + final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, + final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, + final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, + final boolean allowOverdraft, final BigDecimal overdraftLimit, boolean withHoldTax) { + this(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, submittedOnDate, submittedBy, + nominalAnnualInterestRate, interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, + interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, + withdrawalFeeApplicableForTransfer, savingsAccountCharges, allowOverdraft, overdraftLimit, false, null, null, false, null, + null, withHoldTax); + } + + protected SavingsAccount(final Client client, final Group group, final SavingsProduct product, final Staff savingsOfficer, + final String accountNo, final ExternalId externalId, final SavingsAccountStatusType status, final AccountType accountType, + final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal nominalAnnualInterestRate, + final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, + final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, + final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, + final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, + final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, + final boolean allowOverdraft, final BigDecimal overdraftLimit, final boolean enforceMinRequiredBalance, + final BigDecimal minRequiredBalance, final BigDecimal maxAllowedLienLimit, final boolean lienAllowed, + final BigDecimal nominalAnnualInterestRateOverdraft, final BigDecimal minOverdraftForInterestCalculation, boolean withHoldTax) { + this.client = client; + this.group = group; + this.product = product; + this.savingsOfficer = savingsOfficer; + if (StringUtils.isBlank(accountNo)) { + this.accountNumber = new RandomPasswordGenerator(19).generate(); + this.accountNumberRequiresAutoGeneration = true; + } else { + this.accountNumber = accountNo; + } + + this.currency = product.currency(); + this.externalId = externalId; + this.status = status.getValue(); + this.accountType = accountType.getValue(); + this.submittedOnDate = submittedOnDate; + this.submittedBy = submittedBy; + this.nominalAnnualInterestRate = nominalAnnualInterestRate; + this.interestCompoundingPeriodType = interestCompoundingPeriodType.getValue(); + this.interestPostingPeriodType = interestPostingPeriodType.getValue(); + this.interestCalculationType = interestCalculationType.getValue(); + this.interestCalculationDaysInYearType = interestCalculationDaysInYearType.getValue(); + this.minRequiredOpeningBalance = minRequiredOpeningBalance; + this.lockinPeriodFrequency = lockinPeriodFrequency; + if (lockinPeriodFrequencyType != null) { + this.lockinPeriodFrequencyType = lockinPeriodFrequencyType.getValue(); + } + this.withdrawalFeeApplicableForTransfer = withdrawalFeeApplicableForTransfer; + + if (!CollectionUtils.isEmpty(savingsAccountCharges)) { + this.charges = associateChargesWithThisSavingsAccount(savingsAccountCharges); + } + + this.summary = new SavingsAccountSummary(); + this.allowOverdraft = allowOverdraft; + this.overdraftLimit = overdraftLimit; + this.nominalAnnualInterestRateOverdraft = nominalAnnualInterestRateOverdraft; + this.minOverdraftForInterestCalculation = minOverdraftForInterestCalculation; + esnureOverdraftLimitsSetForOverdraftAccounts(); + + this.enforceMinRequiredBalance = enforceMinRequiredBalance; + this.minRequiredBalance = minRequiredBalance; + this.lienAllowed = lienAllowed; + this.maxAllowedLienLimit = maxAllowedLienLimit; + this.minBalanceForInterestCalculation = product.minBalanceForInterestCalculation(); + // this.savingsOfficerHistory = null; + this.withHoldTax = withHoldTax; + this.taxGroup = product.getTaxGroup(); + } + + /** + * Used after fetching/hydrating a {@link SavingsAccount} object to inject helper services/components used for + * update summary details after events/transactions on a {@link SavingsAccount}. + */ + public void setHelpers(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper, + final SavingsHelper savingsHelper) { + this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper; + this.savingsHelper = savingsHelper; + } + + public void setSavingsAccountTransactions(final List savingsAccountTransactions) { + this.savingsAccountTransactions.addAll(savingsAccountTransactions); + } + + public List getSavingsAccountTransactionsWithPivotConfig() { + return this.savingsAccountTransactions; + } + + public ExternalId getExternalId() { + return externalId; + } + + public boolean isNotActive() { + return !isActive(); + } + + public boolean isActive() { + return SavingsAccountStatusType.fromInt(this.status).isActive(); + } + + public boolean isNotSubmittedAndPendingApproval() { + return !isSubmittedAndPendingApproval(); + } + + public boolean isSubmittedAndPendingApproval() { + return SavingsAccountStatusType.fromInt(this.status).isSubmittedAndPendingApproval(); + } + + public boolean isApproved() { + return SavingsAccountStatusType.fromInt(this.status).isApproved(); + } + + public boolean isActivated() { + boolean isActive = false; + if (this.activatedOnDate != null) { + isActive = true; + } + return isActive; + } + + public boolean isClosed() { + return SavingsAccountStatusType.fromInt(this.status).isClosed(); + } + + public List getIdentifiers() { + return identifiers; + } + + /** + * Extension point for the interest-posting correction loop in {@link #postInterest}: lets a subclass veto + * reversing/reposting an already-existing posting transaction for a period. Defaults to always allowing the + * correction, so plain savings and recurring deposit behaviour is unchanged. + */ + protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) { + return true; + } + + /** + * Extension point checked by {@code SavingsAccountDomainServiceJpa#handleWithdrawal} before it re-triggers an + * interest recalculation right after creating a withdrawal. Defaults to always requiring the full recalculation + * (via {@link #postInterest} / {@link #calculateInterestUsing}), so plain savings and recurring deposit + * behaviour is unchanged. When this returns false, {@link #refreshBalancesAndSummaryAfterTransfer} is called + * instead. + */ + public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) { + return true; + } + + /** + * Lightweight alternative to {@link #postInterest} used right after a withdrawal when the full interest + * recalculation is skipped (see {@link #isPostWithdrawalInterestRecalculationRequired}). Refreshes running + * balances and the account summary -- the bookkeeping {@link #postInterest} always performs at its tail, + * regardless of whether any posting correction was needed -- without re-running the interest + * posting/correction loop that recalculation drives. + */ + public void refreshBalancesAndSummaryAfterTransfer(final LocalDate interestPostingUpToDate, final boolean backdatedTxnsAllowedTill, + final boolean postReversals) { + Money openingAccountBalance = Money.zero(this.currency); + if (backdatedTxnsAllowedTill) { + if (this.summary.getLastInterestCalculationDate() == null) { + openingAccountBalance = Money.zero(this.currency); + } else { + openingAccountBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } + } + recalculateDailyBalances(openingAccountBalance, interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); + if (!backdatedTxnsAllowedTill) { + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } else { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, + this.savingsAccountTransactions); + } + } + + public void postInterest(final MathContext mc, final LocalDate interestPostingUpToDate, final boolean isInterestTransfer, + final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, + final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { + final List postingPeriods = calculateInterestUsing(mc, interestPostingUpToDate, isInterestTransfer, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, + postReversals); + if (postingPeriods.isEmpty()) { + return; + } + + Money interestPostedToDate = Money.zero(this.currency); + + if (backdatedTxnsAllowedTill) { + interestPostedToDate = Money.of(this.currency, this.summary.getTotalInterestPosted()); + } + + boolean recalucateDailyBalanceDetails = false; + boolean applyWithHoldTax = isWithHoldTaxApplicableForInterestPosting(); + final List withholdTransactions = new ArrayList<>(); + + if (backdatedTxnsAllowedTill) { + withholdTransactions.addAll(findWithHoldSavingsTransactionsWithPivotConfig()); + } else { + withholdTransactions.addAll(findWithHoldTransactions()); + } + + // Reverse manual interest postings that no longer line up with any period this run computed, + // BEFORE the periods below are applied. + // + // A user posting ("post interest as on D") lands its transaction on D-1, because + // SavingsHelper#determineInterestPostingPeriods closes the period at D-1 when it meets D in + // postedAsOnDates. But getManualPostingDates() then feeds that transaction's own date -- D-1 + // -- back in as a boundary on every later run, which closes a period at D-2 instead. So the + // posting's date matches no period end the second time around: findInterestPostingTransactionFor + // below misses it, it is never reversed or corrected, and it survives alongside a new posting + // covering the very same days. The member is credited twice, and total_interest_posted_derived + // drifts above what was actually earned. + // + // A posting is only treated as orphaned, and only reversed, when BOTH hold: + // - its date is not any period's posting date, so the loop below will never find and correct it; + // - some period that WILL be posted in this run (posting date on or before + // interestPostingUpToDate) covers that date, so the days it credited are being credited again. + // The second condition is what keeps this from ever destroying interest. A run whose remaining + // period only matures in the future posts nothing for those days -- the recalculation that + // follows a withdrawal is exactly that case -- and reversing there would silently take earned + // interest back off the member instead of de-duplicating it. + // Automatic postings are left alone: they sit on natural period ends, which always recur. + final Set periodPostingDates = new HashSet<>(); + final List repostedIntervals = new ArrayList<>(); + for (final PostingPeriod period : postingPeriods) { + periodPostingDates.add(period.dateOfPostingTransaction()); + if (!DateUtils.isAfter(period.dateOfPostingTransaction(), interestPostingUpToDate)) { + repostedIntervals.add(period.getPeriodInterval()); + } + } + final List postingsInScope = backdatedTxnsAllowedTill + ? getSavingsAccountTransactionsWithPivotConfig() + : getTransactions(); + for (final SavingsAccountTransaction transaction : new ArrayList<>(postingsInScope)) { + final LocalDate transactionDate = transaction.getTransactionDate(); + if (!transaction.isInterestPosting() || !transaction.isNotReversed() || transaction.isReversalTransaction() + || !transaction.isManualTransaction() || periodPostingDates.contains(transactionDate)) { + continue; + } + boolean coveredByAPostedPeriod = false; + for (final LocalDateInterval interval : repostedIntervals) { + if (interval.contains(transactionDate)) { + coveredByAPostedPeriod = true; + break; + } + } + if (!coveredByAPostedPeriod) { + continue; + } + transaction.reverse(); + final SavingsAccountTransaction strandedWithholdTransaction = findTransactionFor(transactionDate, withholdTransactions); + if (strandedWithholdTransaction != null) { + strandedWithholdTransaction.reverse(); + } + recalucateDailyBalanceDetails = true; + } + + for (final PostingPeriod interestPostingPeriod : postingPeriods) { + final LocalDate interestPostingTransactionDate = interestPostingPeriod.dateOfPostingTransaction(); + final Money interestEarnedToBePostedForPeriod = interestPostingPeriod.getInterestEarned(); + + if (!DateUtils.isAfter(interestPostingTransactionDate, interestPostingUpToDate)) { + interestPostedToDate = interestPostedToDate.plus(interestEarnedToBePostedForPeriod); + + SavingsAccountTransaction postingTransaction = null; + if (backdatedTxnsAllowedTill) { + postingTransaction = findInterestPostingSavingsTransactionWithPivotConfig(interestPostingTransactionDate); + } else { + postingTransaction = findInterestPostingTransactionFor(interestPostingTransactionDate); + } + if (postingTransaction == null) { + SavingsAccountTransaction newPostingTransaction; + if (interestEarnedToBePostedForPeriod.isGreaterThanOrEqualTo(Money.zero(currency))) { + + newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), interestPostingTransactionDate, + interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting()); + } else { + newPostingTransaction = SavingsAccountTransaction.overdraftInterest(this, office(), interestPostingTransactionDate, + interestEarnedToBePostedForPeriod.negated(), interestPostingPeriod.isUserPosting()); + } + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(newPostingTransaction); + } else { + addTransaction(newPostingTransaction); + } + if (applyWithHoldTax) { + createWithHoldTransaction(interestEarnedToBePostedForPeriod.getAmount(), interestPostingTransactionDate, + backdatedTxnsAllowedTill); + } + recalucateDailyBalanceDetails = true; + } else { + boolean correctionRequired = false; + if (postingTransaction.isInterestPostingAndNotReversed()) { + correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod); + } else { + correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated()); + } + if (correctionRequired && isInterestPostingCorrectionAllowed(postingTransaction)) { + boolean applyWithHoldTaxForOldTransaction = false; + postingTransaction.reverse(); + SavingsAccountTransaction reversal = null; + if (postReversals) { + reversal = SavingsAccountTransaction.reversal(postingTransaction); + } + final SavingsAccountTransaction withholdTransaction = findTransactionFor(interestPostingTransactionDate, + withholdTransactions); + if (withholdTransaction != null) { + withholdTransaction.reverse(); + applyWithHoldTaxForOldTransaction = true; + } + SavingsAccountTransaction newPostingTransaction; + if (interestEarnedToBePostedForPeriod.isGreaterThanOrEqualTo(Money.zero(currency))) { + newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), + interestPostingTransactionDate, interestEarnedToBePostedForPeriod, + interestPostingPeriod.isUserPosting()); + } else { + newPostingTransaction = SavingsAccountTransaction.overdraftInterest(this, office(), + interestPostingTransactionDate, interestEarnedToBePostedForPeriod.negated(), + interestPostingPeriod.isUserPosting()); + } + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(newPostingTransaction); + if (reversal != null) { + addTransactionToExisting(reversal); + } + } else { + addTransaction(newPostingTransaction); + if (reversal != null) { + addTransaction(reversal); + } + } + if (applyWithHoldTaxForOldTransaction) { + createWithHoldTransaction(interestEarnedToBePostedForPeriod.getAmount(), interestPostingTransactionDate, + backdatedTxnsAllowedTill); + } + recalucateDailyBalanceDetails = true; + } + } + } + } + + if (recalucateDailyBalanceDetails) { + // no openingBalance concept supported yet but probably will to + // allow + // for migrations. + Money openingAccountBalance = Money.zero(this.currency); + + if (backdatedTxnsAllowedTill) { + if (this.summary.getLastInterestCalculationDate() == null) { + openingAccountBalance = Money.zero(this.currency); + } else { + openingAccountBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } + } + + // update existing transactions so derived balance fields are + // correct. + recalculateDailyBalances(openingAccountBalance, interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); + } + + if (!backdatedTxnsAllowedTill) { + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } else { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, + this.savingsAccountTransactions); + } + } + + protected List findWithHoldTransactions() { + final List withholdTransactions = new ArrayList<>(); + List trans = getTransactions(); + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isWithHoldTaxAndNotReversed()) { + withholdTransactions.add(transaction); + } + } + return withholdTransactions; + } + + protected List findWithHoldSavingsTransactionsWithPivotConfig() { + final List withholdTransactions = new ArrayList<>(); + List trans = getSavingsAccountTransactionsWithPivotConfig(); + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isWithHoldTaxAndNotReversed() && !transaction.isReversalTransaction()) { + withholdTransactions.add(transaction); + } + } + return withholdTransactions; + } + + private boolean isWithHoldTaxApplicableForInterestPosting() { + return false; + } + + protected SavingsAccountTransaction findInterestPostingTransactionFor(final LocalDate postingDate) { + SavingsAccountTransaction postingTransation = null; + List trans = getTransactions(); + for (final SavingsAccountTransaction transaction : trans) { + if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) + && transaction.occursOn(postingDate) && !transaction.isReversalTransaction()) { + postingTransation = transaction; + break; + } + } + return postingTransation; + } + + protected SavingsAccountTransaction findInterestPostingSavingsTransactionWithPivotConfig(final LocalDate postingDate) { + SavingsAccountTransaction postingTransation = null; + List trans = getSavingsAccountTransactionsWithPivotConfig(); + for (final SavingsAccountTransaction transaction : trans) { + if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) + && transaction.occursOn(postingDate) && !transaction.isReversalTransaction()) { + postingTransation = transaction; + break; + } + } + return postingTransation; + } + + protected SavingsAccountTransaction findTransactionFor(final LocalDate postingDate, + final List transactions) { + SavingsAccountTransaction transaction = null; + for (final SavingsAccountTransaction savingsAccountTransaction : transactions) { + if (savingsAccountTransaction.occursOn(postingDate)) { + transaction = savingsAccountTransaction; + break; + } + } + return transaction; + } + + private Map calculateTaxSplit(final BigDecimal amount, final LocalDate date, final int scale) { + if (this.taxGroup == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + return new HashMap<>(); + } + ClientKyc kyc = this.client != null ? this.client.getClientKyc() : null; + + // 1. Form 15G/15H Waiver Check + boolean waiveTds = false; + if (kyc != null && Boolean.TRUE.equals(kyc.getForm15Applicable())) { + String fy = kyc.getForm15Fy(); + if (isDateInFinancialYear(date, fy)) { + waiveTds = true; + } + } + + if (waiveTds) { + return new HashMap<>(); + } + + // 2. Determine if 20% higher rate applies (missing/unlinked PAN) + boolean applyHigherTdsRate = false; + if (kyc != null) { + boolean hasVerifiedPan = kyc.isPanVerified() || Boolean.TRUE.equals(kyc.getPanDeclarationTaken()); + String seedingStatus = kyc.getPanAadhaarSeedingStatus(); + + if (!hasVerifiedPan || "N".equalsIgnoreCase(seedingStatus)) { + applyHigherTdsRate = true; + } + } else { + // Default to higher rate (20%) if no KYC record is found + applyHigherTdsRate = true; + } + + // 3. Split tax + if (applyHigherTdsRate) { + return TaxUtils.splitTaxWithOverride(amount, date, this.taxGroup.getTaxGroupMappings(), scale, new BigDecimal("20.00")); + } else { + return TaxUtils.splitTax(amount, date, this.taxGroup.getTaxGroupMappings(), scale); + } + } + + private boolean isDateInFinancialYear(final LocalDate date, final String fy) { + if (fy == null || !fy.matches("\\d{4}-\\d{2}")) { + return false; + } + try { + int startYear = Integer.parseInt(fy.substring(0, 4)); + LocalDate start = LocalDate.of(startYear, 4, 1); + LocalDate end = LocalDate.of(startYear + 1, 3, 31); + return !date.isBefore(start) && !date.isAfter(end); + } catch (Exception e) { + return false; + } + } + + protected boolean createWithHoldTransaction(final BigDecimal amount, final LocalDate date, final boolean backdatedTxnsAllowedTill) { + boolean isTaxAdded = false; + if (this.taxGroup != null && amount.compareTo(BigDecimal.ZERO) > 0) { + Map taxSplit = calculateTaxSplit(amount, date, amount.scale()); + BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit); + if (totalTax.compareTo(BigDecimal.ZERO) > 0) { + SavingsAccountTransaction withholdTransaction = SavingsAccountTransaction.withHoldTax(this, office(), date, + Money.of(currency, totalTax), taxSplit); + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(withholdTransaction); + } else { + addTransaction(withholdTransaction); + } + isTaxAdded = true; + } + } + return isTaxAdded; + } + + protected boolean updateWithHoldTransaction(final BigDecimal amount, final SavingsAccountTransaction withholdTransaction) { + boolean isTaxAdded = false; + if (this.taxGroup != null && amount.compareTo(BigDecimal.ZERO) > 0) { + Map taxSplit = calculateTaxSplit(amount, withholdTransaction.getTransactionDate(), amount.scale()); + BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit); + if (totalTax.compareTo(BigDecimal.ZERO) > 0) { + if (withholdTransaction.getId() == null) { + withholdTransaction.setAmount(Money.of(currency, totalTax)); + withholdTransaction.getTaxDetails().clear(); + SavingsAccountTransaction.updateTaxDetails(taxSplit, withholdTransaction); + isTaxAdded = true; + } else if (totalTax.compareTo(withholdTransaction.getAmount()) != 0) { + withholdTransaction.reverse(); + SavingsAccountTransaction newWithholdTransaction = SavingsAccountTransaction.withHoldTax(this, office(), + withholdTransaction.getTransactionDate(), Money.of(currency, totalTax), taxSplit); + addTransaction(newWithholdTransaction); + isTaxAdded = true; + } + } + } + return isTaxAdded; + } + + // Determine the last transaction for given day + protected SavingsAccountTransaction findLastTransaction(final LocalDate date) { + + SavingsAccountTransaction savingsTransaction = null; + List trans = getTransactions(); + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isNotReversed() && !transaction.isReversalTransaction() && transaction.occursOn(date)) { + savingsTransaction = transaction; + break; + } + } + + return savingsTransaction; + } + + /** + * The last non-reversed transaction dated strictly BEFORE {@code date}, or null when none exists. + *

+ * Distinct from {@link #findLastTransaction(LocalDate)}, which matches the date exactly. Used to seed the opening + * balance of an interest calculation that starts at a pivot date: a transaction dated ON that date falls inside the + * first posting period and is applied there, so seeding from it would count it twice, while requiring an exact match + * would leave the opening balance at zero on any pivot date that happens to carry no transaction. + *

+ * Relies on {@code transactions} being ordered ascending by date, as its {@code @OrderBy} declares, so the final + * match is the latest one. + */ + protected SavingsAccountTransaction findLastTransactionBefore(final LocalDate date) { + SavingsAccountTransaction candidate = null; + for (final SavingsAccountTransaction transaction : getTransactions()) { + if (transaction.isNotReversed() && !transaction.isReversalTransaction() + && DateUtils.isBefore(transaction.getTransactionDate(), date)) { + candidate = transaction; + } + } + return candidate; + } + + protected SavingsAccountTransaction findLastFilteredTransactionWithPivotConfig(final LocalDate date) { + SavingsAccountTransaction savingsTransaction = null; + List trans = getSavingsAccountTransactionsWithPivotConfig(); + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isNotReversed() && !transaction.isReversalTransaction() && transaction.occursOn(date)) { + savingsTransaction = transaction; + break; + } + } + return savingsTransaction; + } + + public List getManualPostingDates() { + List transactions = new ArrayList<>(); + for (SavingsAccountTransaction trans : this.transactions) { + if (trans.isInterestPosting() && trans.isNotReversed() && !trans.isReversalTransaction() && trans.isManualTransaction()) { + transactions.add(trans.getTransactionDate()); + } + } + return transactions; + } + + public List getManualPostingDatesWithPivotConfig() { + List transactions = new ArrayList<>(); + for (SavingsAccountTransaction trans : this.savingsAccountTransactions) { + if (trans.isInterestPosting() && trans.isNotReversed() && trans.isManualTransaction()) { + transactions.add(trans.getTransactionDate()); + } + } + return transactions; + } + + /** + * All interest calculation based on END-OF-DAY-BALANCE. + * + * Interest calculation is performed on-the-fly over all account transactions. + * + * + * 1. Calculate Interest From Beginning Of Account 1a. determine the 'crediting' periods that exist for this savings + * acccount 1b. determine the 'compounding' periods that exist within each 'crediting' period calculate the amount + * of interest due at the end of each 'crediting' period check if an existing 'interest posting' transaction exists + * for date and matches the amount posted + * + * @param isInterestTransfer + * TODO + */ + + public List calculateInterestUsing(final MathContext mc, final LocalDate upToInterestCalculationDate, + boolean isInterestTransfer, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, + final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { + + // no openingBalance concept supported yet but probably will to allow for migrations. + // Check global configurations and 'pivot' date is null + Money openingAccountBalance = backdatedTxnsAllowedTill ? Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()) + : Money.zero(this.currency); + + // update existing transactions so derived balance fields are correct. + recalculateDailyBalances(openingAccountBalance, upToInterestCalculationDate, backdatedTxnsAllowedTill, postReversals); + + final List allPostingPeriods = new ArrayList<>(); + if (hasInterestCalculation() || hasOverdraftInterestCalculation()) { + // 1. default to calculate interest based on entire history OR + // 2. determine latest 'posting period' and find interest credited to that period + + // A generate list of EndOfDayBalances (not including interest postings) + final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType + .fromInt(this.interestPostingPeriodType); + + final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType + .fromInt(this.interestCompoundingPeriodType); + + final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType + .fromInt(this.interestCalculationDaysInYearType); + List postedAsOnDates = null; + if (backdatedTxnsAllowedTill) { + postedAsOnDates = getManualPostingDatesWithPivotConfig(); + } else { + postedAsOnDates = getManualPostingDates(); + } + if (postInterestOnDate != null) { + postedAsOnDates.add(postInterestOnDate); + } + final List postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods( + getStartInterestCalculationDate(), upToInterestCalculationDate, postingPeriodType, financialYearBeginningMonth, + postedAsOnDates); + + Money periodStartingBalance; + if (this.startInterestCalculationDate != null && !this.getStartInterestCalculationDate().equals(this.getActivationDate())) { + LocalDate startInterestCalculationDate = this.startInterestCalculationDate; + SavingsAccountTransaction transaction = null; + if (backdatedTxnsAllowedTill) { + transaction = findLastFilteredTransactionWithPivotConfig(startInterestCalculationDate); + } else { + // Strictly before the pivot, not on it: anything dated on the pivot date belongs to the + // first posting period and is applied there, so seeding from it would apply it twice -- + // and an exact-date match would leave this at zero whenever the pivot date carries no + // transaction of its own, silently accruing the whole term on a zero balance. + transaction = findLastTransactionBefore(startInterestCalculationDate); + } + + if (transaction == null) { + periodStartingBalance = Money.zero(this.currency); + } else if (backdatedTxnsAllowedTill) { + periodStartingBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } else { + // summary.runningBalanceOnPivotDate is @Transient and only ever populated by the + // assembler on the backdated-transactions path, so reading it here returned zero and + // silently wiped the opening balance. The balance carried by the last transaction + // before the start date is the same figure, and is always present. + periodStartingBalance = transaction.getRunningBalance(this.currency); + } + } else { + periodStartingBalance = Money.zero(this.currency); + } + + final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType + .fromInt(this.interestCalculationType); + final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, upToInterestCalculationDate); + final BigDecimal overdraftInterestRateAsFraction = getEffectiveOverdraftInterestRateAsFraction(mc); + final Collection interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(getId()); + final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); + final Money minOverdraftForInterestCalculation = Money.of(getCurrency(), this.minOverdraftForInterestCalculation); + + for (final LocalDateInterval periodInterval : postingPeriodIntervals) { + + boolean isUserPosting = false; + if (postedAsOnDates.contains(periodInterval.endDate().plusDays(1))) { + isUserPosting = true; + } + + PostingPeriod postingPeriod = null; + List orderedNonInterestPostingTransactions = null; + if (backdatedTxnsAllowedTill) { + orderedNonInterestPostingTransactions = retreiveOrderedNonInterestPostingSavingsTransactionsWithPivotConfig(); + } else { + orderedNonInterestPostingTransactions = retreiveOrderedNonInterestPostingTransactions(); + } + + List savingsAccountTransactionDetailsForPostingPeriod = toSavingsAccountTransactionDetailsForPostingPeriodList( + orderedNonInterestPostingTransactions); + + postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance, + savingsAccountTransactionDetailsForPostingPeriod, this.currency, compoundingPeriodType, interestCalculationType, + interestRateAsFraction, daysInYearType.getValue(), upToInterestCalculationDate, interestPostTransactions, + isInterestTransfer, minBalanceForInterestCalculation, isSavingsInterestPostingAtCurrentPeriodEnd, + overdraftInterestRateAsFraction, minOverdraftForInterestCalculation, isUserPosting, financialYearBeginningMonth); + + periodStartingBalance = postingPeriod.closingBalance(); + + allPostingPeriods.add(postingPeriod); + } + + this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, getLockedInUntilDate(), + isTransferInterestToOtherAccount()); + + this.summary.updateFromInterestPeriodSummaries(this.currency, allPostingPeriods); + } + + if (backdatedTxnsAllowedTill) { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, + this.savingsAccountTransactions); + } else { + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } + + return allPostingPeriods; + } + + private BigDecimal getEffectiveOverdraftInterestRateAsFraction(MathContext mc) { + if (this.nominalAnnualInterestRateOverdraft == null) { + return BigDecimal.ZERO; + } + return this.nominalAnnualInterestRateOverdraft.divide(BigDecimal.valueOf(100L), mc); + } + + public BigDecimal getEffectiveInterestRateAsFractionAccrual(final MathContext mc, final LocalDate upToInterestCalculationDate) { + return this.nominalAnnualInterestRate.divide(BigDecimal.valueOf(100L), mc); + } + + @SuppressWarnings("unused") + protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate upToInterestCalculationDate) { + return this.nominalAnnualInterestRate.divide(BigDecimal.valueOf(100L), mc); + } + + private boolean hasInterestCalculation() { + return !MathUtil.isEmpty(nominalAnnualInterestRate); + } + + private boolean hasOverdraftInterestCalculation() { + return isAllowOverdraft() && !MathUtil.isEmpty(getOverdraftLimit()) && !MathUtil.isEmpty(nominalAnnualInterestRateOverdraft); + } + + public List retrieveOrderedAccrualTransactions() { + return retrieveListOfTransactions().stream().filter(SavingsAccountTransaction::isAccrual) + .sorted(new SavingsAccountTransactionComparator()).collect(Collectors.toList()); + } + + /** + * Transactions that should NOT count as part of this account's own balance history for interest + * calculation purposes -- excludes interest-posting credits (they're the thing being calculated, not an + * input to it) AND, for accounts that transfer their interest out to a linked account + * (isTransferInterestToOtherAccount(), e.g. FD Special), the paired WITHDRAWAL transaction that later + * sweeps that same interest back out (see TransferInterestToSavingsTasklet). + *

+ * Without also excluding that withdrawal, only the credit half of the round-trip gets stripped out here + * -- the debit half stays, permanently understating this account's balance (and therefore every + * subsequent period's interest) by that amount from the first transfer onward, even though the real + * ledger balance never actually changed. fetchPostInterestTransactionIds(getId()) returns exactly the + * withdrawal-transaction ids that are the FROM side of a real INTEREST_TRANSFER account transfer on this + * account, so excluding those ids here closes that gap. + */ + protected Collection interestTransferWithdrawalTransactionIds() { + if (getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount()) { + return Collections.emptyList(); + } + return this.savingsHelper.fetchPostInterestTransactionIds(getId()); + } + + /** + * The given transactions minus the interest-transfer withdrawal legs, exactly as + * {@link #retreiveOrderedNonInterestPostingTransactions()} removes them before the interest engine ever + * sees them. + *

+ * Those two sets MUST agree. {@link #resetAccountTransactionsEndOfDayBalances} derives each transaction's + * balance segment by walking backwards and ending the previous segment one day before the next + * transaction -- so a transaction present there but stripped out here truncates the preceding segment by a + * day, with nothing left in the engine's own list to account for that day. On an FD Special, whose + * transfer-out withdrawal always carries the same date as the interest posting it pays out, that silently + * cost the very first period one earning day. + */ + protected List excludeInterestTransferWithdrawals( + final List transactions) { + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); + if (interestTransferWithdrawalIds.isEmpty()) { + return transactions; + } + return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId())) + .collect(Collectors.toList()); + } + + protected List retreiveOrderedNonInterestPostingTransactions() { + final List listOfTransactionsSorted = retrieveListOfTransactions(); + + final List orderedNonInterestPostingTransactions = new ArrayList<>(); + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); + + for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { + if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) + && transaction.isNotReversed() && !transaction.isReversalTransaction() + && !interestTransferWithdrawalIds.contains(transaction.getId())) { + orderedNonInterestPostingTransactions.add(transaction); + } + } + orderedNonInterestPostingTransactions.sort(new SavingsAccountTransactionComparator()); + return orderedNonInterestPostingTransactions; + } + + protected List retreiveOrderedNonInterestPostingSavingsTransactionsWithPivotConfig() { + final List listOfTransactionsSorted = retrieveSortedTransactions(); + + final List orderedNonInterestPostingTransactions = new ArrayList<>(); + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); + + for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { + if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) + && transaction.isNotReversed() && !transaction.isReversalTransaction() + && !interestTransferWithdrawalIds.contains(transaction.getId())) { + orderedNonInterestPostingTransactions.add(transaction); + } + } + orderedNonInterestPostingTransactions.sort(new SavingsAccountTransactionComparator()); + return orderedNonInterestPostingTransactions; + } + + protected List retrieveSortedTransactions() { + final List listOfTransactionsSorted = new ArrayList<>(); + listOfTransactionsSorted.addAll(this.savingsAccountTransactions); + final SavingsAccountTransactionComparator transactionComparator = new SavingsAccountTransactionComparator(); + Collections.sort(listOfTransactionsSorted, transactionComparator); + return listOfTransactionsSorted; + } + + protected List retrieveListOfTransactions() { + final List listOfTransactionsSorted = new ArrayList<>(); + listOfTransactionsSorted.addAll(this.transactions); + + final SavingsAccountTransactionComparator transactionComparator = new SavingsAccountTransactionComparator(); + Collections.sort(listOfTransactionsSorted, transactionComparator); + return listOfTransactionsSorted; + } + + protected void recalculateDailyBalances(final Money openingAccountBalance, final LocalDate interestPostingUpToDate, + final boolean backdatedTxnsAllowedTill, boolean postReversals) { + Money runningBalance = openingAccountBalance; + boolean calculateInterest = hasInterestCalculation() || hasOverdraftInterestCalculation(); + + List accountTransactionsSorted = null; + + if (backdatedTxnsAllowedTill) { + accountTransactionsSorted = retrieveSortedTransactions(); + } else { + accountTransactionsSorted = retrieveListOfTransactions(); + } + + boolean isTransactionsModified = false; + for (final SavingsAccountTransaction transaction : accountTransactionsSorted) { + if (transaction.isReversed() || transaction.isReversalTransaction()) { + transaction.zeroBalanceFields(); + } else { + Money overdraftAmount = Money.zero(this.currency); + Money transactionAmount = Money.zero(this.currency); + if ((transaction.isCredit() || transaction.isAmountRelease())) { + if (runningBalance.isLessThanZero()) { + Money diffAmount = transaction.getAmount(this.currency).plus(runningBalance); + if (diffAmount.isGreaterThanZero()) { + overdraftAmount = transaction.getAmount(this.currency).minus(diffAmount); + } else { + overdraftAmount = transaction.getAmount(this.currency); + } + } + transactionAmount = transactionAmount.plus(transaction.getAmount(this.currency)); + } else if (transaction.isDebit() || transaction.isAmountOnHold()) { + if (runningBalance.isLessThanZero()) { + overdraftAmount = transaction.getAmount(this.currency); + } + transactionAmount = transactionAmount.minus(transaction.getAmount(this.currency)); + } + + runningBalance = runningBalance.plus(transactionAmount); + transaction.setRunningBalance(runningBalance); + + if (MathUtil.isEmpty(overdraftAmount) && runningBalance.isLessThanZero() && !transaction.isAmountOnHold()) { + overdraftAmount = runningBalance.negated(); + } + if (!calculateInterest || transaction.getId() == null || transaction.getOverdraftAmount(this.currency).isZero()) { + transaction.setOverdraftAmount(overdraftAmount); + } else if (!MathUtil.isEqualTo(overdraftAmount, transaction.getOverdraftAmount(this.currency)) + && !transaction.isAccrual()) { + SavingsAccountTransaction accountTransaction = SavingsAccountTransaction.copyTransaction(transaction); + if (transaction.isChargeTransaction()) { + Set chargesPaidBy = transaction.getSavingsAccountChargesPaid(); + final Set newChargePaidBy = new HashSet<>(); + chargesPaidBy.forEach(x -> newChargePaidBy + .add(SavingsAccountChargePaidBy.instance(accountTransaction, x.getSavingsAccountCharge(), x.getAmount()))); + accountTransaction.getSavingsAccountChargesPaid().addAll(newChargePaidBy); + } + SavingsAccountTransaction reversal = null; + transaction.reverse(); + if (postReversals) { + reversal = SavingsAccountTransaction.reversal(transaction); + } + if (MathUtil.isGreaterThanZero(overdraftAmount)) { + accountTransaction.setOverdraftAmount(overdraftAmount); + } + accountTransaction.setRunningBalance(runningBalance); + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(accountTransaction); + if (reversal != null) { + addTransactionToExisting(reversal); + } + } else { + addTransaction(accountTransaction); + if (reversal != null) { + addTransaction(reversal); + } + } + isTransactionsModified = true; + } + + } + } + + if (isTransactionsModified) { + if (backdatedTxnsAllowedTill) { + accountTransactionsSorted = retrieveSortedTransactions(); + } else { + accountTransactionsSorted = retrieveListOfTransactions(); + } + } + resetAccountTransactionsEndOfDayBalances(accountTransactionsSorted, interestPostingUpToDate); + } + + protected void resetAccountTransactionsEndOfDayBalances(final List accountTransactionsSorted, + final LocalDate interestPostingUpToDate) { + // loop over transactions in reverse + LocalDate endOfBalanceDate = interestPostingUpToDate; + for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) { + final SavingsAccountTransaction transaction = accountTransactionsSorted.get(i); + if (transaction.isNotReversed() && !transaction.isReversalTransaction() + && !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) { + transaction.updateCumulativeBalanceAndDates(this.currency, endOfBalanceDate); + // this transactions transaction date is end of balance date for + // previous transaction. + endOfBalanceDate = transaction.getTransactionDate().minusDays(1); + } + } + } + + public SavingsAccountTransaction deposit(final SavingsAccountTransactionDTO transactionDTO, final boolean backdatedTxnsAllowedTill, + final Long relaxingDaysConfigForPivotDate, final String refNo) { + return deposit(transactionDTO, SavingsAccountTransactionType.DEPOSIT, backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, + refNo); + } + + public SavingsAccountTransaction dividendPayout(final SavingsAccountTransactionDTO transactionDTO, + final boolean backdatedTxnsAllowedTill, final Long relaxingDaysConfigForPivotDate) { + String refNo = null; + return deposit(transactionDTO, SavingsAccountTransactionType.DIVIDEND_PAYOUT, backdatedTxnsAllowedTill, + relaxingDaysConfigForPivotDate, refNo); + } + + public SavingsAccountTransaction deposit(final SavingsAccountTransactionDTO transactionDTO, + final SavingsAccountTransactionType savingsAccountTransactionType, final boolean backdatedTxnsAllowedTill, + final Long relaxingDaysConfigForPivotDate, final String refNo) { + final String resourceTypeName = depositAccountType().resourceName(); + if (isNotActive()) { + final String defaultUserMessage = "Transaction is not allowed. Account is not active."; + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg." + resourceTypeName + ".transaction.account.is.not.active", defaultUserMessage, "transactionDate", + transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isDateInTheFuture(transactionDTO.getTransactionDate())) { + final String defaultUserMessage = "Transaction date cannot be in the future."; + final ApiParameterError error = ApiParameterError.parameterError("error.msg." + resourceTypeName + ".transaction.in.the.future", + defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isBefore(transactionDTO.getTransactionDate(), getActivationDate())) { + final Object[] defaultUserArgs = Arrays.asList(transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), + getActivationDate().format(transactionDTO.getFormatter())).toArray(); + final String defaultUserMessage = "Transaction date cannot be before accounts activation date."; + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg." + resourceTypeName + ".transaction.before.activation.date", defaultUserMessage, "transactionDate", + defaultUserArgs); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + validatePivotDateTransaction(transactionDTO.getTransactionDate(), backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, + resourceTypeName); + + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_DEPOSIT, transactionDTO.getTransactionDate()); + + final Money amount = Money.of(this.currency, transactionDTO.getTransactionAmount()); + + final SavingsAccountTransaction transaction = SavingsAccountTransaction.deposit(this, office(), transactionDTO.getPaymentDetail(), + transactionDTO.getTransactionDate(), amount, savingsAccountTransactionType, refNo); + + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(transaction); + } else { + this.accrualsForSavingsReverse(transactionDTO, backdatedTxnsAllowedTill); + addTransaction(transaction); + } + + if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue()) + || this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) { + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + } + + if (backdatedTxnsAllowedTill) { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, + this.savingsAccountTransactions); + } + + return transaction; + } + + public void validatePivotDateTransaction(LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, + final Long relaxingDaysConfigForPivotDate, final String resourceTypeName) { + if (backdatedTxnsAllowedTill) { + if (this.getSummary().getInterestPostedTillDate() != null && DateUtils.isBefore(transactionDate, + getSummary().getInterestPostedTillDate().minusDays(relaxingDaysConfigForPivotDate))) { + final Object[] defaultUserArgs = Arrays.asList(transactionDate, getActivationDate()).toArray(); + final String defaultUserMessage = "Transaction date cannot be before transactions pivot date."; + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg." + resourceTypeName + ".transaction.before.pivot.date", defaultUserMessage, "transactionDate", + defaultUserArgs); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + } + + public LocalDate getActivationDate() { + return this.activatedOnDate; + } + + public AppUser getActivatedBy() { + return this.activatedBy; + } + + public LocalDate getWithdrawnOnDate() { + return this.withdrawnOnDate; + } + + public AppUser getWithdrawnBy() { + return this.withdrawnBy; + } + + // startInterestCalculationDate is set during migration so that there is no + // interference with interest posting of previous system + public LocalDate getStartInterestCalculationDate() { + LocalDate startInterestCalculationLocalDate = null; + if (this.startInterestCalculationDate != null) { + startInterestCalculationLocalDate = this.startInterestCalculationDate; + } else { + startInterestCalculationLocalDate = getActivationDate(); + } + return startInterestCalculationLocalDate; + } + + public void setStartInterestCalculationDate(LocalDate startInterestCalculationDate) { + this.startInterestCalculationDate = startInterestCalculationDate; + } + + public SavingsAccountTransaction withdraw(final SavingsAccountTransactionDTO transactionDTO, final boolean applyWithdrawFee, + final boolean backdatedTxnsAllowedTill, final Long relaxingDaysConfigForPivotDate, String refNo) { + if (!isTransactionsAllowed()) { + + final String defaultUserMessage = "Transaction is not allowed. Account is not active."; + final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.account.is.not.active", + defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isDateInTheFuture(transactionDTO.getTransactionDate())) { + final String defaultUserMessage = "Transaction date cannot be in the future."; + final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.in.the.future", + defaultUserMessage, "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isBefore(transactionDTO.getTransactionDate(), getActivationDate())) { + final Object[] defaultUserArgs = Arrays.asList(transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), + getActivationDate().format(transactionDTO.getFormatter())).toArray(); + final String defaultUserMessage = "Transaction date cannot be before accounts activation date."; + final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.before.activation.date", + defaultUserMessage, "transactionDate", defaultUserArgs); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (isAccountLocked(transactionDTO.getTransactionDate())) { + final String defaultUserMessage = "Withdrawal is not allowed. No withdrawals are allowed until after " + + getLockedInUntilDate().format(transactionDTO.getFormatter()); + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg.savingsaccount.transaction.withdrawals.blocked.during.lockin.period", defaultUserMessage, "transactionDate", + transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()), + getLockedInUntilDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + validatePivotDateTransaction(transactionDTO.getTransactionDate(), backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, + "savingsaccount"); + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_WITHDRAWAL, transactionDTO.getTransactionDate()); + + if (applyWithdrawFee) { + // auto pay withdrawal fee + payWithdrawalFee(transactionDTO.getTransactionAmount(), transactionDTO.getTransactionDate(), transactionDTO.getPaymentDetail(), + backdatedTxnsAllowedTill, refNo); + } + + final Money transactionAmountMoney = Money.of(this.currency, transactionDTO.getTransactionAmount()); + final SavingsAccountTransaction transaction = SavingsAccountTransaction.withdrawal(this, office(), + transactionDTO.getPaymentDetail(), transactionDTO.getTransactionDate(), transactionAmountMoney, refNo); + + if (backdatedTxnsAllowedTill) { + addTransactionToExisting(transaction); + } else { + this.accrualsForSavingsReverse(transactionDTO, backdatedTxnsAllowedTill); + addTransaction(transaction); + } + + if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue()) + || this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) { + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + } + if (backdatedTxnsAllowedTill) { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, + this.savingsAccountTransactions); + } + return transaction; + } + + public BigDecimal calculateWithdrawalFee(final BigDecimal transactionAmount) { + BigDecimal result = BigDecimal.ZERO; + if (isWithdrawalFeeApplicableForTransfer()) { + for (SavingsAccountCharge charge : this.charges()) { + if (charge.isWithdrawalFee() && charge.isActive()) { + result = result.add(charge.calculateWithdralFeeAmount(transactionAmount), MoneyHelper.getMathContext()); + } + } + } + return result; + } + + private void payWithdrawalFee(final BigDecimal transactionAmount, final LocalDate transactionDate, final PaymentDetail paymentDetail, + final boolean backdatedTxnsAllowedTill, final String refNo) { + for (SavingsAccountCharge charge : this.charges()) { + if (charge.isWithdrawalFee() && charge.isActive()) { + + if (charge.getFreeWithdrawalCount() == null) { + charge.setFreeWithdrawalCount(0); + } + + if (charge.isEnablePaymentType() && charge.isEnableFreeWithdrawal()) { // discount transaction to + // specific paymentType + if (paymentDetail != null && paymentDetail.getPaymentType() != null + && paymentDetail.getPaymentType().getName().equals(charge.getCharge().getPaymentType().getName())) { + resetFreeChargeDaysCount(charge, transactionAmount, transactionDate, refNo); + } + } else if (charge.isEnablePaymentType()) { // normal charge-transaction to specific paymentType + if (paymentDetail != null && paymentDetail.getPaymentType() != null + && paymentDetail.getPaymentType().getName().equals(charge.getCharge().getPaymentType().getName())) { + charge.updateWithdralFeeAmount(transactionAmount); + this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, + refNo); + } + } else if (!charge.isEnablePaymentType() && charge.isEnableFreeWithdrawal()) { // discount transaction + // irrespective of + // PaymentTypes. + resetFreeChargeDaysCount(charge, transactionAmount, transactionDate, refNo); + + } else { // normal-withdraw + charge.updateWithdralFeeAmount(transactionAmount); + this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, + refNo); + } + } + } + } + + private void resetFreeChargeDaysCount(SavingsAccountCharge charge, final BigDecimal transactionAmount, final LocalDate transactionDate, + final String refNo) { + LocalDate resetDate = charge.getResetChargeDate(); + + Integer restartPeriod = charge.getRestartFrequency(); + if (charge.getRestartFrequencyEnum() == 2) { // calculation for months + LocalDate localDate = DateUtils.getBusinessLocalDate(); + + LocalDate resetLocalDate; + if (resetDate == null) { + resetLocalDate = this.activatedOnDate; + } else { + resetLocalDate = resetDate; + } + + LocalDate gapIntervalMonth = resetLocalDate.plusMonths(restartPeriod); + + YearMonth gapYearMonth = YearMonth.from(gapIntervalMonth); + YearMonth localYearMonth = YearMonth.from(localDate); + if (localYearMonth.isBefore(gapYearMonth)) { + countValidation(charge, transactionAmount, transactionDate, refNo); + } else { + discountCharge(1, charge); + } + } else { // calculation for days + long completedDays; + + if (resetDate == null) { + completedDays = DAYS.between(DateUtils.getBusinessLocalDate(), this.activatedOnDate); + + } else { + completedDays = DAYS.between(DateUtils.getBusinessLocalDate(), resetDate); + } + + int totalDays = (int) completedDays; + + if (totalDays < restartPeriod) { + countValidation(charge, transactionAmount, transactionDate, refNo); + } else { + discountCharge(1, charge); + } + } + } + + private void countValidation(SavingsAccountCharge charge, final BigDecimal transactionAmount, final LocalDate transactionDate, + final String refNo) { + boolean backdatedTxnsAllowedTill = false; + if (charge.getFreeWithdrawalCount() < charge.getFrequencyFreeWithdrawalCharge()) { + final Integer count = charge.getFreeWithdrawalCount() + 1; + charge.setFreeWithdrawalCount(count); + charge.updateNoWithdrawalFee(); + } else { + charge.updateWithdralFeeAmount(transactionAmount); + this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, refNo); + } + } + + private void discountCharge(Integer freeWithdrawalCount, SavingsAccountCharge charge) { + charge.setFreeWithdrawalCount(freeWithdrawalCount); + charge.setDiscountDueDate(DateUtils.getBusinessLocalDate()); + charge.updateNoWithdrawalFee(); + } + + public boolean isBeforeLastPostingPeriod(final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill) { + + boolean transactionBeforeLastInterestPosting = false; + + if (!backdatedTxnsAllowedTill) { + for (final SavingsAccountTransaction transaction : retrieveListOfTransactions()) { + if ((transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) + && transaction.isAfter(transactionDate) && !transaction.isReversalTransaction()) { + transactionBeforeLastInterestPosting = true; + break; + } + } + } else { + if (this.summary.getInterestPostedTillDate() == null) { + return false; + } + transactionBeforeLastInterestPosting = DateUtils.isBefore(transactionDate, this.summary.getInterestPostedTillDate()); + } + + return transactionBeforeLastInterestPosting; + } + + public void validateAccountBalanceDoesNotBecomeNegative(final BigDecimal transactionAmount, final boolean isException, + final List depositAccountOnHoldTransactions, final boolean backdatedTxnsAllowedTill) { + + List transactionsSortedByDate = null; + + if (backdatedTxnsAllowedTill) { + transactionsSortedByDate = retrieveSortedTransactions(); + } else { + transactionsSortedByDate = retrieveListOfTransactions(); + } + + Money runningBalance = Money.zero(this.currency); + if (backdatedTxnsAllowedTill) { + runningBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } + + Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); + LocalDate lastSavingsDate = null; + final BigDecimal withdrawalFee = null; + for (final SavingsAccountTransaction transaction : transactionsSortedByDate) { + if (transaction.isNotReversed() && transaction.isCredit() && !transaction.isReversalTransaction()) { + runningBalance = runningBalance.plus(transaction.getAmount(this.currency)); + } else if (transaction.isNotReversed() && transaction.isDebit() && !transaction.isReversalTransaction()) { + runningBalance = runningBalance.minus(transaction.getAmount(this.currency)); + } else { + continue; + } + + /* + * Loop through the onHold funds and see if we need to deduct or add to minimum required balance and the + * point in time the transaction was made: + */ + if (depositAccountOnHoldTransactions != null) { + for (final DepositAccountOnHoldTransaction onHoldTransaction : depositAccountOnHoldTransactions) { + // Compare the balance of the on hold: + if (!DateUtils.isAfter(onHoldTransaction.getTransactionDate(), transaction.getTransactionDate()) + && (lastSavingsDate == null || DateUtils.isAfter(onHoldTransaction.getTransactionDate(), lastSavingsDate))) { + if (onHoldTransaction.getTransactionType().isHold()) { + minRequiredBalance = minRequiredBalance.plus(onHoldTransaction.getAmount(this.currency)); + } else { + minRequiredBalance = minRequiredBalance.minus(onHoldTransaction.getAmount(this.currency)); + } + } + } + } + + // deal with potential minRequiredBalance and + // enforceMinRequiredBalance + if (!isException && transaction.canProcessBalanceCheck() && !isOverdraft()) { + if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, + transactionAmount); + } + } + lastSavingsDate = transaction.getTransactionDate(); + + } + + // In overdraft cases, minRequiredBalance can be in violation after + // interest posting + // and should be checked after processing all transactions + if (isOverdraft()) { + if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); + } + } + + if (this.getSavingsHoldAmount().compareTo(BigDecimal.ZERO) > 0) { + if (this.enforceMinRequiredBalance) { + if (runningBalance.minus(minRequiredBalance.plus(this.getSavingsHoldAmount())).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, + transactionAmount); + } + } else { + if (runningBalance.minus(this.getSavingsHoldAmount()).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, + transactionAmount); + } + } + } + } + + public void validateAccountBalanceDoesNotBecomeNegative(final String transactionAction, + final List depositAccountOnHoldTransactions, final boolean backdatedTxnsAllowedTill) { + + List transactionsSortedByDate = null; + BigDecimal transactionAmount = null; + if (backdatedTxnsAllowedTill) { + transactionsSortedByDate = retrieveSortedTransactions(); + } else { + transactionsSortedByDate = retrieveListOfTransactions(); + } + Money runningBalance = Money.zero(this.currency); + + if (backdatedTxnsAllowedTill) { + runningBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } + + Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); + LocalDate lastSavingsDate = null; + for (final SavingsAccountTransaction transaction : transactionsSortedByDate) { + + transactionAmount = transaction.getAmount(); + + if (transaction.isNotReversed() && transaction.isCredit()) { + runningBalance = runningBalance.plus(transaction.getAmount(this.currency)); + } else if (transaction.isNotReversed() && transaction.isDebit()) { + runningBalance = runningBalance.minus(transaction.getAmount(this.currency)); + } + + /* + * Loop through the onHold funds and see if we need to deduct or add to minimum required balance and the + * point in time the transaction was made: + */ + if (depositAccountOnHoldTransactions != null) { + for (final DepositAccountOnHoldTransaction onHoldTransaction : depositAccountOnHoldTransactions) { + // Compare the balance of the on hold: + if (!DateUtils.isAfter(onHoldTransaction.getTransactionDate(), transaction.getTransactionDate()) + && (lastSavingsDate == null || DateUtils.isAfter(onHoldTransaction.getTransactionDate(), lastSavingsDate))) { + if (onHoldTransaction.getTransactionType().isHold()) { + minRequiredBalance = minRequiredBalance.plus(onHoldTransaction.getAmount(this.currency)); + } else { + minRequiredBalance = minRequiredBalance.minus(onHoldTransaction.getAmount(this.currency)); + } + } + } + } + + // enforceMinRequiredBalance + if (transaction.canProcessBalanceCheck()) { + if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + transactionAction); + if (!this.allowOverdraft) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("results.in.balance.going.negative"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + } + lastSavingsDate = transaction.getTransactionDate(); + } + + BigDecimal withdrawalFee = null; + if (isOverdraft()) { + if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); + } + } + } + + public void validateAccountBalanceDoesNotViolateOverdraft(final List savingsAccountTransaction, + final BigDecimal amountPaid) { + if (savingsAccountTransaction != null && savingsAccountTransaction.size() > 0) { + SavingsAccountTransaction savingsAccountTransactionFirst = savingsAccountTransaction.get(0); + if (!this.allowOverdraft) { + if (savingsAccountTransactionFirst.getRunningBalance(this.currency).minus(amountPaid).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), null, amountPaid); + } + } + + } else { + if (!this.allowOverdraft) { + throw new InsufficientAccountBalanceException("transactionAmount", BigDecimal.ZERO, null, amountPaid); + } + } + } + + protected boolean isAccountLocked(final LocalDate transactionDate) { + return DateUtils.isBefore(transactionDate, getLockedInUntilDate()); + } + + public LocalDate getLockedInUntilDate() { + return this.lockedInUntilDate; + } + + public BigDecimal getAccountBalance() { + return this.summary.getAccountBalance(this.currency).getAmount(); + } + + public void modifyApplication(final JsonCommand command, final Map actualChanges) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.modifyApplicationAction); + this.modifyApplication(command, actualChanges, baseDataValidator); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + public void modifyApplication(final JsonCommand command, final Map actualChanges, + final DataValidatorBuilder baseDataValidator) { + + final SavingsAccountStatusType currentStatus = SavingsAccountStatusType.fromInt(this.status); + final boolean isAllowedState = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus) + || SavingsAccountStatusType.REJECTED.hasStateOf(currentStatus); + if (!isAllowedState) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); + return; + } + // If modifying a rejected application, reset it back to pending approval + if (SavingsAccountStatusType.REJECTED.hasStateOf(currentStatus)) { + this.status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue(); + this.rejectedOnDate = null; + this.rejectedBy = null; + actualChanges.put("status", SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue()); + } + + final String localeAsInput = command.locale(); + final String dateFormat = command.dateFormat(); + + if (command.isChangeInLocalDateParameterNamed(SavingsApiConstants.submittedOnDateParamName, getSubmittedOnDate())) { + final String newValueAsString = command.stringValueOfParameterNamed(SavingsApiConstants.submittedOnDateParamName); + actualChanges.put(SavingsApiConstants.submittedOnDateParamName, newValueAsString); + actualChanges.put(SavingsApiConstants.localeParamName, localeAsInput); + actualChanges.put(SavingsApiConstants.dateFormatParamName, dateFormat); + this.submittedOnDate = command.localDateValueOfParameterNamed(SavingsApiConstants.submittedOnDateParamName); + } + if (command.isChangeInStringParameterNamed(SavingsApiConstants.accountNoParamName, this.accountNumber)) { + final String newValue = command.stringValueOfParameterNamed(SavingsApiConstants.accountNoParamName); + actualChanges.put(SavingsApiConstants.accountNoParamName, newValue); + this.accountNumber = StringUtils.defaultIfEmpty(newValue, null); + } + if (command.isChangeInStringParameterNamed(SavingsApiConstants.externalIdParamName, this.externalId.getValue())) { + final String newValue = command.stringValueOfParameterNamed(SavingsApiConstants.externalIdParamName); + actualChanges.put(SavingsApiConstants.externalIdParamName, newValue); + this.externalId = ExternalIdFactory.produce(newValue); + if (this.externalId.isEmpty() && TemporaryConfigurationServiceContainer.isExternalIdAutoGenerationEnabled()) { + this.externalId = ExternalId.generate(); + } + } + if (command.isChangeInLongParameterNamed(SavingsApiConstants.clientIdParamName, clientId())) { + final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.clientIdParamName); + actualChanges.put(SavingsApiConstants.clientIdParamName, newValue); + } + if (command.isChangeInLongParameterNamed(SavingsApiConstants.groupIdParamName, groupId())) { + final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.groupIdParamName); + actualChanges.put(SavingsApiConstants.groupIdParamName, newValue); + } + if (command.isChangeInLongParameterNamed(SavingsApiConstants.productIdParamName, this.product.getId())) { + final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.productIdParamName); + actualChanges.put(SavingsApiConstants.productIdParamName, newValue); + } + if (command.isChangeInLongParameterNamed(SavingsApiConstants.fieldOfficerIdParamName, hasSavingsOfficerId())) { + final Long newValue = command.longValueOfParameterNamed(SavingsApiConstants.fieldOfficerIdParamName); + actualChanges.put(SavingsApiConstants.fieldOfficerIdParamName, newValue); + } + if (command.isChangeInBigDecimalParameterNamed(SavingsApiConstants.nominalAnnualInterestRateParamName, + this.nominalAnnualInterestRate)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(SavingsApiConstants.nominalAnnualInterestRateParamName); + actualChanges.put(SavingsApiConstants.nominalAnnualInterestRateParamName, newValue); + this.nominalAnnualInterestRate = newValue; + } + if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCompoundingPeriodTypeParamName, + this.interestCompoundingPeriodType)) { + final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCompoundingPeriodTypeParamName); + this.interestCompoundingPeriodType = newValue != null ? SavingsCompoundingInterestPeriodType.fromInt(newValue).getValue() + : newValue; + actualChanges.put(SavingsApiConstants.interestCompoundingPeriodTypeParamName, this.interestCompoundingPeriodType); + } + if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestPostingPeriodTypeParamName, + this.interestPostingPeriodType)) { + final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestPostingPeriodTypeParamName); + this.interestPostingPeriodType = newValue != null ? SavingsPostingInterestPeriodType.fromInt(newValue).getValue() : newValue; + actualChanges.put(SavingsApiConstants.interestPostingPeriodTypeParamName, this.interestPostingPeriodType); + } + if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCalculationTypeParamName, this.interestCalculationType)) { + final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCalculationTypeParamName); + this.interestCalculationType = newValue != null ? SavingsInterestCalculationType.fromInt(newValue).getValue() : newValue; + actualChanges.put(SavingsApiConstants.interestCalculationTypeParamName, this.interestCalculationType); + } + if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.interestCalculationDaysInYearTypeParamName, + this.interestCalculationDaysInYearType)) { + final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.interestCalculationDaysInYearTypeParamName); + this.interestCalculationDaysInYearType = newValue != null + ? SavingsInterestCalculationDaysInYearType.fromInt(newValue).getValue() + : newValue; + actualChanges.put(SavingsApiConstants.interestCalculationDaysInYearTypeParamName, this.interestCalculationDaysInYearType); + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(SavingsApiConstants.minRequiredOpeningBalanceParamName, + this.minRequiredOpeningBalance)) { + final BigDecimal newValue = command + .bigDecimalValueOfParameterNamedDefaultToNullIfZero(SavingsApiConstants.minRequiredOpeningBalanceParamName); + actualChanges.put(SavingsApiConstants.minRequiredOpeningBalanceParamName, newValue); + actualChanges.put("locale", localeAsInput); + this.minRequiredOpeningBalance = Money.of(this.currency, newValue).getAmount(); + } + if (command.isChangeInIntegerParameterNamedDefaultingZeroToNull(SavingsApiConstants.lockinPeriodFrequencyParamName, + this.lockinPeriodFrequency)) { + final Integer newValue = command + .integerValueOfParameterNamedDefaultToNullIfZero(SavingsApiConstants.lockinPeriodFrequencyParamName); + actualChanges.put(SavingsApiConstants.lockinPeriodFrequencyParamName, newValue); + actualChanges.put("locale", localeAsInput); + this.lockinPeriodFrequency = newValue; + } + + if (command.isChangeInIntegerParameterNamed(SavingsApiConstants.lockinPeriodFrequencyTypeParamName, + this.lockinPeriodFrequencyType)) { + final Integer newValue = command.integerValueOfParameterNamed(SavingsApiConstants.lockinPeriodFrequencyTypeParamName); + actualChanges.put(SavingsApiConstants.lockinPeriodFrequencyTypeParamName, newValue); + this.lockinPeriodFrequencyType = newValue != null ? SavingsPeriodFrequencyType.fromInt(newValue).getValue() : newValue; + } + + // set period type to null if frequency is null + if (this.lockinPeriodFrequency == null) { + this.lockinPeriodFrequencyType = null; + } + + if (command.isChangeInBooleanParameterNamed(withdrawalFeeForTransfersParamName, this.withdrawalFeeApplicableForTransfer)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(withdrawalFeeForTransfersParamName); + actualChanges.put(withdrawalFeeForTransfersParamName, newValue); + this.withdrawalFeeApplicableForTransfer = newValue; + } + + // charges + final String chargesParamName = "charges"; + if (command.hasParameter(chargesParamName)) { + final JsonArray jsonArray = command.arrayOfParameterNamed(chargesParamName); + if (jsonArray != null) { + actualChanges.put(chargesParamName, command.jsonFragment(chargesParamName)); + } + } + + if (command.isChangeInBooleanParameterNamed(allowOverdraftParamName, this.allowOverdraft)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(allowOverdraftParamName); + actualChanges.put(allowOverdraftParamName, newValue); + this.allowOverdraft = newValue; + } + + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(overdraftLimitParamName, this.overdraftLimit)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(overdraftLimitParamName); + actualChanges.put(overdraftLimitParamName, newValue); + actualChanges.put(localeParamName, localeAsInput); + this.overdraftLimit = newValue; + } + + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(nominalAnnualInterestRateOverdraftParamName, + this.nominalAnnualInterestRateOverdraft)) { + final BigDecimal newValue = command + .bigDecimalValueOfParameterNamedDefaultToNullIfZero(nominalAnnualInterestRateOverdraftParamName); + actualChanges.put(nominalAnnualInterestRateOverdraftParamName, newValue); + actualChanges.put(localeParamName, localeAsInput); + this.nominalAnnualInterestRateOverdraft = newValue; + } + + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(minOverdraftForInterestCalculationParamName, + this.minOverdraftForInterestCalculation)) { + final BigDecimal newValue = command + .bigDecimalValueOfParameterNamedDefaultToNullIfZero(minOverdraftForInterestCalculationParamName); + actualChanges.put(minOverdraftForInterestCalculationParamName, newValue); + actualChanges.put(localeParamName, localeAsInput); + this.minOverdraftForInterestCalculation = newValue; + } + + if (!this.allowOverdraft) { + this.overdraftLimit = null; + this.nominalAnnualInterestRateOverdraft = null; + this.minOverdraftForInterestCalculation = null; + } + + if (command.isChangeInBooleanParameterNamed(enforceMinRequiredBalanceParamName, this.enforceMinRequiredBalance)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(enforceMinRequiredBalanceParamName); + actualChanges.put(enforceMinRequiredBalanceParamName, newValue); + this.enforceMinRequiredBalance = newValue; + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(minRequiredBalanceParamName, this.minRequiredBalance)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(minRequiredBalanceParamName); + actualChanges.put(minRequiredBalanceParamName, newValue); + actualChanges.put(localeParamName, localeAsInput); + this.minRequiredBalance = newValue; + } + if (command.isChangeInBooleanParameterNamed(lienAllowedParamName, this.lienAllowed)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(lienAllowedParamName); + actualChanges.put(lienAllowedParamName, newValue); + this.lienAllowed = newValue; + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(maxAllowedLienLimitParamName, this.maxAllowedLienLimit)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(maxAllowedLienLimitParamName); + actualChanges.put(maxAllowedLienLimitParamName, newValue); + actualChanges.put(localeParamName, localeAsInput); + this.maxAllowedLienLimit = newValue; + } + if (command.isChangeInBooleanParameterNamed(withHoldTaxParamName, this.withHoldTax)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName); + actualChanges.put(withHoldTaxParamName, newValue); + this.withHoldTax = newValue; + if (this.withHoldTax && this.taxGroup == null) { + baseDataValidator.reset().parameter(withHoldTaxParamName).failWithCode("not.supported.for.this.account"); + } + } + + if (command.isChangeInBooleanParameterNamed(introducerRequiredParamName, this.introducerRequired)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(introducerRequiredParamName); + actualChanges.put(introducerRequiredParamName, newValue); + this.introducerRequired = newValue; + } + + if (command.isChangeInBooleanParameterNamed(shareLinkingEnabledParamName, this.shareLinkingEnabled)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(shareLinkingEnabledParamName); + actualChanges.put(shareLinkingEnabledParamName, newValue); + this.shareLinkingEnabled = newValue; + } + + // CBS extended fields mapping during modify application + final String accountTypeParam = "accountType"; + if (command.isChangeInStringParameterNamed(accountTypeParam, this.cbsAccountType)) { + final String newValue = command.stringValueOfParameterNamed(accountTypeParam); + actualChanges.put(accountTypeParam, newValue); + this.cbsAccountType = newValue; + } + + final String customerSegmentParam = "customerSegment"; + if (command.isChangeInStringParameterNamed(customerSegmentParam, this.customerSegment)) { + final String newValue = command.stringValueOfParameterNamed(customerSegmentParam); + actualChanges.put(customerSegmentParam, newValue); + this.customerSegment = newValue; + } + + final String accountStatusParam = "accountStatus"; + if (command.isChangeInStringParameterNamed(accountStatusParam, this.accountStatus)) { + final String newValue = command.stringValueOfParameterNamed(accountStatusParam); + actualChanges.put(accountStatusParam, newValue); + this.accountStatus = newValue; + } + + final String modeOfOperationParam = "modeOfOperation"; + if (command.isChangeInStringParameterNamed(modeOfOperationParam, this.modeOfOperation)) { + final String newValue = command.stringValueOfParameterNamed(modeOfOperationParam); + actualChanges.put(modeOfOperationParam, newValue); + this.modeOfOperation = newValue; + } + + final String specialCategoryParam = "specialCategory"; + if (command.isChangeInStringParameterNamed(specialCategoryParam, this.specialCategory)) { + final String newValue = command.stringValueOfParameterNamed(specialCategoryParam); + actualChanges.put(specialCategoryParam, newValue); + this.specialCategory = newValue; + } + + final String weakerSectionParam = "weakerSection"; + if (command.isChangeInStringParameterNamed(weakerSectionParam, this.weakerSection)) { + final String newValue = command.stringValueOfParameterNamed(weakerSectionParam); + actualChanges.put(weakerSectionParam, newValue); + this.weakerSection = newValue; + } + + final String nominationFacilityParam = "nominationFacility"; + if (command.isChangeInBooleanParameterNamed(nominationFacilityParam, this.nominationFacility)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(nominationFacilityParam); + actualChanges.put(nominationFacilityParam, newValue); + this.nominationFacility = newValue; + } + + final String autoDebitFacilityParam = "autoDebitFacility"; + if (command.isChangeInBooleanParameterNamed(autoDebitFacilityParam, this.autoDebitFacility)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(autoDebitFacilityParam); + actualChanges.put(autoDebitFacilityParam, newValue); + this.autoDebitFacility = newValue; + } + + final String isSeniorCitizenParam = "isSeniorCitizen"; + if (command.isChangeInBooleanParameterNamed(isSeniorCitizenParam, this.isSeniorCitizen)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(isSeniorCitizenParam); + actualChanges.put(isSeniorCitizenParam, newValue); + this.isSeniorCitizen = newValue; + } + + final String seniorCitizenInterestRateParam = "seniorCitizenInterestRate"; + if (command.isChangeInBigDecimalParameterNamed(seniorCitizenInterestRateParam, this.seniorCitizenInterestRate)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(seniorCitizenInterestRateParam); + actualChanges.put(seniorCitizenInterestRateParam, newValue); + this.seniorCitizenInterestRate = newValue; + if (newValue != null && newValue.compareTo(BigDecimal.ZERO) > 0) { + updateNominalAnnualInterestRate(newValue); + } + } + + final String guardianNameParam = "guardianName"; + if (command.isChangeInStringParameterNamed(guardianNameParam, this.guardianName)) { + final String newValue = command.stringValueOfParameterNamed(guardianNameParam); + actualChanges.put(guardianNameParam, newValue); + this.guardianName = newValue; + } + + final String guardianCifNumberParam = "guardianCifNumber"; + if (command.isChangeInStringParameterNamed(guardianCifNumberParam, this.guardianCifNumber)) { + final String newValue = command.stringValueOfParameterNamed(guardianCifNumberParam); + actualChanges.put(guardianCifNumberParam, newValue); + this.guardianCifNumber = newValue; + } + + final String guardianRelationshipParam = "guardianRelationship"; + if (command.isChangeInStringParameterNamed(guardianRelationshipParam, this.guardianRelationship)) { + final String newValue = command.stringValueOfParameterNamed(guardianRelationshipParam); + actualChanges.put(guardianRelationshipParam, newValue); + this.guardianRelationship = newValue; + } + + final String minorDateOfBirthParam = "minorDateOfBirth"; + if (command.isChangeInLocalDateParameterNamed(minorDateOfBirthParam, this.minorDateOfBirth)) { + final LocalDate newValue = command.localDateValueOfParameterNamed(minorDateOfBirthParam); + actualChanges.put(minorDateOfBirthParam, newValue); + this.minorDateOfBirth = newValue; + } + + final String guardianClientIdParam = "guardianClientId"; + if (command.isChangeInLongParameterNamed(guardianClientIdParam, this.guardianClientId)) { + final Long newValue = command.longValueOfParameterNamed(guardianClientIdParam); + actualChanges.put(guardianClientIdParam, newValue); + this.guardianClientId = newValue; + } + + final String introducerIdParam = "introducerId"; + if (command.isChangeInLongParameterNamed(introducerIdParam, this.introducerClientId)) { + final Long newValue = command.longValueOfParameterNamed(introducerIdParam); + actualChanges.put(introducerIdParam, newValue); + this.introducerClientId = newValue; + } + + final String introducerNameParam = "introducerName"; + if (command.isChangeInStringParameterNamed(introducerNameParam, this.introducerName)) { + final String newValue = command.stringValueOfParameterNamed(introducerNameParam); + actualChanges.put(introducerNameParam, newValue); + this.introducerName = newValue; + } + + final String introducerCifNumberParam = "introducerCifNumber"; + if (command.isChangeInStringParameterNamed(introducerCifNumberParam, this.introducerCifNumber)) { + final String newValue = command.stringValueOfParameterNamed(introducerCifNumberParam); + actualChanges.put(introducerCifNumberParam, newValue); + this.introducerCifNumber = newValue; + } + + final String linkedAccountIdParam = "linkedAccountId"; + if (command.isChangeInLongParameterNamed(linkedAccountIdParam, this.linkedAccountId)) { + final Long newValue = command.longValueOfParameterNamed(linkedAccountIdParam); + actualChanges.put(linkedAccountIdParam, newValue); + this.linkedAccountId = newValue; + } + + final String linkedAccountTypeParam = "linkedAccountType"; + if (command.isChangeInStringParameterNamed(linkedAccountTypeParam, this.linkedAccountType)) { + final String newValue = command.stringValueOfParameterNamed(linkedAccountTypeParam); + actualChanges.put(linkedAccountTypeParam, newValue); + this.linkedAccountType = newValue; + } + + final String linkedAccountNoParam = "linkedAccountNo"; + if (command.isChangeInStringParameterNamed(linkedAccountNoParam, this.linkedAccountNo)) { + final String newValue = command.stringValueOfParameterNamed(linkedAccountNoParam); + actualChanges.put(linkedAccountNoParam, newValue); + this.linkedAccountNo = newValue; + } + + final String accountFacilityConfigParam = "accountFacilityConfig"; + if (command.hasParameter(accountFacilityConfigParam)) { + final com.google.gson.JsonElement parsedJson = command.parsedJson(); + if (parsedJson != null && parsedJson.isJsonObject()) { + final com.google.gson.JsonObject jsonObj = parsedJson.getAsJsonObject(); + if (jsonObj.has(accountFacilityConfigParam) && !jsonObj.get(accountFacilityConfigParam).isJsonNull() + && jsonObj.get(accountFacilityConfigParam).isJsonObject()) { + final com.google.gson.JsonObject configObj = jsonObj.get(accountFacilityConfigParam).getAsJsonObject(); + if (this.facilityConfig == null) { + this.facilityConfig = new SavingsAccountFacilityConfig(this); + } + this.facilityConfig.updateFrom(configObj); + actualChanges.put(accountFacilityConfigParam, configObj.toString()); + } + } + } + + final String jointHoldersParam = "jointHolders"; + if (command.hasParameter(jointHoldersParam)) { + final com.google.gson.JsonElement parsedJson = command.parsedJson(); + if (parsedJson != null && parsedJson.isJsonObject()) { + final com.google.gson.JsonObject jsonObj = parsedJson.getAsJsonObject(); + if (jsonObj.has(jointHoldersParam) && !jsonObj.get(jointHoldersParam).isJsonNull() + && jsonObj.get(jointHoldersParam).isJsonArray()) { + final com.google.gson.JsonArray holdersArray = jsonObj.get(jointHoldersParam).getAsJsonArray(); + final List holders = new ArrayList<>(); + for (int i = 0; i < holdersArray.size(); i++) { + final com.google.gson.JsonObject h = holdersArray.get(i).getAsJsonObject(); + final Long clientId = h.has("clientId") && !h.get("clientId").isJsonNull() + ? h.get("clientId").getAsLong() : null; + final String cifNumber = h.has("cifNumber") && !h.get("cifNumber").isJsonNull() + ? h.get("cifNumber").getAsString() : null; + final String displayName = h.has("displayName") && !h.get("displayName").isJsonNull() + ? h.get("displayName").getAsString() : null; + final BigDecimal sharePercentage = h.has("sharePercentage") && !h.get("sharePercentage").isJsonNull() + ? h.get("sharePercentage").getAsBigDecimal() : null; + final boolean isPrimary = h.has("isPrimary") && h.get("isPrimary").getAsBoolean(); + String signingAuthority = h.has("signingAuthority") && !h.get("signingAuthority").isJsonNull() + ? h.get("signingAuthority").getAsString() : null; + String role = h.has("role") && !h.get("role").isJsonNull() + ? h.get("role").getAsString() : null; + + if (signingAuthority == null || signingAuthority.trim().isEmpty()) { + signingAuthority = isPrimary ? "Mandatory" : "Optional"; + } + if (role == null || role.trim().isEmpty()) { + role = isPrimary ? "Primary Account Holder" : "Co-Holder"; + } + + final boolean isMandatory = h.has("isMandatory") && h.get("isMandatory").getAsBoolean(); + holders.add(new SavingsAccountJointHolder(this, clientId, cifNumber, displayName, + sharePercentage, signingAuthority, role, isPrimary, isMandatory)); + } + this.jointHolders.clear(); + this.jointHolders.addAll(holders); + actualChanges.put(jointHoldersParam, holdersArray.toString()); + } + } + } + + validateLockinDetails(baseDataValidator); + esnureOverdraftLimitsSetForOverdraftAccounts(); + } + + /** + * If overdrafts are allowed and the overdraft limit is not set, set the same to Zero + **/ + private void esnureOverdraftLimitsSetForOverdraftAccounts() { + + this.overdraftLimit = this.overdraftLimit == null ? BigDecimal.ZERO : this.overdraftLimit; + this.nominalAnnualInterestRateOverdraft = this.nominalAnnualInterestRateOverdraft == null ? BigDecimal.ZERO + : this.nominalAnnualInterestRateOverdraft; + this.minOverdraftForInterestCalculation = this.minOverdraftForInterestCalculation == null ? BigDecimal.ZERO + : this.minOverdraftForInterestCalculation; + } + + private void validateLockinDetails(final DataValidatorBuilder baseDataValidator) { + + /* + * final List dataValidationErrors = new ArrayList(); final + * DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + * .resource(resourceName); + */ + + if (this.lockinPeriodFrequency == null) { + baseDataValidator.reset().parameter(lockinPeriodFrequencyTypeParamName).value(this.lockinPeriodFrequencyType).ignoreIfNull() + .inMinMaxRange(0, 3); + + if (this.lockinPeriodFrequencyType != null) { + baseDataValidator.reset().parameter(lockinPeriodFrequencyParamName).value(this.lockinPeriodFrequency).ignoreIfNull() + .integerZeroOrGreater(); + } + } else { + baseDataValidator.reset().parameter(lockinPeriodFrequencyParamName).value(this.lockinPeriodFrequencyType) + .integerZeroOrGreater(); + baseDataValidator.reset().parameter(lockinPeriodFrequencyTypeParamName).value(this.lockinPeriodFrequencyType).ignoreIfNull() + .inMinMaxRange(0, 3); + } + } + + public Map deriveAccountingBridgeData(final String currencyCode, final Set existingTransactionIds, + final Set existingReversedTransactionIds, boolean isAccountTransfer, final boolean backdatedTxnsAllowedTill) { + + final Map accountingBridgeData = new LinkedHashMap<>(); + accountingBridgeData.put("savingsId", getId()); + accountingBridgeData.put("savingsProductId", productId()); + accountingBridgeData.put("currencyCode", currencyCode); + accountingBridgeData.put("officeId", officeId()); + accountingBridgeData.put("cashBasedAccountingEnabled", isCashBasedAccountingEnabledOnSavingsProduct()); + accountingBridgeData.put("accrualBasedAccountingEnabled", isAccrualBasedAccountingEnabledOnSavingsProduct()); + accountingBridgeData.put("isAccountTransfer", isAccountTransfer); + accountingBridgeData.put("depositAccountType", depositAccountType().getValue()); + + final List> newSavingsTransactions = new ArrayList<>(); + + List trans = null; + + if (backdatedTxnsAllowedTill) { + trans = getSavingsAccountTransactionsWithPivotConfig(); + } else { + trans = getTransactions(); + } + + // Adding new transactions to the array + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isReversed() && !existingReversedTransactionIds.contains(transaction.getId())) { + newSavingsTransactions.add(transaction.toMapData(currencyCode)); + } else if (!existingTransactionIds.contains(transaction.getId())) { + newSavingsTransactions.add(transaction.toMapData(currencyCode)); + } + } + + accountingBridgeData.put("newSavingsTransactions", newSavingsTransactions); + return accountingBridgeData; + } + + public Collection findExistingTransactionIds() { + final Collection ids = new ArrayList<>(); + List trans = getTransactions(); + for (final SavingsAccountTransaction transaction : trans) { + ids.add(transaction.getId()); + } + return ids; + } + + public Collection findCurrentTransactionIdsWithPivotDateConfig() { + + final Collection ids = new ArrayList<>(); + List trans = getSavingsAccountTransactionsWithPivotConfig(); + for (final SavingsAccountTransaction transaction : trans) { + ids.add(transaction.getId()); + } + return ids; + } + + public Collection findExistingReversedTransactionIds() { + + final Collection ids = new ArrayList<>(); + List trans = getTransactions(); + // time consuming + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isReversed()) { + ids.add(transaction.getId()); + } + } + + return ids; + } + + public Collection findCurrentReversedTransactionIdsWithPivotDateConfig() { + final Collection ids = new ArrayList<>(); + List trans = getSavingsAccountTransactionsWithPivotConfig(); + // time consuming + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isReversed()) { + ids.add(transaction.getId()); + } + } + return ids; + } + + public void update(final Client client) { + this.client = client; + } + + public void update(final Group group) { + this.group = group; + } + + public void update(final SavingsProduct product) { + this.product = product; + this.minBalanceForInterestCalculation = product.minBalanceForInterestCalculation(); + } + + public void update(final Staff savingsOfficer) { + this.savingsOfficer = savingsOfficer; + } + + public void updateAccountNo(final String newAccountNo) { + this.accountNumber = newAccountNo; + this.accountNumberRequiresAutoGeneration = false; + } + + public boolean isAccountNumberRequiresAutoGeneration() { + return this.accountNumberRequiresAutoGeneration; + } + + public Long productId() { + return this.product.getId(); + } + + public SavingsProduct savingsProduct() { + return this.product; + } + + private Boolean isCashBasedAccountingEnabledOnSavingsProduct() { + return this.product.isCashBasedAccountingEnabled(); + } + + private Boolean isAccrualBasedAccountingEnabledOnSavingsProduct() { + return this.product.isAccrualBasedAccountingEnabled(); + } + + public Long officeId() { + Long officeId = null; + if (this.client != null) { + officeId = this.client.officeId(); + } else if (this.group != null) { + officeId = this.group.officeId(); + } + return officeId; + } + + public Office office() { + Office office = null; + if (this.client != null) { + office = this.client.getOffice(); + } else if (this.group != null) { + office = this.group.getOffice(); + } + return office; + } + + public Staff getSavingsOfficer() { + return this.savingsOfficer; + } + + public Boolean getEnforceMinRequiredBalance() { + return this.enforceMinRequiredBalance; + } + + public Boolean getLienAllowed() { + return this.lienAllowed; + } + + public void unassignSavingsOfficer() { + this.savingsOfficer = null; + } + + public void assignSavingsOfficer(final Staff fieldOfficer) { + this.savingsOfficer = fieldOfficer; + } + + public Long clientId() { + Long id = null; + if (this.client != null) { + id = this.client.getId(); + } + return id; + } + + public Long groupId() { + Long id = null; + if (this.group != null) { + id = this.group.getId(); + } + return id; + } + + public GroupSavingsIndividualMonitoring getGsim() { + return gsim; + } + + public Long getSavingsProductId() { + return this.savingsProduct().getId(); + } + + public void setGsim(GroupSavingsIndividualMonitoring gsim) { + this.gsim = gsim; + } + + public Long hasSavingsOfficerId() { + Long id = null; + if (this.savingsOfficer != null) { + id = this.savingsOfficer.getId(); + } + return id; + } + + public boolean hasSavingsOfficer(final Staff fromSavingsOfficer) { + + boolean matchesCurrentSavingsOfficer = false; + if (this.savingsOfficer != null) { + matchesCurrentSavingsOfficer = this.savingsOfficer.identifiedBy(fromSavingsOfficer); + } else { + matchesCurrentSavingsOfficer = fromSavingsOfficer == null; + } + return matchesCurrentSavingsOfficer; + } + + public void reassignSavingsOfficer(final Staff newSavingsOfficer, final LocalDate assignmentDate) { + final SavingsOfficerAssignmentHistory latestHistoryRecord = findLatestIncompleteHistoryRecord(); + final SavingsOfficerAssignmentHistory lastAssignmentRecord = findLastAssignmentHistoryRecord(newSavingsOfficer); + + // assignment date should not be less than savings account submitted date + if (DateUtils.isBefore(assignmentDate, getSubmittedOnDate())) { + final String errorMessage = "The Savings Officer assignment date (" + assignmentDate.toString() + + ") cannot be before savings submitted date (" + getSubmittedOnDate().toString() + ")."; + throw new SavingsOfficerAssignmentDateException("cannot.be.before.savings.submitted.date", errorMessage, assignmentDate, + getSubmittedOnDate()); + } else if (lastAssignmentRecord != null && lastAssignmentRecord.isBeforeEndDate(assignmentDate)) { + final String errorMessage = "The Savings Officer assignment date (" + assignmentDate + + ") cannot be before previous Savings Officer unassigned date (" + lastAssignmentRecord.getEndDate() + ")."; + throw new SavingsOfficerAssignmentDateException("cannot.be.before.previous.unassignement.date", errorMessage, assignmentDate, + lastAssignmentRecord.getEndDate()); + } else if (DateUtils.isDateInTheFuture(assignmentDate)) { + final String errorMessage = "The Savings Officer assignment date (" + assignmentDate + ") cannot be in the future."; + throw new SavingsOfficerAssignmentDateException("cannot.be.a.future.date", errorMessage, assignmentDate); + } else if (latestHistoryRecord != null && this.savingsOfficer.identifiedBy(newSavingsOfficer)) { + latestHistoryRecord.setStartDate(assignmentDate); + } else if (latestHistoryRecord != null && latestHistoryRecord.matchesStartDateOf(assignmentDate)) { + latestHistoryRecord.setSavingsOfficer(newSavingsOfficer); + this.savingsOfficer = newSavingsOfficer; + } else if (latestHistoryRecord != null && latestHistoryRecord.isBeforeStartDate(assignmentDate)) { + final String errorMessage = "Savings account with identifier " + getId() + " was already assigned before date " + + assignmentDate; + throw new SavingsOfficerAssignmentDateException("is.before.last.assignment.date", errorMessage, getId(), assignmentDate); + } else { + if (latestHistoryRecord != null) { + // savings officer correctly changed from previous savings + // officer to + // new savings officer + latestHistoryRecord.setEndDate(assignmentDate); + } + this.savingsOfficer = newSavingsOfficer; + if (isNotSubmittedAndPendingApproval()) { + final SavingsOfficerAssignmentHistory savingsOfficerAssignmentHistory = SavingsOfficerAssignmentHistory.createNew(this, + this.savingsOfficer, assignmentDate); + this.savingsOfficerHistory.add(savingsOfficerAssignmentHistory); + } + } + } + + private SavingsOfficerAssignmentHistory findLastAssignmentHistoryRecord(final Staff newSavingsOfficer) { + SavingsOfficerAssignmentHistory lastAssignmentRecordLatestEndDate = null; + for (final SavingsOfficerAssignmentHistory historyRecord : this.savingsOfficerHistory) { + if (historyRecord.isCurrentRecord() && !historyRecord.isSameSavingsOfficer(newSavingsOfficer)) { + lastAssignmentRecordLatestEndDate = historyRecord; + break; + } + + if (lastAssignmentRecordLatestEndDate == null) { + lastAssignmentRecordLatestEndDate = historyRecord; + } else if (historyRecord.isBeforeEndDate(lastAssignmentRecordLatestEndDate.getEndDate()) + && !historyRecord.isSameSavingsOfficer(newSavingsOfficer)) { + lastAssignmentRecordLatestEndDate = historyRecord; + } + } + return lastAssignmentRecordLatestEndDate; + } + + public LocalDate getSubmittedOnDate() { + return submittedOnDate; + } + + public LocalDate getApprovedOnDate() { + return approvedOnDate; + } + + public LocalDate getRejectedOnDate() { + return rejectedOnDate; + } + + public AppUser getRejectedBy() { + return this.rejectedBy; + } + + public void removeSavingsOfficer(final LocalDate unassignDate) { + final SavingsOfficerAssignmentHistory latestHistoryRecord = findLatestIncompleteHistoryRecord(); + + if (latestHistoryRecord != null) { + validateUnassignDate(latestHistoryRecord, unassignDate); + latestHistoryRecord.setEndDate(unassignDate); + } + this.savingsOfficer = null; + } + + private SavingsOfficerAssignmentHistory findLatestIncompleteHistoryRecord() { + SavingsOfficerAssignmentHistory latestRecordWithNoEndDate = null; + for (final SavingsOfficerAssignmentHistory historyRecord : this.savingsOfficerHistory) { + if (historyRecord.isCurrentRecord()) { + latestRecordWithNoEndDate = historyRecord; + break; + } + } + return latestRecordWithNoEndDate; + } + + private void validateUnassignDate(final SavingsOfficerAssignmentHistory latestHistoryRecord, final LocalDate unassignDate) { + if (DateUtils.isBefore(unassignDate, latestHistoryRecord.getStartDate())) { + final String errorMessage = "The Savings officer Unassign date(" + unassignDate + ") cannot be before its assignment date (" + + latestHistoryRecord.getStartDate() + ")."; + throw new SavingsOfficerUnassignmentDateException("cannot.be.before.assignment.date", errorMessage, getId(), + getSavingsOfficer().getId(), latestHistoryRecord.getStartDate(), unassignDate); + } else if (DateUtils.isDateInTheFuture(unassignDate)) { + final String errorMessage = "The Savings Officer Unassign date (" + unassignDate + ") cannot be in the future."; + throw new SavingsOfficerUnassignmentDateException("cannot.be.a.future.date", errorMessage, unassignDate); + } + } + + public MonetaryCurrency getCurrency() { + return this.currency; + } + + public void validateNewApplicationState(final String resourceName) { + // validateWithdrawalFeeDetails(); + // validateAnnualFeeDetails(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(resourceName + SavingsApiConstants.summitalAction); + + validateLockinDetails(baseDataValidator); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + final LocalDate submittedOn = getSubmittedOnDate(); + if (DateUtils.isDateInTheFuture(submittedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(submittedOn) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); + } + + if (this.client != null && this.client.isActivatedAfter(submittedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(this.client.getActivationDate()) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); + } else if (this.group != null && this.group.isActivatedAfter(submittedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.submittedOnDateParamName).value(this.group.getActivationDate()) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); + } + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + public Client getClient() { + return this.client; + } + + public BigDecimal getNominalAnnualInterestRate() { + return this.nominalAnnualInterestRate; + } + + public Integer getInterestCompoundingPeriodType() { + return this.interestCompoundingPeriodType; + } + + public Integer getInterestPostingPeriodType() { + return this.interestPostingPeriodType; + } + + public Integer getInterestCalculationType() { + return this.interestCalculationType; + } + + public BigDecimal getNominalAnnualInterestRateOverdraft() { + return this.nominalAnnualInterestRateOverdraft; + } + + public Map approveApplication(final AppUser currentUser, final JsonCommand command) { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.approvalAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { + baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.status = SavingsAccountStatusType.APPROVED.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + + // only do below if status has changed in the 'approval' case + final LocalDate approvedOn = command.localDateValueOfParameterNamed(SavingsApiConstants.approvedOnDateParamName); + final String approvedOnDateChange = command.stringValueOfParameterNamed(SavingsApiConstants.approvedOnDateParamName); + + this.approvedOnDate = approvedOn; + this.approvedBy = currentUser; + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.approvedOnDateParamName, approvedOnDateChange); + + final LocalDate submittalDate = getSubmittedOnDate(); + if (DateUtils.isBefore(approvedOn, submittalDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String submittalDateAsString = formatter.format(submittalDate); + + baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName).value(submittalDateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (DateUtils.isAfterBusinessDate(approvedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_APPROVED, approvedOn); + + // FIXME - kw - support field officer history for savings accounts + // if (this.fieldOfficer != null) { + // final LoanOfficerAssignmentHistory loanOfficerAssignmentHistory = + // LoanOfficerAssignmentHistory.createNew(this, + // this.fieldOfficer, approvedOn); + // this.loanOfficerHistory.add(loanOfficerAssignmentHistory); + // } + if (this.savingsOfficer != null) { + final SavingsOfficerAssignmentHistory savingsOfficerAssignmentHistory = SavingsOfficerAssignmentHistory.createNew(this, + this.savingsOfficer, approvedOn); + this.savingsOfficerHistory.add(savingsOfficerAssignmentHistory); + } + return actualChanges; + } + + public Map undoApplicationApproval() { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.undoApprovalAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.APPROVED.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.approvedOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.approved.state"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + + this.approvedOnDate = null; + this.approvedBy = null; + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = null; + this.closedBy = null; + actualChanges.put(SavingsApiConstants.approvedOnDateParamName, ""); + + // FIXME - kw - support field officer history for savings accounts + // this.loanOfficerHistory.clear(); + + return actualChanges; + } + + public void undoTransaction(final Long transactionId) { + + SavingsAccountTransaction transactionToUndo = null; + for (final SavingsAccountTransaction transaction : this.transactions) { + if (transaction.isIdentifiedBy(transactionId)) { + transactionToUndo = transaction; + } + } + + if (transactionToUndo == null) { + throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionId); + } + + validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); + transactionToUndo.reverse(); + if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { + // undo charge + final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); + for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { + final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); + if (transactionToUndo.isChargeTransaction()) { + chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.getCurrency())); + } else if (transactionToUndo.isWaiveCharge()) { + chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.getCurrency())); + } + } + } + } + + public void undoSavingsTransaction(final Long transactionId) { + + SavingsAccountTransaction transactionToUndo = null; + for (final SavingsAccountTransaction transaction : this.savingsAccountTransactions) { + if (transaction.isIdentifiedBy(transactionId)) { + transactionToUndo = transaction; + } + } + + if (transactionToUndo == null) { + throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionId); + } + + validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); + transactionToUndo.reverse(); + if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { + // undo charge + final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); + for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { + final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); + if (transactionToUndo.isChargeTransaction()) { + chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.currency)); + } else if (transactionToUndo.isWaiveCharge()) { + chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.currency)); + } + } + } + } + + public void undoTransaction(final SavingsAccountTransaction transactionToUndo) { + + if (transactionToUndo.isReversed()) { + throw new SavingsAccountTransactionNotFoundException(this.getId(), transactionToUndo.getId()); + } + + validateAttemptToUndoTransferRelatedTransactions(transactionToUndo); + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_UNDO_TRANSACTION, transactionToUndo.getTransactionDate()); + transactionToUndo.reverse(); + if (transactionToUndo.isChargeTransaction() || transactionToUndo.isWaiveCharge()) { + // undo charge + final Set chargesPaidBy = transactionToUndo.getSavingsAccountChargesPaid(); + for (final SavingsAccountChargePaidBy savingsAccountChargePaidBy : chargesPaidBy) { + final SavingsAccountCharge chargeToUndo = savingsAccountChargePaidBy.getSavingsAccountCharge(); + if (transactionToUndo.isChargeTransaction()) { + chargeToUndo.undoPayment(this.getCurrency(), transactionToUndo.getAmount(this.currency)); + } else if (transactionToUndo.isWaiveCharge()) { + chargeToUndo.undoWaiver(this.getCurrency(), transactionToUndo.getAmount(this.currency)); + } + } + } + } + + private LocalDate findLatestAnnualFeeTransactionDueDate() { + LocalDate nextDueDate = null; + + LocalDate lastAnnualFeeTransactionDate = null; + for (final SavingsAccountTransaction transaction : retreiveOrderedNonInterestPostingTransactions()) { + if (transaction.isAnnualFeeAndNotReversed()) { + LocalDate transactionDate = transaction.getTransactionDate(); + if (lastAnnualFeeTransactionDate == null) { + lastAnnualFeeTransactionDate = transactionDate; + nextDueDate = lastAnnualFeeTransactionDate; + } + if (DateUtils.isAfter(transactionDate, lastAnnualFeeTransactionDate)) { + lastAnnualFeeTransactionDate = transactionDate; + nextDueDate = lastAnnualFeeTransactionDate; + } + } + } + return nextDueDate; + } + + public void validateAccountBalanceDoesNotBecomeNegativeMinimal(final BigDecimal transactionAmount, final boolean isException) { + // final List transactionsSortedByDate = retrieveListOfTransactions(); + Money runningBalance = this.summary.getAccountBalance(getCurrency()); + Money minRequiredBalance = minRequiredBalanceDerived(getCurrency()); + final BigDecimal withdrawalFee = null; + + // check last txn date + + // In overdraft cases, minRequiredBalance can be in violation after interest posting + // and should be checked after processing all transactions + if (!isOverdraft()) { + if (runningBalance.minus(minRequiredBalance).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); + } + } + + if (this.getSavingsHoldAmount().compareTo(BigDecimal.ZERO) > 0) { + if (runningBalance.minus(this.getSavingsHoldAmount()).minus(minRequiredBalance).isLessThanZero()) { + throw new InsufficientAccountBalanceException("transactionAmount", getAccountBalance(), withdrawalFee, transactionAmount); + } + } + } + + public Map rejectApplication(final AppUser currentUser, final JsonCommand command) { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.rejectAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.status = SavingsAccountStatusType.REJECTED.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + + final LocalDate rejectedOn = command.localDateValueOfParameterNamed(SavingsApiConstants.rejectedOnDateParamName); + final String rejectedOnAsString = command.stringValueOfParameterNamed(SavingsApiConstants.rejectedOnDateParamName); + + this.rejectedOnDate = rejectedOn; + this.rejectedBy = currentUser; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = rejectedOn; + this.closedBy = currentUser; + + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.rejectedOnDateParamName, rejectedOnAsString); + actualChanges.put(SavingsApiConstants.closedOnDateParamName, rejectedOnAsString); + + final LocalDate submittalDate = getSubmittedOnDate(); + if (DateUtils.isBefore(rejectedOn, submittalDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String submittalDateAsString = formatter.format(submittalDate); + + baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName).value(submittalDateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (DateUtils.isAfterBusinessDate(rejectedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.rejectedOnDateParamName).value(rejectedOn) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_REJECTED, rejectedOn); + + return actualChanges; + } + + public Map applicantWithdrawsFromApplication(final AppUser currentUser, final JsonCommand command) { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.withdrawnByApplicantAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.submittedandpendingapproval.state"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.status = SavingsAccountStatusType.WITHDRAWN_BY_APPLICANT.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + + final LocalDate withdrawnOn = command.localDateValueOfParameterNamed(SavingsApiConstants.withdrawnOnDateParamName); + final String withdrawnOnAsString = command.stringValueOfParameterNamed(SavingsApiConstants.withdrawnOnDateParamName); + + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = withdrawnOn; + this.withdrawnBy = currentUser; + this.closedOnDate = withdrawnOn; + this.closedBy = currentUser; + + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.withdrawnOnDateParamName, withdrawnOnAsString); + actualChanges.put(SavingsApiConstants.closedOnDateParamName, withdrawnOnAsString); + + final LocalDate submittalDate = getSubmittedOnDate(); + if (DateUtils.isBefore(withdrawnOn, submittalDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String submittalDateAsString = formatter.format(submittalDate); + + baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName).value(submittalDateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.submittal.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (DateUtils.isAfterBusinessDate(withdrawnOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.withdrawnOnDateParamName).value(withdrawnOn) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLICATION_WITHDRAWAL_BY_CUSTOMER, withdrawnOn); + + return actualChanges; + } + + public Map activate(final AppUser currentUser, final JsonCommand command) { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.activateAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.APPROVED.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.approved.state"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final Locale locale = command.extractLocale(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); + final LocalDate activationDate = command.localDateValueOfParameterNamed(SavingsApiConstants.activatedOnDateParamName); + + this.status = SavingsAccountStatusType.ACTIVE.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.activatedOnDateParamName, activationDate.format(fmt)); + + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = null; + this.closedBy = null; + this.activatedOnDate = activationDate; + this.activatedBy = currentUser; + this.lockedInUntilDate = calculateDateAccountIsLockedUntil(getActivationDate()); + + /* + * if (annualFeeSettingsSet()) { updateToNextAnnualFeeDueDateFrom(getActivationLocalDate()); } + */ + if (this.client != null && this.client.isActivatedAfter(activationDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String dateAsString = formatter.format(this.client.getActivationDate()); + baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.client.activation.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (this.group != null && this.group.isActivatedAfter(activationDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String dateAsString = formatter.format(this.client.getActivationDate()); + baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.group.activation.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final LocalDate approvalDate = getApprovedOnDate(); + if (DateUtils.isBefore(activationDate, approvalDate)) { + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String dateAsString = formatter.format(approvalDate); + + baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(dateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.approval.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (DateUtils.isAfterBusinessDate(activationDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.activatedOnDateParamName).value(activationDate) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.a.future.date"); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_ACTIVATE, activationDate); + + return actualChanges; + } + + public void processAccountUponActivation(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, + final Integer financialYearBeginningMonth) { + // update annual fee due date + for (SavingsAccountCharge charge : this.charges()) { + charge.updateToNextDueDateFrom(getActivationDate()); + } + + // auto pay the activation time charges (No need of checking the pivot date config) + this.payActivationCharges(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, false); + // TODO : AA add activation charges to actual changes list + } + + public Money activateWithBalance() { + // Activation never deposits money -- funding happens strictly through a separate deposit + // transaction (posted by a clerk) after the account is already active, regardless of any + // minimum required opening balance the product has configured. + return Money.zero(this.currency); + } + + public void approveAndActivateApplication(final LocalDate appliedonDate, final AppUser appliedBy) { + this.status = SavingsAccountStatusType.ACTIVE.getValue(); + this.approvedOnDate = appliedonDate; + this.approvedBy = appliedBy; + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = null; + this.closedBy = null; + this.activatedOnDate = appliedonDate; + this.activatedBy = appliedBy; + this.lockedInUntilDate = calculateDateAccountIsLockedUntil(getActivationDate()); + } + + private void payActivationCharges(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, + final boolean backdatedTxnsAllowedTill) { + boolean isSavingsChargeApplied = false; + boolean postReversals = false; + UUID refNo = UUID.randomUUID(); + for (SavingsAccountCharge savingsAccountCharge : this.charges()) { + if (savingsAccountCharge.isSavingsActivation()) { + isSavingsChargeApplied = true; + payCharge(savingsAccountCharge, savingsAccountCharge.getAmountOutstanding(getCurrency()), getActivationDate(), + backdatedTxnsAllowedTill, refNo.toString()); + } + } + + if (isSavingsChargeApplied) { + final MathContext mc = MathContext.DECIMAL64; + boolean isInterestTransfer = false; + LocalDate postInterestAsOnDate = null; + if (this.isBeforeLastPostingPeriod(getActivationDate(), backdatedTxnsAllowedTill)) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + this.postInterest(mc, today, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, + postInterestAsOnDate, backdatedTxnsAllowedTill, postReversals); + } else { + final LocalDate today = DateUtils.getBusinessLocalDate(); + this.calculateInterestUsing(mc, today, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth, postInterestAsOnDate, backdatedTxnsAllowedTill, postReversals); + } + } + } + + public Map close(final AppUser currentUser, final JsonCommand command) { + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.closeAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.active.state"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final Locale locale = command.extractLocale(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); + final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); + + if (DateUtils.isBefore(closedDate, getActivationDate())) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("must.be.after.activation.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (DateUtils.isAfterBusinessDate(closedDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("cannot.be.a.future.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + final List savingsAccountTransactions = retrieveListOfTransactions(); + if (savingsAccountTransactions.size() > 0) { + final SavingsAccountTransaction accountTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1); + if (accountTransaction.isAfter(closedDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("must.be.after.last.transaction.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + } + if (getAccountBalance().doubleValue() != 0) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("results.in.balance.not.zero"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_CLOSE_ACCOUNT, closedDate); + this.status = SavingsAccountStatusType.CLOSED.getValue(); + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.closedOnDateParamName, closedDate.format(fmt)); + + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = closedDate; + this.closedBy = currentUser; + + return actualChanges; + } + + protected void validateActivityNotBeforeClientOrGroupTransferDate(final SavingsEvent event, final LocalDate activityDate) { + if (this.client != null) { + final LocalDate clientOfficeJoiningDate = this.client.getOfficeJoiningDate(); + if (DateUtils.isBefore(activityDate, clientOfficeJoiningDate)) { + throw new SavingsActivityPriorToClientTransferException(event.toString(), clientOfficeJoiningDate); + } + } + } + + private void validateAttemptToUndoTransferRelatedTransactions(final SavingsAccountTransaction savingsAccountTransaction) { + if (savingsAccountTransaction.isTransferRelatedTransaction()) { + throw new SavingsTransferTransactionsCannotBeUndoneException(savingsAccountTransaction.getId()); + } + } + + protected LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) { + + LocalDate lockedInUntilLocalDate = null; + final PeriodFrequencyType lockinPeriodFrequencyType = PeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); + switch (lockinPeriodFrequencyType) { + case INVALID: + break; + case DAYS: + lockedInUntilLocalDate = activationLocalDate.plusDays(this.lockinPeriodFrequency); + break; + case WEEKS: + lockedInUntilLocalDate = activationLocalDate.plusWeeks(this.lockinPeriodFrequency); + break; + case MONTHS: + lockedInUntilLocalDate = activationLocalDate.plusMonths(this.lockinPeriodFrequency); + break; + case YEARS: + lockedInUntilLocalDate = activationLocalDate.plusYears(this.lockinPeriodFrequency); + break; + case WHOLE_TERM: + LOG.error("TODO Implement calculateDateAccountIsLockedUntil for WHOLE_TERM"); + break; + } + + return lockedInUntilLocalDate; + } + + public Group group() { + return this.group; + } + + public boolean isWithdrawalFeeApplicableForTransfer() { + return this.withdrawalFeeApplicableForTransfer; + } + + public void activateAccountBasedOnBalance() { + if (getStatus().isClosed() && !this.summary.getAccountBalance(getCurrency()).isZero()) { + this.status = SavingsAccountStatusType.ACTIVE.getValue(); + } + } + + public LocalDate getClosedOnDate() { + return this.closedOnDate; + } + + public AppUser getClosedBy() { + return this.closedBy; + } + + public SavingsAccountSummary getSummary() { + return this.summary; + } + + public List getTransactions() { + return this.transactions; + } + + public void addTransaction(final SavingsAccountTransaction transaction) { + this.transactions.add(transaction); + } + + public void addTransactionToExisting(final SavingsAccountTransaction transaction) { + this.savingsAccountTransactions.add(transaction); + } + + public void setStatus(final Integer status) { + this.status = status; + } + + private Set associateChargesWithThisSavingsAccount(final Set savingsAccountCharges) { + for (final SavingsAccountCharge savingsAccountCharge : savingsAccountCharges) { + savingsAccountCharge.update(this); + } + return savingsAccountCharges; + } + + public boolean update(final Set newSavingsAccountCharges) { + if (newSavingsAccountCharges == null) { + return false; + } + + if (this.charges == null) { + this.charges = new HashSet<>(); + } + this.charges.clear(); + this.charges.addAll(associateChargesWithThisSavingsAccount(newSavingsAccountCharges)); + return true; + } + + public boolean hasCurrencyCodeOf(final String matchingCurrencyCode) { + if (this.currency == null) { + return false; + } + return this.currency.getCode().equalsIgnoreCase(matchingCurrencyCode); + } + + public void removeCharge(final SavingsAccountCharge charge) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (isClosed()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("delete.transaction.invalid.account.is.closed"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (isActive() || isApproved()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("delete.transaction.invalid.account.is.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.charges.remove(charge); + } + + public void waiveCharge(final Long savingsAccountChargeId, final boolean backdatedTxnsAllowedTill) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (isClosed()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (isNotActive()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final SavingsAccountCharge savingsAccountCharge = getCharge(savingsAccountChargeId); + + if (savingsAccountCharge.isNotActive()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("charge.is.not.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (savingsAccountCharge.isWithdrawalFee()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.waiver.of.withdrawal.fee.not.supported"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + // validate charge is not already paid or waived + if (savingsAccountCharge.isWaived()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.already.waived"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } else if (savingsAccountCharge.isPaid()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.paid"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + // waive charge + final Money amountWaived = savingsAccountCharge.waive(getCurrency()); + handleWaiverChargeTransactions(savingsAccountCharge, amountWaived, backdatedTxnsAllowedTill); + } + + public void addCharge(final DateTimeFormatter formatter, final SavingsAccountCharge savingsAccountCharge, + final Charge chargeDefinition) { + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (isClosed()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (!hasCurrencyCodeOf(chargeDefinition.getCurrencyCode())) { + baseDataValidator.reset() + .failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.currency.and.charge.currency.not.same"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final LocalDate chargeDueDate = savingsAccountCharge.getDueDate(); + + if (savingsAccountCharge.isOnSpecifiedDueDate()) { + if (DateUtils.isBefore(chargeDueDate, getActivationDate())) { + baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getActivationDate().format(formatter)) + .failWithCodeNoParameterAddedToErrorCode("before.activationDate"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } else if (DateUtils.isBefore(chargeDueDate, getSubmittedOnDate())) { + baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getSubmittedOnDate().format(formatter)) + .failWithCodeNoParameterAddedToErrorCode("before.submittedOnDate"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (savingsAccountCharge.isSavingsActivation() && !(isSubmittedAndPendingApproval() || (isApproved() && isNotActive()))) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.valid.account.status.cannot.add.activation.time.charge"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + // Only one annual fee is supported per account + if (savingsAccountCharge.isAnnualFee()) { + if (this.isAnnualFeeExists()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("multiple.annual.fee.per.account.not.supported"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + } + + if (savingsAccountCharge.isAnnualFee() || savingsAccountCharge.isMonthlyFee() || savingsAccountCharge.isWeeklyFee()) { + // update due date + if (isActive()) { + savingsAccountCharge.updateToNextDueDateFrom(getActivationDate()); + } else if (isApproved()) { + savingsAccountCharge.updateToNextDueDateFrom(getApprovedOnDate()); + } + } + + // activation charge and withdrawal charges not required this validation + if (savingsAccountCharge.isOnSpecifiedDueDate()) { + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_APPLY_CHARGE, chargeDueDate); + } + + // add new charge to savings account + this.charges.add(savingsAccountCharge); + + } + + private boolean isAnnualFeeExists() { + for (SavingsAccountCharge charge : this.charges()) { + if (charge.isAnnualFee()) { + return true; + } + } + return false; + } + + public SavingsAccountTransaction payCharge(final SavingsAccountCharge savingsAccountCharge, final BigDecimal amountPaid, + final LocalDate transactionDate, final DateTimeFormatter formatter, final boolean backdatedTxnsAllowedTill, + final String refNo) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (isClosed()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (isNotActive()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (savingsAccountCharge.isNotActive()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("charge.is.not.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (DateUtils.isBefore(transactionDate, getActivationDate())) { + baseDataValidator.reset().parameter(dueAsOfDateParamName).value(getActivationDate().format(formatter)) + .failWithCodeNoParameterAddedToErrorCode("transaction.before.activationDate"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isDateInTheFuture(transactionDate)) { + baseDataValidator.reset().parameter(dueAsOfDateParamName).value(transactionDate.format(formatter)) + .failWithCodeNoParameterAddedToErrorCode("transaction.is.futureDate"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (savingsAccountCharge.isSavingsActivation()) { + baseDataValidator.reset() + .failWithCodeNoParameterAddedToErrorCode("transaction.not.valid.cannot.pay.activation.time.charge.is.automated"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (savingsAccountCharge.isAnnualFee()) { + final LocalDate annualFeeDueDate = savingsAccountCharge.getDueDate(); + if (annualFeeDueDate == null) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.annualfee.settings"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (DateUtils.isBefore(transactionDate, annualFeeDueDate)) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.before.dueDate"); + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + LocalDate currentAnnualFeeNextDueDate = findLatestAnnualFeeTransactionDueDate(); + if (DateUtils.isEqual(currentAnnualFeeNextDueDate, transactionDate)) { + baseDataValidator.reset().parameter("dueDate").value(transactionDate.format(formatter)) + .failWithCodeNoParameterAddedToErrorCode("transaction.exists.on.date"); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + // validate charge is not already paid or waived + if (savingsAccountCharge.isWaived()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.already.waived"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } else if (savingsAccountCharge.isPaid()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.charge.is.paid"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final Money chargePaid = Money.of(currency, amountPaid); + if (!savingsAccountCharge.getAmountOutstanding(getCurrency()).isGreaterThanOrEqualTo(chargePaid)) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.charge.amount.paid.in.access"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + return this.payCharge(savingsAccountCharge, chargePaid, transactionDate, backdatedTxnsAllowedTill, refNo); + } + + public SavingsAccountTransaction payCharge(final SavingsAccountCharge savingsAccountCharge, final Money amountPaid, + final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, String refNo) { + savingsAccountCharge.pay(getCurrency(), amountPaid); + return handlePayChargeTransactions(savingsAccountCharge, amountPaid, transactionDate, backdatedTxnsAllowedTill, refNo); + } + + private SavingsAccountTransaction handlePayChargeTransactions(SavingsAccountCharge savingsAccountCharge, Money transactionAmount, + final LocalDate transactionDate, final boolean backdatedTxnsAllowedTill, final String refNo) { + SavingsAccountTransaction chargeTransaction; + + if (savingsAccountCharge.isWithdrawalFee()) { + chargeTransaction = SavingsAccountTransaction.withdrawalFee(this, office(), transactionDate, transactionAmount, refNo); + } else if (savingsAccountCharge.isAnnualFee()) { + chargeTransaction = SavingsAccountTransaction.annualFee(this, office(), transactionDate, transactionAmount); + } else { + chargeTransaction = SavingsAccountTransaction.charge(this, office(), transactionDate, transactionAmount); + } + + handleChargeTransactions(savingsAccountCharge, chargeTransaction, backdatedTxnsAllowedTill); + return chargeTransaction; + } + + private void handleWaiverChargeTransactions(SavingsAccountCharge savingsAccountCharge, Money transactionAmount, + boolean backdatedTxnsAllowedTill) { + final SavingsAccountTransaction chargeTransaction = SavingsAccountTransaction.waiver(this, office(), + DateUtils.getBusinessLocalDate(), transactionAmount); + handleChargeTransactions(savingsAccountCharge, chargeTransaction, backdatedTxnsAllowedTill); + } + + private void handleChargeTransactions(final SavingsAccountCharge savingsAccountCharge, final SavingsAccountTransaction transaction, + final boolean backdatedTxnsAllowedTill) { + // Provide a link between transaction and savings charge for which + // amount is waived. + final SavingsAccountChargePaidBy chargePaidBy = SavingsAccountChargePaidBy.instance(transaction, savingsAccountCharge, + transaction.getAmount(this.currency).getAmount()); + transaction.getSavingsAccountChargesPaid().add(chargePaidBy); + if (backdatedTxnsAllowedTill) { + this.savingsAccountTransactions.add(transaction); + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction, + this.savingsAccountTransactions); + } else { + this.transactions.add(transaction); + } + } + + private SavingsAccountCharge getCharge(final Long savingsAccountChargeId) { + SavingsAccountCharge charge = null; + for (final SavingsAccountCharge existingCharge : this.charges) { + if (existingCharge.getId().equals(savingsAccountChargeId)) { + charge = existingCharge; + break; + } + } + + if (charge == null) { + throw new SavingsAccountChargeNotFoundException(savingsAccountChargeId, getId()); + } + + return charge; + } + + public Set charges() { + return (this.charges == null) ? new HashSet() : this.charges; + } + + public void validateAccountValuesWithProduct() { + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (this.overdraftLimit != null && this.product.overdraftLimit() != null + && this.overdraftLimit.compareTo(this.product.overdraftLimit()) > 0) { + baseDataValidator.reset().parameter(SavingsApiConstants.overdraftLimitParamName).value(this.overdraftLimit) + .failWithCode("cannot.exceed.product.value"); + } + + validateInterestPostingAndCompoundingPeriodTypes(baseDataValidator); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + public boolean allowOverdraft() { + return this.allowOverdraft; + } + + public LocalDate accountSubmittedOrActivationDate() { + return getActivationDate() == null ? getSubmittedOnDate() : getActivationDate(); + } + + protected boolean isTransferInterestToOtherAccount() { + return false; + } + + public boolean accountSubmittedAndActivationOnSameDate() { + return getActivationDate() != null && DateUtils.isEqual(getActivationDate(), getSubmittedOnDate()); + + } + + public void validateInterestPostingAndCompoundingPeriodTypes(final DataValidatorBuilder baseDataValidator) { + Map> postingtoCompoundMap = new HashMap<>(); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.DAILY, Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY)); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.MONTHLY, + Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY)); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.QUATERLY, Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, + SavingsCompoundingInterestPeriodType.MONTHLY, SavingsCompoundingInterestPeriodType.QUATERLY)); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.BIANNUAL, + Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY, + SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL)); + + postingtoCompoundMap.put(SavingsPostingInterestPeriodType.ANNUAL, + Arrays.asList(SavingsCompoundingInterestPeriodType.DAILY, SavingsCompoundingInterestPeriodType.MONTHLY, + SavingsCompoundingInterestPeriodType.QUATERLY, SavingsCompoundingInterestPeriodType.BI_ANNUAL, + SavingsCompoundingInterestPeriodType.ANNUAL)); + + SavingsPostingInterestPeriodType savingsPostingInterestPeriodType = SavingsPostingInterestPeriodType + .fromInt(interestPostingPeriodType); + SavingsCompoundingInterestPeriodType savingsCompoundingInterestPeriodType = SavingsCompoundingInterestPeriodType + .fromInt(interestCompoundingPeriodType); + + if (postingtoCompoundMap.get(savingsPostingInterestPeriodType) == null) { + baseDataValidator.failWithCodeNoParameterAddedToErrorCode("posting.period.type.is.less.than.compound.period.type", + savingsPostingInterestPeriodType.name(), savingsCompoundingInterestPeriodType.name()); + + } + } + + public AppUser getSubmittedBy() { + return this.submittedBy; + } + + public AppUser getApprovedBy() { + return this.approvedBy; + } + + public boolean allowDeposit() { + return true; + } + + public boolean allowWithdrawal() { + return true; + } + + public boolean allowModify() { + return true; + } + + public boolean isTransactionsAllowed() { + return isActive(); + } + + public boolean isTransactionAllowed(SavingsAccountTransactionType transactionType, LocalDate transactionDate) { + if (!isTransactionsAllowed()) { + return false; + } + + Client client = getClient(); + if (client != null && !client.isActive()) { + return false; + } + Group group = group(); + if (group != null && !group.isActive()) { + return false; + } + + if (transactionDate == null) { + return true; + } + if (DateUtils.isDateInTheFuture(transactionDate) || DateUtils.isBefore(transactionDate, getActivationDate())) { + return false; + } + if (transactionType.isCredit()) { + return true; + } + return !isAccountLocked(transactionDate); + } + + public BigDecimal minBalanceForInterestCalculation() { + return this.minBalanceForInterestCalculation; + } + + public void inactivateCharge(SavingsAccountCharge savingsAccountCharge, LocalDate inactivationOnDate) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(SAVINGS_ACCOUNT_RESOURCE_NAME); + + if (isClosed()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.closed"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (isNotActive()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("transaction.invalid.account.is.not.active"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + savingsAccountCharge.inactiavateCharge(inactivationOnDate); + } + + public SavingsAccountCharge getUpdatedChargeDetails(SavingsAccountCharge savingsAccountCharge) { + for (final SavingsAccountCharge charge : this.charges) { + if (charge.equals(savingsAccountCharge)) { + savingsAccountCharge = charge; + break; + } + } + return savingsAccountCharge; + } + + public String getAccountNumber() { + return this.accountNumber; + } + + private Money minRequiredBalanceDerived(final MonetaryCurrency currency) { + // Minimum required balance is not enforced -- withdrawals (and every other balance check + // that goes through this) must never be blocked by a configured minimum balance. Overdraft + // headroom is a separate concept and still applies. + Money minReqBalance = Money.zero(currency); + if (this.allowOverdraft) { + minReqBalance = minReqBalance.minus(this.overdraftLimit); + } + return minReqBalance; + } + + public BigDecimal getOnHoldFunds() { + return this.onHoldFunds == null ? BigDecimal.ZERO : this.onHoldFunds; + } + + public void holdFunds(BigDecimal onHoldFunds) { + this.onHoldFunds = getOnHoldFunds().add(onHoldFunds); + } + + public void releaseFunds(BigDecimal onHoldFunds) { + this.onHoldFunds = getOnHoldFunds().subtract(onHoldFunds); + } + + public BigDecimal getWithdrawableBalance() { + return getAccountBalance().subtract(minRequiredBalanceDerived(getCurrency()).getAmount()).subtract(this.getOnHoldFunds()) + .subtract(this.getSavingsHoldAmount()).subtract(this.getSavingsHoldAmountClearing()); + } + + public BigDecimal getWithdrawableBalanceWithLien() { + return getAccountBalance().subtract(minRequiredBalanceDerived(getCurrency()).getAmount()).subtract(this.getOnHoldFunds()) + .subtract(this.getSavingsHoldAmount()); + } + + public BigDecimal getWithdrawableBalanceWithoutMinimumBalance() { + return getAccountBalance().subtract(this.getOnHoldFunds()).subtract(this.getSavingsHoldAmount()); + } + + public TaxGroup getTaxGroup() { + return this.taxGroup; + } + + public boolean withHoldTax() { + return this.withHoldTax; + } + + public void setWithHoldTax(boolean withHoldTax) { + this.withHoldTax = withHoldTax; + } + + protected boolean applyWithholdTaxForDepositAccounts(final LocalDate interestPostingUpToDate, boolean recalucateDailyBalance, + final boolean backdatedTxnsAllowedTill) { + final List withholdTransactions = findWithHoldTransactions(); + SavingsAccountTransaction withholdTransaction = findTransactionFor(interestPostingUpToDate, withholdTransactions); + final BigDecimal totalInterestPosted = this.savingsAccountTransactionSummaryWrapper.calculateTotalInterestPosted(this.currency, + this.transactions); + if (withholdTransaction == null && this.withHoldTax()) { + boolean isWithholdTaxAdded = createWithHoldTransaction(totalInterestPosted, interestPostingUpToDate, backdatedTxnsAllowedTill); + recalucateDailyBalance = recalucateDailyBalance || isWithholdTaxAdded; + } else { + boolean isWithholdTaxAdded = updateWithHoldTransaction(totalInterestPosted, withholdTransaction); + recalucateDailyBalance = recalucateDailyBalance || isWithholdTaxAdded; + } + + return recalucateDailyBalance; + } + + public void setSubStatusInactive(final boolean backdatedTxnsAllowedTill) { + this.sub_status = SavingsAccountSubStatusEnum.INACTIVE.getValue(); + LocalDate transactionDate = DateUtils.getBusinessLocalDate(); + for (SavingsAccountCharge charge : this.charges()) { + if (charge.isSavingsNoActivity() && charge.isActive()) { + charge.updateWithdralFeeAmount(this.getAccountBalance()); + UUID refNo = UUID.randomUUID(); + this.payCharge(charge, charge.getAmountOutstanding(this.getCurrency()), transactionDate, backdatedTxnsAllowedTill, + refNo.toString()); + } + } + boolean postReversals = false; + recalculateDailyBalances(Money.zero(this.currency), transactionDate, backdatedTxnsAllowedTill, postReversals); + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } + + public void recalculateBalancesAfterFacilityCharges(final LocalDate transactionDate) { + recalculateDailyBalances(Money.zero(this.currency), transactionDate, false, false); + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } + + public void setSubStatusDormant() { + this.sub_status = SavingsAccountSubStatusEnum.DORMANT.getValue(); + } + + public void escheat(AppUser appUser) { + this.status = SavingsAccountStatusType.CLOSED.getValue(); + this.sub_status = SavingsAccountSubStatusEnum.ESCHEAT.getValue(); + this.closedOnDate = DateUtils.getBusinessLocalDate(); + this.closedBy = appUser; + boolean postInterestAsOnDate = false; + boolean postReversals = false; + LocalDate transactionDate = DateUtils.getBusinessLocalDate(); + if (this.getSummary().getAccountBalance(this.getCurrency()).isGreaterThanZero()) { + SavingsAccountTransaction transaction = SavingsAccountTransaction.escheat(this, transactionDate, postInterestAsOnDate); + this.transactions.add(transaction); + } + recalculateDailyBalances(Money.zero(this.currency), transactionDate, false, postReversals); + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } + + public void loadLazyCollections() { + transactions.size(); + charges.size(); + savingsOfficerHistory.size(); + if (group != null) { + Office dummyOffice = group.getOffice(); + } // Ensure lazy loading of group if set + } + + public void updateSavingsAccountSummary(final List transactions) { + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, transactions); + } + + public void updateReason(final String reasonForBlock) { + this.reasonForBlock = reasonForBlock; + } + + public Map block() { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.blockAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + + return actualChanges; + } + + public Map unblock() { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + + } + + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); + if (!SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { + baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) + .failWithCodeNoParameterAddedToErrorCode("not.in.blocked.state"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + return actualChanges; + } + + public Map blockCredits(Integer currentSubstatus) { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.blockCreditsAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + } + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { + + baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) + .value(SavingsAccountSubStatusEnum.fromInt(currentSubstatus)).failWithCodeNoParameterAddedToErrorCode("currently.set"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + if (SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); + } else { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK_CREDIT.getValue(); + } + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + + return actualChanges; + } + + public Map unblockCredits() { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockCreditsAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + } + + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); + if (!(SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(currentSubStatus) + || SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus))) { + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode("credits.are.not.blocked"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK_DEBIT.getValue(); + } else { + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + } + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + return actualChanges; + } + + public Map blockDebits(Integer currentSubstatus) { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.blockDebitsAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + + } + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { + + baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) + .value(SavingsAccountSubStatusEnum.fromInt(currentSubstatus)).failWithCodeNoParameterAddedToErrorCode("currently.set"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + if (SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(currentSubstatus))) { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue(); + } else { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK_DEBIT.getValue(); + } + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + + return actualChanges; + } + + public Map unblockDebits() { + + final Map actualChanges = new LinkedHashMap<>(); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(depositAccountType().resourceName() + SavingsApiConstants.unblockDebitsAction); + + final SavingsAccountStatusType currentStatus = getStatus(); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + + baseDataValidator.reset().parameter(SavingsApiConstants.statusParamName) + .failWithCodeNoParameterAddedToErrorCode(SavingsApiConstants.ERROR_MSG_SAVINGS_ACCOUNT_NOT_ACTIVE); + + } + + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.sub_status); + if (!(SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(currentSubStatus) + || SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus))) { + baseDataValidator.reset().parameter(SavingsApiConstants.subStatusParamName) + .failWithCodeNoParameterAddedToErrorCode("debits.are.not.blocked"); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { + this.sub_status = SavingsAccountSubStatusEnum.BLOCK_CREDIT.getValue(); + } else { + this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue(); + } + actualChanges.put(SavingsApiConstants.subStatusParamName, SavingsEnumerations.subStatus(this.sub_status)); + return actualChanges; + } + + public SavingsAccountStatusType getStatus() { + return SavingsAccountStatusType.fromInt(status); + } + + public Integer getSubStatus() { + return this.sub_status; + } + + /** + * Applies the debit block that follows a lapsed periodic re-KYC. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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() { + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); + if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) { + throw new SavingsAccountBlockedException(this.getId()); + } + } + + public void validateForDebitBlock() { + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); + if (SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(currentSubStatus)) { + throw new SavingsAccountDebitsBlockedException(this.getId()); + } + } + + public void validateForCreditBlock() { + final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus()); + if (SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(currentSubStatus)) { + throw new SavingsAccountCreditsBlockedException(this.getId()); + } + } + + public LocalDate retrieveLastTransactionDate() { + final List transactionsSortedByDate = retrieveListOfTransactions(); + SavingsAccountTransaction lastTransaction = null; + if (transactionsSortedByDate.size() > 0) { + lastTransaction = transactionsSortedByDate.get(transactionsSortedByDate.size() - 1); + } + LocalDate lastransactionDate = null; + if (lastTransaction != null) { + lastransactionDate = lastTransaction.getTransactionDate(); + } + return lastransactionDate; + } + + public LocalDate retrieveLastTransactionDateWithPivotConfig() { + final List transactionsSortedByDate = retrieveSortedTransactions(); + SavingsAccountTransaction lastTransaction = null; + if (transactionsSortedByDate.size() > 0) { + lastTransaction = transactionsSortedByDate.get(transactionsSortedByDate.size() - 1); + } + LocalDate lastransactionDate = null; + if (lastTransaction != null) { + lastransactionDate = lastTransaction.getTransactionDate(); + } + return lastransactionDate; + } + + /** A manual, staff-placed lien (court order, minimum-balance hold, etc.) -- distinct from a + * {@link #HOLD_CATEGORY_CLEARING} hold, which exists because of the clearing process itself rather + * than a decision someone made about this specific account. */ + public static final String HOLD_CATEGORY_CASH = "CASH"; + public static final String HOLD_CATEGORY_CLEARING = "CLEARING"; + + public BigDecimal getSavingsHoldAmount() { + return this.savingsOnHoldAmount == null ? BigDecimal.ZERO : this.savingsOnHoldAmount; + } + + public BigDecimal getSavingsHoldAmountClearing() { + return this.savingsOnHoldAmountClearing == null ? BigDecimal.ZERO : this.savingsOnHoldAmountClearing; + } + + /** Defaults to the manual-lien bucket -- every call site written before the two-category split + * keeps behaving exactly as it did before. */ + public void holdAmount(BigDecimal amount) { + holdAmount(amount, HOLD_CATEGORY_CASH); + } + + public void holdAmount(BigDecimal amount, String category) { + if (HOLD_CATEGORY_CLEARING.equals(category)) { + this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().add(amount); + } else { + this.savingsOnHoldAmount = getSavingsHoldAmount().add(amount); + } + } + + public void releaseOnHoldAmount(BigDecimal amount) { + releaseOnHoldAmount(amount, HOLD_CATEGORY_CASH); + } + + public void releaseOnHoldAmount(BigDecimal amount, String category) { + if (HOLD_CATEGORY_CLEARING.equals(category)) { + this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().subtract(amount); + } else { + this.savingsOnHoldAmount = getSavingsHoldAmount().subtract(amount); + } + } + + public AccountType getAccountType() { + return AccountType.fromInt(accountType); + } + + public Integer getAccountTypes() { + return accountType; + } + + public void setAccountType(Integer accountType) { + this.accountType = accountType; + } + + private boolean isOverdraft() { + return allowOverdraft; + } + + public Long getGroupId() { + return this.groupId(); + } + + public Integer getInterestCalculationDaysInYearType() { + return this.interestCalculationDaysInYearType; + } + + public BigDecimal getMinRequiredOpeningBalance() { + return this.minRequiredOpeningBalance; + } + + public Integer getLockinPeriodFrequency() { + return this.lockinPeriodFrequency; + } + + public Integer getLockinPeriodFrequencyType() { + return this.lockinPeriodFrequencyType; + } + + public boolean isWithdrawalFeeForTransfer() { + return this.withdrawalFeeApplicableForTransfer; + } + + public boolean isAllowOverdraft() { + return this.allowOverdraft; + } + + public BigDecimal getOverdraftLimit() { + return this.overdraftLimit; + } + + public BigDecimal getMinOverdraftForInterestCalculation() { + return this.minOverdraftForInterestCalculation; + } + + public BigDecimal getMinRequiredBalance() { + return this.minRequiredBalance; + } + + public boolean isEnforceMinRequiredBalance() { + return this.enforceMinRequiredBalance; + } + + public BigDecimal getMaxAllowedLienLimit() { + return this.maxAllowedLienLimit; + } + + public boolean isLienAllowed() { + return this.lienAllowed; + } + + public BigDecimal getMinBalanceForInterestCalculation() { + return this.minBalanceForInterestCalculation; + } + + public int getVersion() { + return this.version; + } + + public boolean isWithHoldTax() { + return this.withHoldTax; + } + + public void setAccruedTillDate(LocalDate accruedTillDate) { + this.accruedTillDate = accruedTillDate; + } + + public LocalDate getLastClosedBusinessDate() { + return this.lastClosedBusinessDate; + } + + public void setLastClosedBusinessDate(LocalDate lastClosedBusinessDate) { + this.lastClosedBusinessDate = lastClosedBusinessDate; + } + + public List toSavingsAccountTransactionDetailsForPostingPeriodList( + List transactions) { + return transactions.stream() + .map(transaction -> transaction.toSavingsAccountTransactionDetailsForPostingPeriod(this.currency, this.allowOverdraft)) + .toList(); + } + + public void accrualsForSavingsReverse(SavingsAccountTransactionDTO transactionDTO, final boolean backdatedTxnsAllowedTill) { + List accountTransactionsSorted = null; + + if (backdatedTxnsAllowedTill) { + accountTransactionsSorted = retrieveSortedTransactions(); + } else { + accountTransactionsSorted = retrieveListOfTransactions(); + } + for (final SavingsAccountTransaction transaction : accountTransactionsSorted) { + boolean typeTransaccionValidation = transaction.getTransactionType() == SavingsAccountTransactionType.ACCRUAL; + if (typeTransaccionValidation && (transaction.getDateOf().isAfter(transactionDTO.getTransactionDate()) + || transaction.getDateOf().isEqual(transactionDTO.getTransactionDate()))) { + transaction.reverse(); + } + } + } + + public List toSavingsAccountTransactionDetailsForPostingPeriodList() { + return retreiveOrderedNonInterestPostingTransactions().stream() + .map(transaction -> transaction.toSavingsAccountTransactionDetailsForPostingPeriod(this.currency, this.allowOverdraft)) + .toList(); + } + + @Override + public DepositAccountType depositAccountType() { + return DepositAccountType.fromInt(100); + } + + // ─── CBS extended fields ─────────────────────────────────────────────────── + + @Column(name = "cbs_account_type", length = 100) + private String cbsAccountType; + + @Column(name = "customer_segment", length = 100) + private String customerSegment; + + @Column(name = "account_status", length = 50) + private String accountStatus; + + @Column(name = "mode_of_operation", length = 50) + private String modeOfOperation; + + @Column(name = "special_category", length = 100) + private String specialCategory; + + @Column(name = "weaker_section", length = 100) + private String weakerSection; + + @Column(name = "guardian_name", length = 200) + private String guardianName; + + @Column(name = "guardian_cif_number", length = 50) + private String guardianCifNumber; + + @Column(name = "guardian_relationship", length = 100) + private String guardianRelationship; + + @Column(name = "minor_date_of_birth") + private LocalDate minorDateOfBirth; + + @Column(name = "guardian_client_id") + private Long guardianClientId; + + @Column(name = "introducer_client_id") + private Long introducerClientId; + + @Column(name = "introducer_name", length = 200) + private String introducerName; + + @Column(name = "introducer_cif_number", length = 50) + private String introducerCifNumber; + + @Column(name = "nominee_data", columnDefinition = "TEXT") + private String nomineeData; + + @Column(name = "linked_account_id") + private Long linkedAccountId; + + @Column(name = "linked_account_type", length = 20) + private String linkedAccountType; + + @Column(name = "linked_account_no", length = 50) + private String linkedAccountNo; + + @Column(name = "introducer_required", nullable = false, columnDefinition = "boolean default false") + private Boolean introducerRequired = false; + + @Column(name = "share_linking_enabled", nullable = false, columnDefinition = "boolean default false") + private Boolean shareLinkingEnabled = false; + + /** + * Plain id, not a JPA relationship - fineract-savings cannot depend on the shareaccounts domain + * class (it lives in fineract-provider, which depends on fineract-savings, not the other way + * round). The write side resolves and validates this id against m_share_account directly; this + * column just carries it back to the share account's own {@code savings_account_id}, mirroring + * the link from both ends. + */ + @Column(name = "share_account_id") + private Long shareAccountId; + + @Column(name = "nomination_facility", nullable = false, columnDefinition = "boolean default false") + private Boolean nominationFacility = false; + + @Column(name = "auto_debit_facility", nullable = false, columnDefinition = "boolean default false") + private Boolean autoDebitFacility = false; + + @Column(name = "is_senior_citizen", nullable = false, columnDefinition = "boolean default false") + private boolean isSeniorCitizen = false; + + @Column(name = "senior_citizen_interest_rate", scale = 6, precision = 19, nullable = true) + private BigDecimal seniorCitizenInterestRate; + + @Column(name = "terminate_true", nullable = false, columnDefinition = "boolean default false") + private boolean terminateTrue = false; + + public boolean isTerminateTrue() { return terminateTrue; } + public void setTerminateTrue(boolean terminateTrue) { this.terminateTrue = terminateTrue; } + + public Map terminate() { + final Map actualChanges = new LinkedHashMap<>(); + this.terminateTrue = true; + this.status = SavingsAccountStatusType.TERMINATED.getValue(); + actualChanges.put("terminateTrue", true); + actualChanges.put("status", SavingsAccountStatusType.TERMINATED.getValue()); + return actualChanges; + } + + + @jakarta.persistence.OneToOne(cascade = jakarta.persistence.CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) + private SavingsAccountFacilityConfig facilityConfig; + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) + private List jointHolders = new ArrayList<>(); + + public String getCbsAccountType() { return cbsAccountType; } + public void setCbsAccountType(String v) { this.cbsAccountType = v; } + + public String getCustomerSegment() { return customerSegment; } + public void setCustomerSegment(String v) { this.customerSegment = v; } + + public String getAccountStatus() { return accountStatus; } + public void setAccountStatus(String v) { this.accountStatus = v; } + + public String getModeOfOperation() { return modeOfOperation; } + public void setModeOfOperation(String v) { this.modeOfOperation = v; } + + public String getSpecialCategory() { return specialCategory; } + public void setSpecialCategory(String v) { this.specialCategory = v; } + + public String getWeakerSection() { return weakerSection; } + public void setWeakerSection(String v) { this.weakerSection = v; } + + public String getGuardianName() { return guardianName; } + public void setGuardianName(String v) { this.guardianName = v; } + + public String getGuardianCifNumber() { return guardianCifNumber; } + public void setGuardianCifNumber(String v) { this.guardianCifNumber = v; } + + public String getGuardianRelationship() { return guardianRelationship; } + public void setGuardianRelationship(String v) { this.guardianRelationship = v; } + + public LocalDate getMinorDateOfBirth() { return minorDateOfBirth; } + public void setMinorDateOfBirth(LocalDate v) { this.minorDateOfBirth = v; } + + public Long getGuardianClientId() { return guardianClientId; } + public void setGuardianClientId(Long v) { this.guardianClientId = v; } + + public Long getIntroducerClientId() { return introducerClientId; } + public void setIntroducerClientId(Long v) { this.introducerClientId = v; } + + public String getIntroducerName() { return introducerName; } + public void setIntroducerName(String v) { this.introducerName = v; } + + public String getIntroducerCifNumber() { return introducerCifNumber; } + public void setIntroducerCifNumber(String v) { this.introducerCifNumber = v; } + + public String getNomineeData() { return nomineeData; } + public void setNomineeData(String v) { this.nomineeData = v; } + + public Long getLinkedAccountId() { return linkedAccountId; } + public void setLinkedAccountId(Long v) { this.linkedAccountId = v; } + + public String getLinkedAccountType() { return linkedAccountType; } + public void setLinkedAccountType(String v) { this.linkedAccountType = v; } + + public String getLinkedAccountNo() { return linkedAccountNo; } + public void setLinkedAccountNo(String v) { this.linkedAccountNo = v; } + + public SavingsAccountFacilityConfig getFacilityConfig() { return facilityConfig; } + public void setFacilityConfig(SavingsAccountFacilityConfig v) { this.facilityConfig = v; } + + public List getJointHolders() { return jointHolders; } + public void setJointHolders(List v) { this.jointHolders = v; } + + public Boolean getNominationFacility() { return nominationFacility; } + public void setNominationFacility(Boolean v) { this.nominationFacility = v; } + + public Boolean getAutoDebitFacility() { return autoDebitFacility; } + public void setAutoDebitFacility(Boolean v) { this.autoDebitFacility = v; } + + public Boolean getIntroducerRequired() { return introducerRequired; } + public void setIntroducerRequired(Boolean v) { this.introducerRequired = v; } + + public Boolean getShareLinkingEnabled() { return shareLinkingEnabled; } + public void setShareLinkingEnabled(Boolean v) { this.shareLinkingEnabled = v; } + + public Long getShareAccountId() { return shareAccountId; } + public void setShareAccountId(Long v) { this.shareAccountId = v; } + + public boolean isSeniorCitizen() { return isSeniorCitizen; } + public void setSeniorCitizen(boolean v) { this.isSeniorCitizen = v; } + + public BigDecimal getSeniorCitizenInterestRate() { return seniorCitizenInterestRate; } + public void setSeniorCitizenInterestRate(BigDecimal v) { this.seniorCitizenInterestRate = v; } + + public void updateNominalAnnualInterestRate(BigDecimal rate) { + if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) { + this.nominalAnnualInterestRate = rate; + } + } +} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java index dc3b0bfbd..a5dffae56 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountFacilityConfig.java @@ -75,9 +75,6 @@ public class SavingsAccountFacilityConfig extends AbstractPersistableCustom { @Column(name = "inter_bank_total_cap_facility") private Boolean interBankTotalCapFacility; - @Column(name = "withdrawal_facility") - private Boolean withdrawalFacility; - @Column(name = "deposit_facility_kyc") private Boolean depositFacilityKyc; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java index 62387acc1..df6a4fc9c 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductAssembler.java @@ -371,8 +371,6 @@ public class SavingsProductAssembler { facilityConfigJson.get("rtgsFacility").getAsBoolean() : false); config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false); - config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ? - facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false); config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false); config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductFacilityConfig.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductFacilityConfig.java index 20e5180d7..dfb95deab 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductFacilityConfig.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProductFacilityConfig.java @@ -75,9 +75,6 @@ public class SavingsProductFacilityConfig extends AbstractPersistableCustom + * 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. + *

+ * 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 @RequiredArgsConstructor @Slf4j -public class FacilityChargeSeederService { +@DependsOn({ "tenantDetailsService", "tenantDatabaseUpgradeService" }) +public class FacilityChargeSeederService implements InitializingBean { private final JdbcTemplate jdbcTemplate; + private final TenantDetailsService tenantDetailsService; // facilityType → [chargeName, defaultAmount, chargeTimeType, chargeAppliesTo] // 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("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("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("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. + *

+ * 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 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() { try { for (Map.Entry 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 - // facility type, but leave it unmapped/inactive — an admin must explicitly pick a charge - // for it via the Facility Charge Mappings screen before it can be enabled on an account. + // facility type. Inserted inactive and unmapped; step 3 below fills in the charge. List existingMapping = jdbcTemplate.queryForList( "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); } + + // 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."); } 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); } } } diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml index d2b0790a5..95f04b653 100644 --- a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml @@ -1,57 +1,58 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2005_add_savings_product_classification_code_values.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2005_add_savings_product_classification_code_values.xml index c25266bbf..0d8343c97 100644 --- a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2005_add_savings_product_classification_code_values.xml +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2005_add_savings_product_classification_code_values.xml @@ -188,4 +188,21 @@ ); + + + + 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' + ); + + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_remove_withdrawal_facility.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_remove_withdrawal_facility.xml new file mode 100644 index 000000000..702128aef --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2032_remove_withdrawal_facility.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + DELETE FROM m_facility_charge_mapping WHERE facility_type = 'WITHDRAWAL_FACILITY'; + + + + + + + + + + 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; + + + + + + + SELECT COUNT(*) FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = false + + + UPDATE m_charge SET is_active = false, is_deleted = true + WHERE name = 'Withdrawal Facility Charge'; + + + + + + + + + + + + + UPDATE m_savings_account_facility_config SET withdrawal_facility = false WHERE withdrawal_facility = true; + + + + + + + + + + + + UPDATE m_savings_product_facility_config SET withdrawal_facility = false WHERE withdrawal_facility = true; + + + +