Compare commits
88
Commits
uate
...
feature/dds
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0e3c0ac8b | ||
|
|
fa8e5f3f16 | ||
|
|
12fdf035ca | ||
|
|
fde2699957 | ||
|
|
dd4bb4a1c0 | ||
|
|
6046134b51 | ||
|
|
e4fad8b648 | ||
|
|
e206536a6b | ||
|
|
bf52c2cda1 | ||
|
|
934c11bf87 | ||
|
|
c1e6e036d0 | ||
|
|
4c0512f154 | ||
|
|
b779d04571 | ||
|
|
184d1b8cf8 | ||
|
|
521d61d6b7 | ||
|
|
af43944699 | ||
|
|
39fa100c70 | ||
|
|
1c6ac9d77f | ||
|
|
6bb512e1fe | ||
|
|
04ac233fbe | ||
|
|
9e0c04583d | ||
|
|
c20a7d0728 | ||
|
|
3d196276c8 | ||
|
|
5cc5d680f2 | ||
|
|
6aa655a8a3 | ||
|
|
518b4d4afc | ||
|
|
05bceea2d4 | ||
|
|
e33e0f23fe | ||
|
|
317a308d8c | ||
|
|
a83806062e | ||
|
|
e4cc2e558e | ||
|
|
a04b65583e | ||
|
|
f6686b0c8f | ||
|
|
45c2826a42 | ||
|
|
c86e0d1cc2 | ||
|
|
c052beeacf | ||
|
|
942bad6c8c | ||
|
|
5a9a103832 | ||
|
|
fd91da3d76 | ||
|
|
c4a99fea5d | ||
|
|
dc0539d53d | ||
|
|
509370eaeb | ||
|
|
1130a482a0 | ||
|
|
8ecab08d57 | ||
|
|
29677e610c | ||
|
|
6ffb0dd9c0 | ||
|
|
8d224a22b4 | ||
|
|
f746a1fb2c | ||
|
|
1bc9d0901d | ||
|
|
08519da8b1 | ||
|
|
58187c23d0 | ||
|
|
179fe3a6ce | ||
|
|
72221f704d | ||
|
|
a42a874811 | ||
|
|
cad7f54c47 | ||
|
|
1a2a945464 | ||
|
|
73ec85cfd6 | ||
|
|
37c961f713 | ||
|
|
46f5a1babd | ||
|
|
20fe11e60d | ||
|
|
eb1aa767f7 | ||
|
|
8724c54d9b | ||
|
|
5c0a482f49 | ||
|
|
0e07e60328 | ||
|
|
e227e8ae0f | ||
|
|
231919b62b | ||
|
|
9eac40bb5c | ||
|
|
14b2585cf5 | ||
|
|
a2fa8688ae | ||
|
|
a8ca7dd948 | ||
|
|
c8833367b9 | ||
|
|
94b0afff38 | ||
|
|
9dd4f8bedb | ||
|
|
1107610c08 | ||
|
|
2213308254 | ||
|
|
c568eb0abf | ||
|
|
1426be9531 | ||
|
|
541c6ab062 | ||
|
|
66a6a4766f | ||
|
|
b9aa29fb35 | ||
|
|
1a9adaec24 | ||
|
|
2da5a25e04 | ||
|
|
baf30cff68 | ||
|
|
f1d68d17fe | ||
|
|
84f8aa2274 | ||
|
|
7773a9e9c6 | ||
|
|
c890faee78 | ||
|
|
abc7eb22a2 |
+1
-1
@@ -159,7 +159,7 @@ gitVersioning.apply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ext['groovy.version'] = '4.0.17'
|
ext['groovy.version'] = '4.0.17'
|
||||||
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString()
|
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString().replace(" ", "%20")
|
||||||
|
|
||||||
// Apply static weaving configuration to all subprojects
|
// Apply static weaving configuration to all subprojects
|
||||||
subprojects { subproject ->
|
subprojects { subproject ->
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- Removes the duplicate vouchers left behind by the double-count bug.
|
||||||
|
--
|
||||||
|
-- Until 2026-09-03 the cashier deposit and clerk withdrawal registers did not wrap their inner
|
||||||
|
-- savings command in VoucherService.withOwnVoucher(...), so one counter transaction raised two
|
||||||
|
-- vouchers over the same money: the register's own (CASHIER_DEPOSIT_REQUEST / CLERK_WITHDRAWAL_
|
||||||
|
-- REQUEST) and a second one from the savings command (SAVINGS_TRANSACTION). The cashier's Received
|
||||||
|
-- and Payment reports counted both, doubling the day's cash.
|
||||||
|
--
|
||||||
|
-- The code is fixed, so no new duplicates are created. This clears the ones already recorded.
|
||||||
|
--
|
||||||
|
-- WHICH ONE IS DELETED: the SAVINGS_TRANSACTION voucher. The register's voucher is the keeper --
|
||||||
|
-- it is the one the register owns, attaches its DR/CR lines to, and stamps onto
|
||||||
|
-- m_savings_account_transaction.voucher_no. Deleting the register's voucher instead would break
|
||||||
|
-- that link and orphan the request row.
|
||||||
|
--
|
||||||
|
-- Run: psql -h localhost -U postgres -d fineract_default -v ON_ERROR_STOP=1 -f cleanup_duplicate_vouchers.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- The duplicates: a SAVINGS_TRANSACTION voucher whose transaction is already claimed by a
|
||||||
|
-- register voucher. Matching through savings_txn_id rather than by date or amount so that two
|
||||||
|
-- genuinely separate transactions of the same value on the same day are never confused.
|
||||||
|
CREATE TEMP TABLE dup_vouchers ON COMMIT DROP AS
|
||||||
|
SELECT sav.voucher_no
|
||||||
|
FROM m_voucher sav
|
||||||
|
JOIN m_voucher reg
|
||||||
|
ON reg.savings_txn_id = sav.reference_id
|
||||||
|
AND reg.reference_type IN ('CLERK_WITHDRAWAL_REQUEST', 'CASHIER_DEPOSIT_REQUEST')
|
||||||
|
WHERE sav.reference_type = 'SAVINGS_TRANSACTION'
|
||||||
|
AND sav.voucher_type IN ('DEPOSIT', 'WITHDRAWAL');
|
||||||
|
|
||||||
|
-- Refuse to run if anything else has attached itself to a voucher about to be deleted: a
|
||||||
|
-- verification or an attachment means someone acted on that slip, and it must be looked at by hand
|
||||||
|
-- rather than removed silently.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
blocked INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO blocked
|
||||||
|
FROM (SELECT voucher_no FROM m_voucher_verification
|
||||||
|
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)
|
||||||
|
UNION ALL
|
||||||
|
SELECT voucher_no FROM m_voucher_attachment
|
||||||
|
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)) x;
|
||||||
|
IF blocked > 0 THEN
|
||||||
|
RAISE EXCEPTION 'Aborting: % duplicate voucher(s) carry a verification or attachment. Review these by hand.', blocked;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Likewise refuse if a savings transaction is stamped with a voucher about to be deleted. It should
|
||||||
|
-- carry the register's number, not the duplicate's; if it does not, the pair needs looking at.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
stamped INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO stamped FROM m_savings_account_transaction
|
||||||
|
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
||||||
|
IF stamped > 0 THEN
|
||||||
|
RAISE EXCEPTION 'Aborting: % savings transaction(s) are stamped with a duplicate voucher number.', stamped;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
SELECT voucher_no AS "about to delete" FROM dup_vouchers ORDER BY voucher_no;
|
||||||
|
|
||||||
|
DELETE FROM m_voucher_line WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
||||||
|
DELETE FROM m_voucher WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -127,14 +127,6 @@ SELECT 'Inter-Bank Cap Charge', 'INR', 0.00, 2, 3, 1, 0, 1, 0, 0, 0,
|
|||||||
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
|
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0);
|
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0);
|
||||||
|
|
||||||
INSERT IGNORE INTO m_charge
|
|
||||||
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
|
|
||||||
charge_calculation_enum, is_penalty, is_active, is_deleted,
|
|
||||||
is_free_withdrawal, is_payment_type, income_or_liability_account_id)
|
|
||||||
SELECT 'Withdrawal Facility Charge', 'INR', 5.00, 2, 3, 1, 0, 1, 0, 0, 0,
|
|
||||||
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0);
|
|
||||||
|
|
||||||
INSERT IGNORE INTO m_charge
|
INSERT IGNORE INTO m_charge
|
||||||
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
|
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
|
||||||
charge_calculation_enum, is_penalty, is_active, is_deleted,
|
charge_calculation_enum, is_penalty, is_active, is_deleted,
|
||||||
@@ -168,6 +160,5 @@ VALUES
|
|||||||
('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
||||||
('RTGS_FACILITY', 'RTGS Facility Charge', 25.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'RTGS Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
('RTGS_FACILITY', 'RTGS Facility Charge', 25.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'RTGS Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
||||||
('INTER_BANK_TOTAL_CAP','Inter-Bank Cap Charge', 0.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0 LIMIT 1)),
|
('INTER_BANK_TOTAL_CAP','Inter-Bank Cap Charge', 0.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0 LIMIT 1)),
|
||||||
('WITHDRAWAL_FACILITY', 'Withdrawal Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
|
||||||
('DEPOSIT_FACILITY_KYC','Deposit KYC Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Deposit KYC Charge' AND is_deleted = 0 LIMIT 1)),
|
('DEPOSIT_FACILITY_KYC','Deposit KYC Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Deposit KYC Charge' AND is_deleted = 0 LIMIT 1)),
|
||||||
('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1));
|
('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1));
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ ALTER TABLE m_current_product_facility_config
|
|||||||
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
|
|
||||||
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
|
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ ALTER TABLE m_current_product_facility_config
|
|||||||
ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_cpfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_cpfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_cpfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_cpfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_cpfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
|
|
||||||
ADD CONSTRAINT fk_cpfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_cpfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_cpfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
|
ADD CONSTRAINT fk_cpfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
|
||||||
|
|
||||||
@@ -55,7 +53,6 @@ ALTER TABLE m_savings_product_facility_config
|
|||||||
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
|
|
||||||
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
|
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
|
||||||
|
|
||||||
@@ -74,6 +71,5 @@ ALTER TABLE m_savings_product_facility_config
|
|||||||
ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_spfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_spfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_spfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_spfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_spfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
|
|
||||||
ADD CONSTRAINT fk_spfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_spfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
|
ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
|
||||||
|
|||||||
+20
-9
@@ -45,6 +45,22 @@ public final class FinancialActivityAccountDataValidator {
|
|||||||
*/
|
*/
|
||||||
private final Set<String> supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues();
|
private final Set<String> supportedParameters = FinancialActivityAccountsJsonInputParams.getAllValues();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activities an administrator may map from the Financial Activity Mappings screen.
|
||||||
|
*
|
||||||
|
* Held in one place because create and update both validate against it, and the two lists had already drifted -
|
||||||
|
* update omitted the two cash activities, and neither offered the dividend activities beyond PAYABLE_DIVIDENDS even
|
||||||
|
* though the accounting code reads them. An activity the posting code resolves but this list omits can only ever be
|
||||||
|
* seeded by a migration, which is how DIVIDEND_PAID and RESERVE_FOR_DIVIDEND came to exist without being editable.
|
||||||
|
*/
|
||||||
|
private static final Object[] MAPPABLE_FINANCIAL_ACTIVITIES = { FinancialActivity.ASSET_TRANSFER.getValue(),
|
||||||
|
FinancialActivity.LIABILITY_TRANSFER.getValue(), FinancialActivity.CASH_AT_MAINVAULT.getValue(),
|
||||||
|
FinancialActivity.CASH_AT_TELLER.getValue(), FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(),
|
||||||
|
FinancialActivity.ASSET_FUND_SOURCE.getValue(), FinancialActivity.PAYABLE_DIVIDENDS.getValue(),
|
||||||
|
FinancialActivity.DIVIDEND_PAID.getValue(), FinancialActivity.RESERVE_FOR_DIVIDEND.getValue(),
|
||||||
|
FinancialActivity.DIVIDEND_FORFEITED_INCOME.getValue(), FinancialActivity.DISTRIBUTABLE_PROFIT.getValue(),
|
||||||
|
FinancialActivity.RETAINED_PROFIT.getValue(), FinancialActivity.DIVIDEND_ROUND_OFF.getValue() };
|
||||||
|
|
||||||
private final String paramNameForFinancialActivity = FinancialActivityAccountsJsonInputParams.FINANCIAL_ACTIVITY_ID.getValue();
|
private final String paramNameForFinancialActivity = FinancialActivityAccountsJsonInputParams.FINANCIAL_ACTIVITY_ID.getValue();
|
||||||
private final String paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue();
|
private final String paramNameForGLAccount = FinancialActivityAccountsJsonInputParams.GL_ACCOUNT_ID.getValue();
|
||||||
|
|
||||||
@@ -59,11 +75,8 @@ public final class FinancialActivityAccountDataValidator {
|
|||||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
||||||
|
|
||||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element);
|
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity, element);
|
||||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull().isOneOfTheseValues(
|
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).notNull()
|
||||||
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
|
||||||
FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue(),
|
|
||||||
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
|
|
||||||
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
|
|
||||||
|
|
||||||
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
|
final Long glAccountId = this.fromApiJsonHelper.extractLongNamed(paramNameForGLAccount, element);
|
||||||
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
|
baseDataValidator.reset().parameter(paramNameForGLAccount).value(glAccountId).notNull().integerGreaterThanZero();
|
||||||
@@ -86,10 +99,8 @@ public final class FinancialActivityAccountDataValidator {
|
|||||||
if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) {
|
if (this.fromApiJsonHelper.parameterExists(paramNameForFinancialActivity, element)) {
|
||||||
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity,
|
final Integer financialActivityId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(paramNameForFinancialActivity,
|
||||||
element);
|
element);
|
||||||
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull().isOneOfTheseValues(
|
baseDataValidator.reset().parameter(paramNameForFinancialActivity).value(financialActivityId).ignoreIfNull()
|
||||||
FinancialActivity.ASSET_TRANSFER.getValue(), FinancialActivity.LIABILITY_TRANSFER.getValue(),
|
.isOneOfTheseValues(MAPPABLE_FINANCIAL_ACTIVITIES);
|
||||||
FinancialActivity.OPENING_BALANCES_TRANSFER_CONTRA.getValue(), FinancialActivity.ASSET_FUND_SOURCE.getValue(),
|
|
||||||
FinancialActivity.PAYABLE_DIVIDENDS.getValue());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) {
|
if (this.fromApiJsonHelper.parameterExists(paramNameForGLAccount, element)) {
|
||||||
|
|||||||
+5
@@ -37,6 +37,7 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
|||||||
DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, //
|
DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, //
|
||||||
GL_ACCOUNT_DISABLED, //
|
GL_ACCOUNT_DISABLED, //
|
||||||
GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, //
|
GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, //
|
||||||
|
GL_ACCOUNT_IS_HEADER, //
|
||||||
INVALID_DEBIT_OR_CREDIT_ACCOUNTS; //
|
INVALID_DEBIT_OR_CREDIT_ACCOUNTS; //
|
||||||
|
|
||||||
public String errorMessage() {
|
public String errorMessage() {
|
||||||
@@ -58,6 +59,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
|||||||
return "Invalid debit or credit accounts are passed";
|
return "Invalid debit or credit accounts are passed";
|
||||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
||||||
return "Target account does not allow manual adjustments";
|
return "Target account does not allow manual adjustments";
|
||||||
|
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
|
||||||
|
return "Target account is a Header account (a chart-of-accounts grouping node) and cannot receive a transaction directly";
|
||||||
}
|
}
|
||||||
return name();
|
return name();
|
||||||
}
|
}
|
||||||
@@ -79,6 +82,8 @@ public class JournalEntryInvalidException extends AbstractPlatformDomainRuleExce
|
|||||||
return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts";
|
return "error.msg.glJournalEntry.invalid.debit.or.credit.accounts";
|
||||||
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
} else if (name().equalsIgnoreCase("GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED")) {
|
||||||
return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted";
|
return "error.msg.glJournalEntry.invalid.account.manual.adjustments.not.permitted";
|
||||||
|
} else if (name().equalsIgnoreCase("GL_ACCOUNT_IS_HEADER")) {
|
||||||
|
return "error.msg.glJournalEntry.invalid.account.is.header";
|
||||||
}
|
}
|
||||||
return name();
|
return name();
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-17
@@ -181,10 +181,14 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
|||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
|
||||||
.notNull().integerGreaterThanZero();
|
.notNull().integerGreaterThanZero();
|
||||||
|
|
||||||
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
// Transfers In Suspense is not offered by every savings product UI, so it is validated only
|
||||||
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
// when the client actually sends it. Omitting it leaves the mapping unset rather than failing.
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
|
||||||
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
||||||
|
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
||||||
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
||||||
|
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
||||||
|
}
|
||||||
|
|
||||||
final Long interestOnSavingsAccountId = this.fromApiJsonHelper
|
final Long interestOnSavingsAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
|
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
|
||||||
@@ -210,20 +214,29 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
|
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
|
||||||
final Long overdraftAccount = this.fromApiJsonHelper
|
// Overdraft Control / Income From Interest / Write Off apply to overdraft-capable products
|
||||||
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
|
// only, so they are validated when supplied rather than demanded from every savings product.
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(),
|
||||||
.value(overdraftAccount).notNull().integerGreaterThanZero();
|
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
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) {
|
||||||
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
|
final Long incomeFromInterest = this.fromApiJsonHelper
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()).value(incomeFromInterest)
|
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
|
||||||
.notNull().integerGreaterThanZero();
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
|
||||||
|
.value(incomeFromInterest).notNull().integerGreaterThanZero();
|
||||||
|
}
|
||||||
|
|
||||||
final Long writtenOff = this.fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) {
|
||||||
element);
|
final Long writtenOff = this.fromApiJsonHelper
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull()
|
.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
||||||
.integerGreaterThanZero();
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff)
|
||||||
|
.notNull().integerGreaterThanZero();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -266,9 +279,15 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
|||||||
|
|
||||||
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)) {
|
if (AccountingValidations.isCashBasedAccounting(accountingRuleType)) {
|
||||||
|
|
||||||
|
// Optional, not mandatory like the three below: it names an account for cash physically
|
||||||
|
// received at the counter for a share purchase, which every register in this bank now
|
||||||
|
// books against the account the money actually reached instead. Left absent, a product
|
||||||
|
// simply carries no such mapping, and any code path that still needs one (a genuine
|
||||||
|
// over-the-counter collection) fails with a named "GL not mapped" error rather than
|
||||||
|
// silently assuming the till.
|
||||||
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
|
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
|
||||||
element);
|
element);
|
||||||
baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId).notNull()
|
baseDataValidator.reset().parameter(SharesProductAccountingParams.SHARES_REFERENCE.getValue()).value(shareReferenceId)
|
||||||
.integerGreaterThanZero();
|
.integerGreaterThanZero();
|
||||||
|
|
||||||
final Long shareSuspenseId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_SUSPENSE.getValue(),
|
final Long shareSuspenseId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_SUSPENSE.getValue(),
|
||||||
|
|||||||
+4
@@ -426,6 +426,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
|||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
||||||
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) {
|
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
||||||
|
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.ROUNDING_ADJUSTMENT)) {
|
||||||
|
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
|
||||||
// Expense
|
// Expense
|
||||||
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
} else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
||||||
@@ -470,6 +472,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
|||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData);
|
||||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) {
|
} else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData);
|
||||||
|
} else if (glAccountForSavings.equals(CashAccountsForSavings.ROUNDING_ADJUSTMENT)) {
|
||||||
|
accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData);
|
||||||
// Expense
|
// Expense
|
||||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) {
|
||||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData);
|
||||||
|
|||||||
+18
@@ -106,6 +106,20 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
|||||||
PortfolioProductType.SAVING);
|
PortfolioProductType.SAVING);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create-or-merge, for an expense row a product may not have yet.
|
||||||
|
*
|
||||||
|
* The plain merge variant assumes the mapping already exists, which is true of every expense
|
||||||
|
* row that shipped with the product screen. A row added later — the rounding adjustment — is
|
||||||
|
* absent on every product created before it, so editing one of those has to create the
|
||||||
|
* mapping rather than fail looking for it.
|
||||||
|
*/
|
||||||
|
public void createOrmergeSavingsToExpenseAccountMappingChanges(final JsonElement element, final String paramName,
|
||||||
|
final Long productId, final int accountTypeId, final Map<String, Object> changes) {
|
||||||
|
createOrmergeProductToAccountMappingChanges(element, paramName, productId, accountTypeId, changes, GLAccountType.EXPENSE,
|
||||||
|
PortfolioProductType.SAVING);
|
||||||
|
}
|
||||||
|
|
||||||
/*** Abstractions for payments channel related to savings products ***/
|
/*** Abstractions for payments channel related to savings products ***/
|
||||||
|
|
||||||
public void savePaymentChannelToFundSourceMappings(final JsonCommand command, final JsonElement element, final Long productId,
|
public void savePaymentChannelToFundSourceMappings(final JsonCommand command, final JsonElement element, final Long productId,
|
||||||
@@ -248,6 +262,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
|||||||
CashAccountsForSavings.TRANSFERS_SUSPENSE.toString(), changes);
|
CashAccountsForSavings.TRANSFERS_SUSPENSE.toString(), changes);
|
||||||
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
||||||
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
||||||
|
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
||||||
|
savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
||||||
break;
|
break;
|
||||||
case ACCRUAL_PERIODIC:
|
case ACCRUAL_PERIODIC:
|
||||||
// asset
|
// asset
|
||||||
@@ -308,6 +324,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa
|
|||||||
|
|
||||||
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(),
|
||||||
savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes);
|
||||||
|
createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(),
|
||||||
|
savingsProductId, AccrualAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case ACCRUAL_UPFRONT:
|
case ACCRUAL_UPFRONT:
|
||||||
|
|||||||
+35
-3
@@ -27,6 +27,7 @@ import org.apache.fineract.accounting.common.AccountingRuleType;
|
|||||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
|
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
|
||||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
|
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
|
||||||
|
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping;
|
||||||
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
|
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
|
||||||
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
@@ -166,9 +167,9 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
|
|||||||
case NONE:
|
case NONE:
|
||||||
break;
|
break;
|
||||||
case CASH_BASED:
|
case CASH_BASED:
|
||||||
// asset
|
// asset -- the one optional mapping here, so unlike the three below it needs its own
|
||||||
mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
|
// handling for the case the field is now absent (see mergeOrRemoveSharesReferenceMapping).
|
||||||
CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
|
mergeOrRemoveSharesReferenceMapping(element, sharesProductId, changes);
|
||||||
|
|
||||||
// income
|
// income
|
||||||
mergeSharesToIncomeAccountMappingChanges(element, SharesProductAccountingParams.INCOME_FROM_FEES.getValue(),
|
mergeSharesToIncomeAccountMappingChanges(element, SharesProductAccountingParams.INCOME_FROM_FEES.getValue(),
|
||||||
@@ -194,4 +195,35 @@ public class ShareProductToGLAccountMappingHelper extends ProductToGLAccountMapp
|
|||||||
public void deleteSharesProductToGLAccountMapping(final Long sharesProductId) {
|
public void deleteSharesProductToGLAccountMapping(final Long sharesProductId) {
|
||||||
deleteProductToGLAccountMapping(sharesProductId, PortfolioProductType.SHARES);
|
deleteProductToGLAccountMapping(sharesProductId, PortfolioProductType.SHARES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the one optional share-product mapping, Shares Reference, on an edit that does not change
|
||||||
|
* the accounting rule.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The other three mappings (Suspense, Income From Fees, Equity) are mandatory, so
|
||||||
|
* {@code mergeProductToAccountMappingChanges} only ever needs to handle "present and possibly
|
||||||
|
* changed" for them -- a null value there cannot happen without failing validation first. Shares
|
||||||
|
* Reference can legitimately be absent, and the shared merge helper (used across loan, savings and
|
||||||
|
* share products alike) only handles "present": it silently leaves an existing mapping row alone
|
||||||
|
* when the incoming value is null, rather than clearing it. Editing this one field out of an
|
||||||
|
* existing product would otherwise leave the product still carrying its old GL underneath, invisible
|
||||||
|
* on the edit form but still resolvable by any code path that looks it up directly.
|
||||||
|
*/
|
||||||
|
private void mergeOrRemoveSharesReferenceMapping(final JsonElement element, final Long sharesProductId,
|
||||||
|
final Map<String, Object> changes) {
|
||||||
|
final Long shareReferenceId = this.fromApiJsonHelper.extractLongNamed(SharesProductAccountingParams.SHARES_REFERENCE.getValue(),
|
||||||
|
element);
|
||||||
|
if (shareReferenceId != null) {
|
||||||
|
mergeSharesToAssetAccountMappingChanges(element, SharesProductAccountingParams.SHARES_REFERENCE.getValue(), sharesProductId,
|
||||||
|
CashAccountsForShares.SHARES_REFERENCE.getValue(), CashAccountsForShares.SHARES_REFERENCE.toString(), changes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ProductToGLAccountMapping existing = this.accountMappingRepository.findCoreProductToFinAccountMapping(sharesProductId,
|
||||||
|
PortfolioProductType.SHARES.getValue(), CashAccountsForShares.SHARES_REFERENCE.getValue());
|
||||||
|
if (existing != null) {
|
||||||
|
this.accountMappingRepository.delete(existing);
|
||||||
|
changes.put(SharesProductAccountingParams.SHARES_REFERENCE.getValue(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -54,6 +54,7 @@
|
|||||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||||
|
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||||
|
|||||||
+1
@@ -54,6 +54,7 @@
|
|||||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||||
|
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||||
|
|||||||
+1
@@ -54,6 +54,7 @@
|
|||||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||||
|
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ task cleanupGeneratedJavaFiles() {
|
|||||||
// Configure Java compilation
|
// Configure Java compilation
|
||||||
tasks.named('compileJava') {
|
tasks.named('compileJava') {
|
||||||
outputs.cacheIf { true }
|
outputs.cacheIf { true }
|
||||||
dependsOn(buildJavaSdk, buildTypescriptAngularSdk, buildAsciidoc, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
||||||
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
|
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||||
|
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||||
|
|||||||
+45
-5
@@ -269,7 +269,15 @@ public final class AccountingConstants {
|
|||||||
OVERDRAFT_PORTFOLIO_CONTROL(11), //
|
OVERDRAFT_PORTFOLIO_CONTROL(11), //
|
||||||
INCOME_FROM_INTEREST(12), //
|
INCOME_FROM_INTEREST(12), //
|
||||||
LOSSES_WRITTEN_OFF(13), //
|
LOSSES_WRITTEN_OFF(13), //
|
||||||
ESCHEAT_LIABILITY(14); //
|
ESCHEAT_LIABILITY(14), //
|
||||||
|
/**
|
||||||
|
* Where the paise dropped from a cash closure are absorbed.
|
||||||
|
*
|
||||||
|
* Cash has no paise in circulation, so a closing balance of 9,883.79 is handed over as
|
||||||
|
* 9,883 and the 0.79 has to land somewhere other than the member's own ledger. Numbered
|
||||||
|
* well clear of the accrual slots so the two sets never collide as either grows.
|
||||||
|
*/
|
||||||
|
ROUNDING_ADJUSTMENT(26); //
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
@@ -317,7 +325,9 @@ public final class AccountingConstants {
|
|||||||
FEES_RECEIVABLE(15), //
|
FEES_RECEIVABLE(15), //
|
||||||
PENALTIES_RECEIVABLE(16), //
|
PENALTIES_RECEIVABLE(16), //
|
||||||
INTEREST_PAYABLE(17), //
|
INTEREST_PAYABLE(17), //
|
||||||
INTEREST_RECEIVABLE(18);
|
INTEREST_RECEIVABLE(18), //
|
||||||
|
/** Where the paise dropped from a cash closure are absorbed — see the cash enum. */
|
||||||
|
ROUNDING_ADJUSTMENT(26);
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
@@ -372,7 +382,8 @@ public final class AccountingConstants {
|
|||||||
PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), //
|
PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), //
|
||||||
INTEREST_RECEIVABLE("interestReceivableAccountId"), //
|
INTEREST_RECEIVABLE("interestReceivableAccountId"), //
|
||||||
FEES_RECEIVABLE("feesReceivableAccountId"), //
|
FEES_RECEIVABLE("feesReceivableAccountId"), //
|
||||||
INTEREST_PAYABLE("interestPayableAccountId");
|
INTEREST_PAYABLE("interestPayableAccountId"), //
|
||||||
|
ROUNDING_ADJUSTMENT("roundingAdjustmentAccountId");
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
|
|
||||||
@@ -410,7 +421,8 @@ public final class AccountingConstants {
|
|||||||
FEES_RECEIVABLE("feeReceivableAccount"), //
|
FEES_RECEIVABLE("feeReceivableAccount"), //
|
||||||
PENALTIES_RECEIVABLE("penaltyReceivableAccount"), //
|
PENALTIES_RECEIVABLE("penaltyReceivableAccount"), //
|
||||||
INTEREST_PAYABLE("interestPayableAccount"), //
|
INTEREST_PAYABLE("interestPayableAccount"), //
|
||||||
INTEREST_RECEIVABLE("interestReceivableAccount"); //
|
INTEREST_RECEIVABLE("interestReceivableAccount"), //
|
||||||
|
ROUNDING_ADJUSTMENT("roundingAdjustmentAccount"); //
|
||||||
|
|
||||||
private final String value;
|
private final String value;
|
||||||
|
|
||||||
@@ -436,7 +448,35 @@ public final class AccountingConstants {
|
|||||||
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
|
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
|
||||||
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
||||||
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
||||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY); //
|
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||||
|
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
|
||||||
|
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||||
|
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME), //
|
||||||
|
/**
|
||||||
|
* The year's closed-out profit a dividend is declared against.
|
||||||
|
*
|
||||||
|
* Kept apart from {@link #RETAINED_PROFIT} on purpose: a declaration is sized on this balance, so anything that
|
||||||
|
* has already been through a distribution decision must not sit here, or the same money would be offered for
|
||||||
|
* distribution a second time.
|
||||||
|
*/
|
||||||
|
DISTRIBUTABLE_PROFIT(302, "distributableProfit", GLAccountType.EQUITY), //
|
||||||
|
/**
|
||||||
|
* Where profit settles once it is no longer distributable - the remainder left after an approved declaration
|
||||||
|
* and dividends forfeited after the three-year claim window. Nothing here ever enters a later dividend
|
||||||
|
* calculation.
|
||||||
|
*/
|
||||||
|
RETAINED_PROFIT(303, "retainedProfit", GLAccountType.EQUITY), //
|
||||||
|
/**
|
||||||
|
* Where the paise a dividend cannot divide evenly are collected.
|
||||||
|
*
|
||||||
|
* A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 that belongs to nobody. It used to be
|
||||||
|
* swept into retained profit along with the undistributed remainder, which buried an arithmetic artefact inside
|
||||||
|
* a figure the society reads as a real business result. Given its own account it stays separately readable -
|
||||||
|
* and an auditor asking why members received less than was declared can be answered from the ledger.
|
||||||
|
*
|
||||||
|
* Optional: a society that has not mapped it has the residue swept to retained profit exactly as before.
|
||||||
|
*/
|
||||||
|
DIVIDEND_ROUND_OFF(304, "dividendRoundOff", GLAccountType.EQUITY); //
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
private final String code;
|
private final String code;
|
||||||
|
|||||||
+32
-1
@@ -137,7 +137,7 @@ public class CommandSourceService {
|
|||||||
String permission = commandSource.getPermissionCode();
|
String permission = commandSource.getPermissionCode();
|
||||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||||
if (isApprovedByChecker || user.isCheckerSuperUser()) {
|
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||||
commandSource.markAsChecked(user);
|
commandSource.markAsChecked(user);
|
||||||
} else {
|
} else {
|
||||||
if (commandSource.isSanitized()) {
|
if (commandSource.isSanitized()) {
|
||||||
@@ -151,6 +151,37 @@ public class CommandSourceService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||||
|
if (!user.isCheckerSuperUser()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| isDdsEntity(commandSource.getEntityName())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DDS module's maker-checker entities. A super user's DDS work goes to a checker like anyone
|
||||||
|
* else's: DDS moves customers' money, and every such move is to be approved by a second person.
|
||||||
|
*/
|
||||||
|
public static boolean isDdsEntity(final String entityName) {
|
||||||
|
return entityName != null && switch (entityName.toUpperCase()) {
|
||||||
|
case "PARTY", "AGENT", "PARTYTRANSACTION", "DDSACCOUNTTYPE", "COMMISSIONSLAB", "DDSBATCH", "DDSCOLLECTIONENTRY" -> true;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+194
@@ -664,6 +664,175 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createAgent() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "AGENT";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/agents/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateAgent(final Long agentId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "AGENT";
|
||||||
|
this.entityId = agentId;
|
||||||
|
this.href = "/dds/agents/" + agentId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder closeAgent(final Long agentId) {
|
||||||
|
this.actionName = "CLOSE";
|
||||||
|
this.entityName = "AGENT";
|
||||||
|
this.entityId = agentId;
|
||||||
|
this.href = "/dds/agents/" + agentId + "?command=close";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createParty() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "PARTY";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/parties/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateParty(final Long partyId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "PARTY";
|
||||||
|
this.entityId = partyId;
|
||||||
|
this.href = "/dds/parties/" + partyId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder closeParty(final Long partyId) {
|
||||||
|
this.actionName = "CLOSE";
|
||||||
|
this.entityName = "PARTY";
|
||||||
|
this.entityId = partyId;
|
||||||
|
this.href = "/dds/parties/" + partyId + "?command=close";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pays a matured DDS account out and closes it. A separate action from CLOSE because it moves
|
||||||
|
* money, and so is worth its own permission and its own line in the audit trail.
|
||||||
|
*/
|
||||||
|
public CommandWrapperBuilder payOutParty(final Long partyId) {
|
||||||
|
this.actionName = "PAYOUT";
|
||||||
|
this.entityName = "PARTY";
|
||||||
|
this.entityId = partyId;
|
||||||
|
this.href = "/dds/parties/" + partyId + "?command=payout";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes a DDS account before its term is up, settling the shortfall against the customer or the
|
||||||
|
* agent. Separate from both CLOSE and PAYOUT because it is the only one of the three that changes
|
||||||
|
* what a customer is owed, so a bank will want it approved by someone other than whoever raised it.
|
||||||
|
*/
|
||||||
|
public CommandWrapperBuilder prematureCloseParty(final Long partyId) {
|
||||||
|
this.actionName = "PREMATURECLOSE";
|
||||||
|
this.entityName = "PARTY";
|
||||||
|
this.entityId = partyId;
|
||||||
|
this.href = "/dds/parties/" + partyId + "?command=prematureclose";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder prematureCloseOverrideParty(final Long partyId) {
|
||||||
|
this.actionName = "PREMATURECLOSEOVERRIDE";
|
||||||
|
this.entityName = "PARTY";
|
||||||
|
this.entityId = partyId;
|
||||||
|
this.href = "/dds/parties/" + partyId + "?command=prematurecloseoverride";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createDdsAccountType() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "DDSACCOUNTTYPE";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/accounttypes/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateDdsAccountType(final Long accountTypeId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "DDSACCOUNTTYPE";
|
||||||
|
this.entityId = accountTypeId;
|
||||||
|
this.href = "/dds/accounttypes/" + accountTypeId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createPartyTransaction() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "PARTYTRANSACTION";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/partytransactions/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createDdsCollectionEntries() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "DDSCOLLECTIONENTRY";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/collectionentries/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder cancelDdsCollectionEntry(final Long entryId) {
|
||||||
|
this.actionName = "CANCEL";
|
||||||
|
this.entityName = "DDSCOLLECTIONENTRY";
|
||||||
|
this.entityId = entryId;
|
||||||
|
this.href = "/dds/collectionentries/" + entryId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder settleDdsCollectionEntries() {
|
||||||
|
this.actionName = "SETTLE";
|
||||||
|
this.entityName = "DDSCOLLECTIONENTRY";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/collectionentries/settle";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createCommissionSlabs() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "COMMISSIONSLAB";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/commissionslabs/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateCommissionSlab(final Long slabId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "COMMISSIONSLAB";
|
||||||
|
this.entityId = slabId;
|
||||||
|
this.href = "/dds/commissionslabs/" + slabId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder generateDdsBatchRun() {
|
||||||
|
this.actionName = "GENERATE";
|
||||||
|
this.entityName = "DDSBATCH";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/batchruns/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder postDdsBatchRun(final Long batchRunId) {
|
||||||
|
this.actionName = "POST";
|
||||||
|
this.entityName = "DDSBATCH";
|
||||||
|
this.entityId = batchRunId;
|
||||||
|
this.href = "/dds/batchruns/" + batchRunId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder deleteOffice(final Long officeId) {
|
||||||
|
this.actionName = "DELETE";
|
||||||
|
this.entityName = "OFFICE";
|
||||||
|
this.entityId = officeId;
|
||||||
|
this.href = "/offices/" + officeId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder createOfficeTransaction() {
|
public CommandWrapperBuilder createOfficeTransaction() {
|
||||||
this.actionName = "CREATE";
|
this.actionName = "CREATE";
|
||||||
this.entityName = "OFFICETRANSACTION";
|
this.entityName = "OFFICETRANSACTION";
|
||||||
@@ -2211,6 +2380,14 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createCbsBaseInterestRate() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "CBSBASEINTERESTRATE";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/fixeddepositproducts/baseinterestrates";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
||||||
this.actionName = "UPDATE";
|
this.actionName = "UPDATE";
|
||||||
this.entityName = "FIXEDDEPOSITPRODUCT";
|
this.entityName = "FIXEDDEPOSITPRODUCT";
|
||||||
@@ -2897,6 +3074,15 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) {
|
||||||
|
this.actionName = "PAYMENT";
|
||||||
|
this.entityName = "FIXEDDEPOSITACCOUNT";
|
||||||
|
this.entityId = accountId;
|
||||||
|
this.savingsId = accountId;
|
||||||
|
this.href = "/fixeddepositaccounts/" + accountId + "?command=payment";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
|
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
|
||||||
this.actionName = "CALCULATEINTEREST";
|
this.actionName = "CALCULATEINTEREST";
|
||||||
this.entityName = "FIXEDDEPOSITACCOUNT";
|
this.entityName = "FIXEDDEPOSITACCOUNT";
|
||||||
@@ -3103,6 +3289,14 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateFacilityChargeMapping(final Long mappingId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "FACILITYCHARGEMAPPING";
|
||||||
|
this.entityId = mappingId;
|
||||||
|
this.href = "/facilitychargemappings/" + mappingId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) {
|
public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) {
|
||||||
this.actionName = "DELETE";
|
this.actionName = "DELETE";
|
||||||
this.entityName = "FINANCIALACTIVITYACCOUNT";
|
this.entityName = "FINANCIALACTIVITYACCOUNT";
|
||||||
|
|||||||
+3
-1
@@ -123,7 +123,9 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
|||||||
AppUser appUser = this.context.authenticatedUser();
|
AppUser appUser = this.context.authenticatedUser();
|
||||||
String permissionCode = commandSource.getPermissionCode();
|
String permissionCode = commandSource.getPermissionCode();
|
||||||
appUser.validateHasCheckerPermissionTo(permissionCode);
|
appUser.validateHasCheckerPermissionTo(permissionCode);
|
||||||
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
|
// A DDS entry is never checked by the user who made it, super user or not - see CommandSourceService.isDdsEntity.
|
||||||
|
if (!configurationService.isSameMakerCheckerEnabled()
|
||||||
|
&& (!appUser.isCheckerSuperUser() || CommandSourceService.isDdsEntity(commandSource.getEntityName()))) {
|
||||||
AppUser maker = commandSource.getMaker();
|
AppUser maker = commandSource.getMaker();
|
||||||
if (maker == null) {
|
if (maker == null) {
|
||||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||||
|
|||||||
+2
@@ -26,6 +26,8 @@ public interface ConfigurationDomainService {
|
|||||||
|
|
||||||
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
||||||
|
|
||||||
|
boolean isMakerCheckerEnabled();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
||||||
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
||||||
|
|||||||
+13
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
|
|||||||
this.dataValidationErrors.add(error);
|
this.dataValidationErrors.add(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the
|
||||||
|
* user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the
|
||||||
|
* args passed to it).
|
||||||
|
*/
|
||||||
|
public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage,
|
||||||
|
final Object... defaultUserMessageArgs) {
|
||||||
|
String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode;
|
||||||
|
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter,
|
||||||
|
this.value, defaultUserMessageArgs);
|
||||||
|
this.dataValidationErrors.add(error);
|
||||||
|
}
|
||||||
|
|
||||||
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
|
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
|
||||||
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
|
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
|
||||||
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
|
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
|
||||||
|
|||||||
+7
@@ -38,7 +38,11 @@ public enum JobName {
|
|||||||
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
|
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
|
||||||
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
||||||
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
||||||
|
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
||||||
|
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
|
||||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||||
|
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
||||||
|
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
||||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||||
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
||||||
@@ -52,6 +56,7 @@ public enum JobName {
|
|||||||
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
|
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
|
||||||
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), //
|
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), //
|
||||||
INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), //
|
INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), //
|
||||||
|
SYNC_BUSINESS_DATE_TO_SYSTEM_DATE("Sync Business Date To System Date"), //
|
||||||
LOAN_COB("Loan COB"), //
|
LOAN_COB("Loan COB"), //
|
||||||
LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
|
LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
|
||||||
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
|
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
|
||||||
@@ -67,6 +72,8 @@ public enum JobName {
|
|||||||
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
|
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
|
||||||
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
|
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
|
||||||
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
|
||||||
|
POST_DDS_INTEREST("Post DDS Interest"), //
|
||||||
|
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
|
||||||
; //
|
; //
|
||||||
|
|
||||||
private final String name;
|
private final String name;
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable data object representing a DDS agent and its guarantors, following the legacy Agent
|
||||||
|
* Master Entry screen. Built via the Lombok builder rather than a long positional constructor, as the
|
||||||
|
* other DDS data objects are.
|
||||||
|
*
|
||||||
|
* The commission block below is on the legacy SubAgent Master screen rather than the Agent one, and
|
||||||
|
* drives the commission batch.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder(toBuilder = true)
|
||||||
|
public final class AgentData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long clientId;
|
||||||
|
private final String clientName;
|
||||||
|
private final String agentCode;
|
||||||
|
private final String agentNameId;
|
||||||
|
private final String agentAddress1;
|
||||||
|
private final String agentAddress2;
|
||||||
|
private final String agentCity;
|
||||||
|
private final String agentPinCode;
|
||||||
|
private final String agentTelephone;
|
||||||
|
private final String agentSavingAccountType;
|
||||||
|
private final String agentSavingAccountNo;
|
||||||
|
private final LocalDate agentOpeningDate;
|
||||||
|
private final LocalDate agentBirthDate;
|
||||||
|
private final BigDecimal agentCurrentBalance;
|
||||||
|
private final BigDecimal agentOpeningBalance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branch this agent belongs to. {@code officeId} is what the screen sends back on an edit;
|
||||||
|
* {@code homeBranch} is that office's current name, read from the office row rather than from
|
||||||
|
* the agent, so a renamed branch reads correctly here without the agent being touched.
|
||||||
|
*/
|
||||||
|
private final Long officeId;
|
||||||
|
private final String homeBranch;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ledger account this agent's collections pass through, if the bank gave them one. The id is
|
||||||
|
* what the screen sends back on an edit; the code and name are what it shows.
|
||||||
|
*/
|
||||||
|
private final Long glAccountId;
|
||||||
|
private final String glAccountCode;
|
||||||
|
private final String glAccountName;
|
||||||
|
|
||||||
|
/** Commission owed but not yet paid out, and the TDS withheld so far. Both are batch outcomes. */
|
||||||
|
private final BigDecimal commission;
|
||||||
|
private final BigDecimal tdsAmount;
|
||||||
|
|
||||||
|
/** Commission settings and the standing figures the commission run deducts. */
|
||||||
|
private final boolean commissionCalculation;
|
||||||
|
private final boolean tdsApplicable;
|
||||||
|
private final BigDecimal tdsPercent;
|
||||||
|
private final BigDecimal deductCommission;
|
||||||
|
private final BigDecimal agentDeposit;
|
||||||
|
private final BigDecimal festivalAdvance;
|
||||||
|
private final BigDecimal otherDeduction;
|
||||||
|
|
||||||
|
private final boolean active;
|
||||||
|
|
||||||
|
/** When the relationship ended, or null while the agent is on the books. */
|
||||||
|
private final LocalDate closeDate;
|
||||||
|
|
||||||
|
private final LocalDateTime createdDate;
|
||||||
|
private final LocalDateTime lastModifiedDate;
|
||||||
|
private final Collection<AgentGuarantorData> guarantors;
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable data object representing a single agent guarantor.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public final class AgentGuarantorData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long clientId;
|
||||||
|
private final String guarantorName;
|
||||||
|
private final String address1;
|
||||||
|
private final String address2;
|
||||||
|
private final String city;
|
||||||
|
private final String pinCode;
|
||||||
|
private final String telephone;
|
||||||
|
|
||||||
|
public static AgentGuarantorData instance(final Long id, final Long clientId, final String guarantorName, final String address1,
|
||||||
|
final String address2, final String city, final String pinCode, final String telephone) {
|
||||||
|
return new AgentGuarantorData(id, clientId, guarantorName, address1, address2, city, pinCode, telephone);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentGuarantorData(final Long id, final Long clientId, final String guarantorName, final String address1,
|
||||||
|
final String address2, final String city, final String pinCode, final String telephone) {
|
||||||
|
this.id = id;
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.guarantorName = guarantorName;
|
||||||
|
this.address1 = address1;
|
||||||
|
this.address2 = address2;
|
||||||
|
this.city = city;
|
||||||
|
this.pinCode = pinCode;
|
||||||
|
this.telephone = telephone;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OrderBy;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||||
|
import org.apache.fineract.organisation.office.domain.Office;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DDS (Daily Deposit Scheme) collection agent. Can represent either a person or, at the top of the
|
||||||
|
* hierarchy, the bank itself acting as its own agent. Holds its own settlement savings account and one
|
||||||
|
* or more guarantors.
|
||||||
|
*
|
||||||
|
* The field set follows the legacy Agent Master Entry screen. SubAgent was folded into Agent
|
||||||
|
* (2026-08-24) so the Agent now does the daily collection work directly, but the SubAgent-only fields
|
||||||
|
* that came across with it (TDS, transfer/security accounts, machine rent, collection-limit history)
|
||||||
|
* were on no screen the business uses and were read by no query, report or job, so they were dropped
|
||||||
|
* again in 0481. Two fields are kept despite not appearing on that screen, because code reads them:
|
||||||
|
* {@code commission}/{@code lastCommissionDate}, and the deduction figures the Agent Commission
|
||||||
|
* Statement reports.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
|
||||||
|
public class Agent extends AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
|
private Client client;
|
||||||
|
|
||||||
|
@Column(name = "agent_code", length = 20, nullable = false)
|
||||||
|
private String agentCode;
|
||||||
|
|
||||||
|
@Column(name = "agent_name", length = 100, nullable = false)
|
||||||
|
private String agentName;
|
||||||
|
|
||||||
|
@Column(name = "agent_address1", length = 200, nullable = false)
|
||||||
|
private String agentAddress1;
|
||||||
|
|
||||||
|
@Column(name = "agent_address2", length = 200)
|
||||||
|
private String agentAddress2;
|
||||||
|
|
||||||
|
@Column(name = "agent_city", length = 100, nullable = false)
|
||||||
|
private String agentCity;
|
||||||
|
|
||||||
|
@Column(name = "agent_pincode", length = 20, nullable = false)
|
||||||
|
private String agentPinCode;
|
||||||
|
|
||||||
|
@Column(name = "agent_telephone", length = 20)
|
||||||
|
private String agentTelephone;
|
||||||
|
|
||||||
|
@Column(name = "agent_saving_account_type", length = 100, nullable = false)
|
||||||
|
private String agentSavingAccountType;
|
||||||
|
|
||||||
|
@Column(name = "agent_saving_account_no", length = 50, nullable = false)
|
||||||
|
private String agentSavingAccountNo;
|
||||||
|
|
||||||
|
@Column(name = "agent_opening_date", nullable = false)
|
||||||
|
private LocalDate agentOpeningDate;
|
||||||
|
|
||||||
|
@Column(name = "agent_birth_date", nullable = false)
|
||||||
|
private LocalDate agentBirthDate;
|
||||||
|
|
||||||
|
@Column(name = "agent_current_balance", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal agentCurrentBalance;
|
||||||
|
|
||||||
|
@Column(name = "agent_opening_balance", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal agentOpeningBalance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branch this agent belongs to, and the thing of record for it.
|
||||||
|
*
|
||||||
|
* Defaults to the agent's client's office - where the person sits on the bank's books - but is
|
||||||
|
* chosen on the screen, since an agent can be registered at one branch and collect out of
|
||||||
|
* another.
|
||||||
|
*/
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "office_id")
|
||||||
|
private Office office;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The office's name, held alongside the office itself.
|
||||||
|
*
|
||||||
|
* Redundant by design: the batch job's branch filter and several reports match on this name, and
|
||||||
|
* were written before the office link existed. It is always written from {@link #office}, never
|
||||||
|
* on its own, so the two cannot drift apart.
|
||||||
|
*/
|
||||||
|
@Column(name = "home_branch", length = 100)
|
||||||
|
private String homeBranch;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ledger account this agent's collections pass through, if the bank has given them one.
|
||||||
|
*
|
||||||
|
* Collections debit it on their way to the customer and settlements credit it when the cash
|
||||||
|
* arrives, so what is left standing on it is what this agent is holding and has not brought in.
|
||||||
|
* One agent to an account - shared, it would say nothing that the old single account did not.
|
||||||
|
*
|
||||||
|
* Null for the agents that predate this, and they go on using the scheme's shared agent-cash
|
||||||
|
* account exactly as before. Nothing about the route the money takes depends on which of the
|
||||||
|
* two it is.
|
||||||
|
*/
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "gl_account_id")
|
||||||
|
private GLAccount glAccount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commission earned by Commission Calculation but not yet paid out. Commission Posting pays it and
|
||||||
|
* resets this to zero, so it reads as "owed to this agent right now" - which is what the legacy
|
||||||
|
* SubAgent Master screen's "Comm" box shows.
|
||||||
|
*/
|
||||||
|
@Column(name = "commission", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal commission;
|
||||||
|
|
||||||
|
/** Whether Commission Calculation covers this agent at all ("Com. Calculation" on the legacy screen). */
|
||||||
|
@Column(name = "commission_calculation", nullable = false)
|
||||||
|
private boolean commissionCalculation;
|
||||||
|
|
||||||
|
/** TDS is withheld from commission at source when the bank has it switched on for this agent. */
|
||||||
|
@Column(name = "tds_applicable", nullable = false)
|
||||||
|
private boolean tdsApplicable;
|
||||||
|
|
||||||
|
@Column(name = "tds_percent", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal tdsPercent;
|
||||||
|
|
||||||
|
/** Running total of TDS withheld, the legacy screen's "TDS Amt". */
|
||||||
|
@Column(name = "tds_amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal tdsAmount;
|
||||||
|
|
||||||
|
/** The four figures the Agent Commission Statement subtracts from gross commission. */
|
||||||
|
@Column(name = "deduct_commission", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal deductCommission;
|
||||||
|
|
||||||
|
@Column(name = "agent_deposit", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal agentDeposit;
|
||||||
|
|
||||||
|
@Column(name = "festival_advance", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal festivalAdvance;
|
||||||
|
|
||||||
|
@Column(name = "other_deduction", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal otherDeduction;
|
||||||
|
|
||||||
|
@Column(name = "last_commission_date")
|
||||||
|
private LocalDate lastCommissionDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An agent who stops collecting is deactivated, never deleted: every party collection, settlement
|
||||||
|
* and commission posting is recorded against it, so removing it would orphan its whole history.
|
||||||
|
* An inactive agent stays out of the pick lists for new collection work while its record and
|
||||||
|
* history stay intact, and it can be switched back on.
|
||||||
|
*/
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private boolean active;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the relationship ended, or null while the agent is on the books.
|
||||||
|
*
|
||||||
|
* Distinct from {@code active}: switching an agent off stops them collecting and has to be
|
||||||
|
* possible immediately, whereas closing is the end of the relationship and is only allowed once
|
||||||
|
* nothing is left hanging. A closed agent is never deleted - their whole collection history is
|
||||||
|
* recorded against them.
|
||||||
|
*/
|
||||||
|
@Column(name = "close_date")
|
||||||
|
private LocalDate closeDate;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||||
|
@OrderBy("id")
|
||||||
|
private List<AgentGuarantor> guarantors = new ArrayList<>();
|
||||||
|
|
||||||
|
protected Agent() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
private Agent(final Client client, final String agentCode, final String agentName, final String agentAddress1,
|
||||||
|
final String agentAddress2, final String agentCity, final String agentPinCode, final String agentTelephone,
|
||||||
|
final String agentSavingAccountType, final String agentSavingAccountNo, final LocalDate agentOpeningDate,
|
||||||
|
final LocalDate agentBirthDate, final BigDecimal agentOpeningBalance, final Office office) {
|
||||||
|
this.client = client;
|
||||||
|
this.agentCode = agentCode;
|
||||||
|
this.agentName = agentName;
|
||||||
|
this.agentAddress1 = agentAddress1;
|
||||||
|
this.agentAddress2 = StringUtils.defaultIfEmpty(agentAddress2, null);
|
||||||
|
this.agentCity = agentCity;
|
||||||
|
this.agentPinCode = agentPinCode;
|
||||||
|
this.agentTelephone = StringUtils.defaultIfEmpty(agentTelephone, null);
|
||||||
|
this.agentSavingAccountType = agentSavingAccountType;
|
||||||
|
this.agentSavingAccountNo = agentSavingAccountNo;
|
||||||
|
this.agentOpeningDate = agentOpeningDate;
|
||||||
|
this.agentBirthDate = agentBirthDate;
|
||||||
|
this.agentOpeningBalance = agentOpeningBalance == null ? BigDecimal.ZERO : agentOpeningBalance;
|
||||||
|
this.agentCurrentBalance = this.agentOpeningBalance;
|
||||||
|
setOffice(office);
|
||||||
|
this.active = true;
|
||||||
|
|
||||||
|
this.commission = BigDecimal.ZERO;
|
||||||
|
// Commission is earned unless someone says otherwise, matching the legacy screen's default.
|
||||||
|
this.commissionCalculation = true;
|
||||||
|
this.tdsApplicable = false;
|
||||||
|
this.tdsPercent = BigDecimal.ZERO;
|
||||||
|
this.tdsAmount = BigDecimal.ZERO;
|
||||||
|
this.deductCommission = BigDecimal.ZERO;
|
||||||
|
this.agentDeposit = BigDecimal.ZERO;
|
||||||
|
this.festivalAdvance = BigDecimal.ZERO;
|
||||||
|
this.otherDeduction = BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param office
|
||||||
|
* the branch this agent belongs to, resolved by the caller from the command's
|
||||||
|
* {@code officeId} or, failing that, from the client's own office.
|
||||||
|
*/
|
||||||
|
public static Agent fromJson(final Client client, final String agentCode, final Office office, final JsonCommand command) {
|
||||||
|
final String agentName = command.stringValueOfParameterNamed("agentNameId");
|
||||||
|
final String agentAddress1 = command.stringValueOfParameterNamed("agentAddress1");
|
||||||
|
final String agentAddress2 = command.stringValueOfParameterNamedAllowingNull("agentAddress2");
|
||||||
|
final String agentCity = command.stringValueOfParameterNamed("agentCity");
|
||||||
|
final String agentPinCode = command.stringValueOfParameterNamed("agentPinCode");
|
||||||
|
final String agentTelephone = command.stringValueOfParameterNamedAllowingNull("agentTelephone");
|
||||||
|
final String agentSavingAccountType = command.stringValueOfParameterNamed("agentSavingAccountType");
|
||||||
|
final String agentSavingAccountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
||||||
|
final LocalDate agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
|
||||||
|
final LocalDate agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
|
||||||
|
final BigDecimal agentOpeningBalance = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentOpeningBalance");
|
||||||
|
|
||||||
|
return new Agent(client, agentCode, agentName, agentAddress1, agentAddress2, agentCity, agentPinCode, agentTelephone,
|
||||||
|
agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentOpeningBalance, office);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addGuarantor(final AgentGuarantor guarantor) {
|
||||||
|
guarantor.setAgent(this);
|
||||||
|
this.guarantors.add(guarantor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void resetGuarantors() {
|
||||||
|
this.guarantors.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commission Calculation: adds what this agent earned to the amount owed to them, withholds the
|
||||||
|
* TDS that went with it, and stamps the date commission is now calculated through - so the next
|
||||||
|
* Commission Calculation only covers collections since then, and re-running it for the same
|
||||||
|
* period finds nothing left to earn.
|
||||||
|
*/
|
||||||
|
public void accrueCommission(final BigDecimal netAmount, final BigDecimal tdsWithheld, final LocalDate throughDate) {
|
||||||
|
this.commission = this.commission.add(netAmount);
|
||||||
|
this.tdsAmount = this.tdsAmount.add(tdsWithheld);
|
||||||
|
this.lastCommissionDate = throughDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commission Posting: pays the given amount out of what is owed and returns what remains owed.
|
||||||
|
*
|
||||||
|
* The amount is the one the run previewed rather than whatever is owed at this moment, so what
|
||||||
|
* gets paid is always what was seen and approved. Since it comes off the owed figure, a second
|
||||||
|
* posting of the same commission finds nothing left to pay.
|
||||||
|
*/
|
||||||
|
public BigDecimal payOutCommission(final BigDecimal amount) {
|
||||||
|
this.commission = this.commission.subtract(amount);
|
||||||
|
return this.commission;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charges the agent for an account of theirs that was closed before its term was up.
|
||||||
|
*
|
||||||
|
* Booked against {@code deductCommission} rather than taken straight off {@code commission},
|
||||||
|
* because what is owed at this moment may be nothing - commission is paid out in runs, and an
|
||||||
|
* agent who was paid yesterday would otherwise escape the charge entirely. Held as a deduction it
|
||||||
|
* is carried until there is commission to take it from, and it shows on the Agent Commission
|
||||||
|
* Statement between gross and take-home, where an agent can see what was charged and why.
|
||||||
|
*/
|
||||||
|
public BigDecimal chargePrematureClosure(final BigDecimal amount) {
|
||||||
|
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
return this.deductCommission;
|
||||||
|
}
|
||||||
|
this.deductCommission = this.deductCommission.add(amount);
|
||||||
|
return this.deductCommission;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four figures the Agent Commission Statement subtracts between gross commission and the
|
||||||
|
* agent's take-home total. They are standing amounts on the agent, not per-run ones, so a run
|
||||||
|
* deducts whatever they hold when it is calculated.
|
||||||
|
*/
|
||||||
|
public BigDecimal totalDeductions() {
|
||||||
|
return this.deductCommission.add(this.agentDeposit).add(this.festivalAdvance).add(this.otherDeduction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isClosed() {
|
||||||
|
return this.closeDate != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cash collected from customers that has not yet reached a branch. */
|
||||||
|
public boolean holdsCash() {
|
||||||
|
return this.agentCurrentBalance.compareTo(BigDecimal.ZERO) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commission earned but not yet paid out. */
|
||||||
|
public boolean isOwedCommission() {
|
||||||
|
return this.commission.compareTo(BigDecimal.ZERO) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the relationship. The caller checks first that no accounts are still assigned; the two
|
||||||
|
* money conditions are checked here, where the figures live.
|
||||||
|
*
|
||||||
|
* Closing also switches the agent off, since an agent who is no longer on the books cannot be
|
||||||
|
* collecting either.
|
||||||
|
*/
|
||||||
|
public void close(final LocalDate closeDate) {
|
||||||
|
this.closeDate = closeDate;
|
||||||
|
this.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cash this agent has collected from parties but not yet handed over to a branch (an Agent Tran
|
||||||
|
* settlement). Raised by a Party Tran collection, lowered by an Agent Tran settlement.
|
||||||
|
*/
|
||||||
|
public BigDecimal credit(final BigDecimal amount) {
|
||||||
|
this.agentCurrentBalance = this.agentCurrentBalance.add(amount);
|
||||||
|
return this.agentCurrentBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal settle(final BigDecimal amount) {
|
||||||
|
this.agentCurrentBalance = this.agentCurrentBalance.subtract(amount);
|
||||||
|
return this.agentCurrentBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void changeClient(final Client newClient) {
|
||||||
|
this.client = newClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the agent to a branch, writing the office's name into {@code homeBranch} in the same
|
||||||
|
* step so the name never has to be set by hand and never lags behind the office.
|
||||||
|
*/
|
||||||
|
public void setOffice(final Office office) {
|
||||||
|
this.office = office;
|
||||||
|
this.homeBranch = office == null ? null : StringUtils.defaultIfEmpty(office.getName(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long officeId() {
|
||||||
|
return this.office == null ? null : this.office.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points this agent's collections at a ledger account, or back at the scheme's shared one.
|
||||||
|
*
|
||||||
|
* Whether the account may be used at all - that it exists, is an asset account, is not disabled,
|
||||||
|
* and is not already another agent's - is settled before this is called. By the time the agent
|
||||||
|
* holds it, the question is closed.
|
||||||
|
*/
|
||||||
|
public void setGlAccount(final GLAccount glAccount) {
|
||||||
|
this.glAccount = glAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long glAccountId() {
|
||||||
|
return this.glAccount == null ? null : this.glAccount.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long clientId() {
|
||||||
|
return this.client.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> update(final JsonCommand command) {
|
||||||
|
|
||||||
|
final Map<String, Object> actualChanges = new LinkedHashMap<>(10);
|
||||||
|
|
||||||
|
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
|
||||||
|
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentNameId", this.agentName)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentNameId");
|
||||||
|
actualChanges.put("agentNameId", newValue);
|
||||||
|
this.agentName = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentAddress1", this.agentAddress1)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentAddress1");
|
||||||
|
actualChanges.put("agentAddress1", newValue);
|
||||||
|
this.agentAddress1 = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentAddress2", this.agentAddress2)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentAddress2");
|
||||||
|
actualChanges.put("agentAddress2", newValue);
|
||||||
|
this.agentAddress2 = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentCity", this.agentCity)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentCity");
|
||||||
|
actualChanges.put("agentCity", newValue);
|
||||||
|
this.agentCity = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentPinCode", this.agentPinCode)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentPinCode");
|
||||||
|
actualChanges.put("agentPinCode", newValue);
|
||||||
|
this.agentPinCode = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentTelephone", this.agentTelephone)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentTelephone");
|
||||||
|
actualChanges.put("agentTelephone", newValue);
|
||||||
|
this.agentTelephone = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentSavingAccountType", this.agentSavingAccountType)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountType");
|
||||||
|
actualChanges.put("agentSavingAccountType", newValue);
|
||||||
|
this.agentSavingAccountType = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("agentSavingAccountNo", this.agentSavingAccountNo)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
||||||
|
actualChanges.put("agentSavingAccountNo", newValue);
|
||||||
|
this.agentSavingAccountNo = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("agentOpeningDate", this.agentOpeningDate)) {
|
||||||
|
actualChanges.put("agentOpeningDate", command.stringValueOfParameterNamed("agentOpeningDate"));
|
||||||
|
this.agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("agentBirthDate", this.agentBirthDate)) {
|
||||||
|
actualChanges.put("agentBirthDate", command.stringValueOfParameterNamed("agentBirthDate"));
|
||||||
|
this.agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("agentOpeningBalance", this.agentOpeningBalance)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("agentOpeningBalance");
|
||||||
|
actualChanges.put("agentOpeningBalance", newValue);
|
||||||
|
// The current balance is the opening balance plus collections less settlements, so
|
||||||
|
// correcting the opening figure has to move the current one by the same amount.
|
||||||
|
// Setting it alone would leave the two contradicting each other for good, since nothing
|
||||||
|
// else ever recomputes the current balance from scratch.
|
||||||
|
this.agentCurrentBalance = this.agentCurrentBalance.add(newValue.subtract(this.agentOpeningBalance));
|
||||||
|
this.agentOpeningBalance = newValue;
|
||||||
|
actualChanges.put("agentCurrentBalance", this.agentCurrentBalance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("active", this.active)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
|
||||||
|
actualChanges.put("active", newValue);
|
||||||
|
this.active = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commission settings and the standing deductions. The running totals the batch maintains -
|
||||||
|
// `commission` and `tdsAmount` - are deliberately not editable here: they are the outcome of
|
||||||
|
// posted runs, and typing over them would put the agent's balance out of step with the runs
|
||||||
|
// that produced it.
|
||||||
|
if (command.isChangeInBooleanParameterNamed("commissionCalculation", this.commissionCalculation)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
|
||||||
|
actualChanges.put("commissionCalculation", newValue);
|
||||||
|
this.commissionCalculation = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("tdsApplicable", this.tdsApplicable)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
|
||||||
|
actualChanges.put("tdsApplicable", newValue);
|
||||||
|
this.tdsApplicable = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
decimalChange(command, actualChanges, "tdsPercent", this.tdsPercent, v -> this.tdsPercent = v);
|
||||||
|
decimalChange(command, actualChanges, "deductCommission", this.deductCommission, v -> this.deductCommission = v);
|
||||||
|
decimalChange(command, actualChanges, "agentDeposit", this.agentDeposit, v -> this.agentDeposit = v);
|
||||||
|
decimalChange(command, actualChanges, "festivalAdvance", this.festivalAdvance, v -> this.festivalAdvance = v);
|
||||||
|
decimalChange(command, actualChanges, "otherDeduction", this.otherDeduction, v -> this.otherDeduction = v);
|
||||||
|
|
||||||
|
// A rate means nothing while TDS is switched off, so it is not left behind to be applied by a
|
||||||
|
// later switch-on nobody connected to it.
|
||||||
|
if (!this.tdsApplicable) {
|
||||||
|
this.tdsPercent = BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actualChanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void decimalChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
|
||||||
|
final BigDecimal existingValue, final Consumer<BigDecimal> setter) {
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed(paramName, existingValue)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(paramName);
|
||||||
|
actualChanges.put(paramName, newValue);
|
||||||
|
setter.accept(newValue == null ? BigDecimal.ZERO : newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single guarantor recorded against an {@link Agent} (or, reused with the same shape, against a
|
||||||
|
* SubAgent). Always created/replaced as part of its parent agent's own create/update command - it has
|
||||||
|
* no independent API of its own.
|
||||||
|
*
|
||||||
|
* Audited in its own right even so: a guarantor is who the bank goes to if an agent's collections go
|
||||||
|
* missing, and a timestamp on the agent saying something changed does not say that the person
|
||||||
|
* standing behind them was swapped, nor by whom.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_agent_guarantor")
|
||||||
|
public class AgentGuarantor extends AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "agent_id", nullable = false)
|
||||||
|
private Agent agent;
|
||||||
|
|
||||||
|
@Column(name = "client_id", nullable = false)
|
||||||
|
private Long clientId;
|
||||||
|
|
||||||
|
@Column(name = "guarantor_name", length = 100, nullable = false)
|
||||||
|
private String guarantorName;
|
||||||
|
|
||||||
|
@Column(name = "address1", length = 200, nullable = false)
|
||||||
|
private String address1;
|
||||||
|
|
||||||
|
@Column(name = "address2", length = 200)
|
||||||
|
private String address2;
|
||||||
|
|
||||||
|
@Column(name = "city", length = 100, nullable = false)
|
||||||
|
private String city;
|
||||||
|
|
||||||
|
@Column(name = "pin_code", length = 20, nullable = false)
|
||||||
|
private String pinCode;
|
||||||
|
|
||||||
|
@Column(name = "telephone", length = 20)
|
||||||
|
private String telephone;
|
||||||
|
|
||||||
|
protected AgentGuarantor() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentGuarantor(final Long clientId, final String guarantorName, final String address1, final String address2,
|
||||||
|
final String city, final String pinCode, final String telephone) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.guarantorName = guarantorName;
|
||||||
|
this.address1 = address1;
|
||||||
|
this.address2 = StringUtils.defaultIfEmpty(address2, null);
|
||||||
|
this.city = city;
|
||||||
|
this.pinCode = pinCode;
|
||||||
|
this.telephone = StringUtils.defaultIfEmpty(telephone, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAgent(final Agent agent) {
|
||||||
|
this.agent = agent;
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when an agent is closed while their books are not square.
|
||||||
|
*
|
||||||
|
* Closing is the end of the relationship, so it is refused until nothing is left hanging: no accounts
|
||||||
|
* still being collected from, no cash held that has not reached a branch, and no commission earned but
|
||||||
|
* unpaid. Each of those is money or a customer that would be left with no one responsible for it.
|
||||||
|
*
|
||||||
|
* Deactivating is deliberately not guarded this way. Switching an agent off has to be possible the
|
||||||
|
* moment the bank decides it - if it waited for the books to be squared, an agent the bank wanted
|
||||||
|
* stopped would keep collecting while the paperwork caught up.
|
||||||
|
*/
|
||||||
|
public class AgentCannotBeClosedException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public AgentCannotBeClosedException(final String agentCode, final String reason) {
|
||||||
|
super("error.msg.dds.agent.cannot.be.closed", "Agent " + agentCode + " cannot be closed: " + reason, agentCode, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when an agent is listed as one of its own guarantors - a guarantee is only worth something
|
||||||
|
* coming from someone other than the agent it backs.
|
||||||
|
*/
|
||||||
|
public class AgentCannotGuaranteeItselfException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public AgentCannotGuaranteeItselfException(final Long clientId) {
|
||||||
|
super("error.msg.agent.cannot.guarantee.itself", "An agent cannot be its own guarantor", clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a commission run cannot pay an agent because their account will not take the credit.
|
||||||
|
*
|
||||||
|
* The account is checked when it is chosen, so reaching this means something changed afterwards -
|
||||||
|
* most likely the account was closed. Failing here rolls the whole run back rather than paying the
|
||||||
|
* other agents and leaving this one silently unpaid: a commission run that has half happened is
|
||||||
|
* worse to unpick than one that has not happened at all.
|
||||||
|
*/
|
||||||
|
public class AgentCommissionAccountUnusableException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public AgentCommissionAccountUnusableException(final String agentCode, final String reason) {
|
||||||
|
super("error.msg.dds.agent.commission.account.unusable",
|
||||||
|
"Commission for agent " + agentCode + " could not be paid: " + reason + ".", agentCode, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the ledger account named for an agent could not carry that agent's cash.
|
||||||
|
*
|
||||||
|
* Refused when it is chosen rather than when it is first posted to. An account that cannot take the
|
||||||
|
* entry fails the whole collection, at the counter, with a customer waiting - and the person who
|
||||||
|
* picked it is not the person who finds out.
|
||||||
|
*/
|
||||||
|
public class AgentGlAccountUnusableException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public AgentGlAccountUnusableException(final String glCode, final String reason) {
|
||||||
|
super("error.msg.dds.agent.gl.account.unusable",
|
||||||
|
"Ledger account " + glCode + " cannot be used for this agent: " + reason
|
||||||
|
+ ". Choose a detail asset account that no other agent already holds.",
|
||||||
|
glCode, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RuntimeException} thrown when a DDS agent resource is not found.
|
||||||
|
*/
|
||||||
|
public class AgentNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public AgentNotFoundException(final Long id) {
|
||||||
|
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||||
|
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentNotFoundException(final String agentCode) {
|
||||||
|
super("error.msg.agent.code.invalid", "Agent with code " + agentCode + " does not exist", agentCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when an agent is given a savings account that belongs to somebody else.
|
||||||
|
*
|
||||||
|
* An agent's commission is their own earnings, so it is paid into their own account. Accepting
|
||||||
|
* another person's account number here would route one person's pay into another's balance - a
|
||||||
|
* mistake that reads as ordinary data entry and would only surface when the person who should have
|
||||||
|
* been paid asked where their money went.
|
||||||
|
*/
|
||||||
|
public class AgentSavingsAccountClientMismatchException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public AgentSavingsAccountClientMismatchException(final String accountNo) {
|
||||||
|
super("error.msg.dds.agent.savings.account.client.mismatch",
|
||||||
|
"Savings account " + accountNo + " belongs to a different client. An agent's commission is paid into their own account.",
|
||||||
|
accountNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when an agent is given a savings account number that no open account carries.
|
||||||
|
*
|
||||||
|
* The agent's commission is paid into this account, so a number that exists only as typed text means
|
||||||
|
* a payment with nowhere to land - and nothing downstream would catch it, because the commission run
|
||||||
|
* records what it paid, not whether the account it named was real.
|
||||||
|
*/
|
||||||
|
public class AgentSavingsAccountNotFoundException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public AgentSavingsAccountNotFoundException(final String accountNo) {
|
||||||
|
super("error.msg.dds.agent.savings.account.not.found",
|
||||||
|
"No open savings account has the number " + accountNo + ". Pick one of the client's own accounts.", accountNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when an agent's commission account is a fixed or recurring deposit rather than a savings
|
||||||
|
* account.
|
||||||
|
*
|
||||||
|
* Commission is paid in as a credit whenever a commission run posts. A savings account takes credits
|
||||||
|
* at any time; a fixed deposit is a single sum placed for a term, and a recurring deposit takes only
|
||||||
|
* its own scheduled instalments - so paying into either is not a thing a bank does, and the posting
|
||||||
|
* would fail when the run came round. Refusing it as the account is chosen puts the error in front
|
||||||
|
* of the officer who can fix it, rather than in a batch log weeks later.
|
||||||
|
*/
|
||||||
|
public class AgentSavingsAccountNotOperativeException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public AgentSavingsAccountNotOperativeException(final String accountNo, final String accountKind) {
|
||||||
|
super("error.msg.dds.agent.savings.account.not.operative",
|
||||||
|
"Account " + accountNo + " is a " + accountKind + " account. Commission has to be paid into an ordinary savings "
|
||||||
|
+ "account, so pick one of the client's own - not a deposit and not a DDS scheme account.",
|
||||||
|
accountNo, accountKind);
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.commissionslab.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public final class CommissionSlabData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final String slabType;
|
||||||
|
private final BigDecimal slabFrom;
|
||||||
|
private final BigDecimal slabTo;
|
||||||
|
private final BigDecimal commissionPercent;
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.commissionslab.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the agent commission chart: an ACCOUNT slab (commission percentage on collection
|
||||||
|
* volume) or a COMMISSION slab (the override a senior agent earns on a sub-agent's commission).
|
||||||
|
* Reference data, not history: a slab says what an agent earns on a given collection volume, and the
|
||||||
|
* commission run reads whatever the chart says at the time it runs. A mistyped range or percentage
|
||||||
|
* therefore has to be correctable - the legacy screen offered an edit on every saved row - so a slab
|
||||||
|
* can be updated. It still cannot be deleted: removing a range silently pays nothing to any agent who
|
||||||
|
* lands in it, which reads as a system fault rather than a decision.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_commission_slab")
|
||||||
|
public class CommissionSlab extends AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@Column(name = "slab_type", length = 20, nullable = false)
|
||||||
|
private String slabType;
|
||||||
|
|
||||||
|
@Column(name = "slab_from", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal slabFrom;
|
||||||
|
|
||||||
|
@Column(name = "slab_to", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal slabTo;
|
||||||
|
|
||||||
|
@Column(name = "commission_percent", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal commissionPercent;
|
||||||
|
|
||||||
|
protected CommissionSlab() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommissionSlab(final String slabType, final BigDecimal slabFrom, final BigDecimal slabTo, final BigDecimal commissionPercent) {
|
||||||
|
this.slabType = slabType;
|
||||||
|
this.slabFrom = slabFrom;
|
||||||
|
this.slabTo = slabTo;
|
||||||
|
this.commissionPercent = commissionPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects this slab, reporting only what actually changed so the audit trail records the
|
||||||
|
* correction rather than a wholesale rewrite.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> update(final JsonCommand command) {
|
||||||
|
final Map<String, Object> actualChanges = new LinkedHashMap<>(4);
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("slabType", this.slabType)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("slabType");
|
||||||
|
actualChanges.put("slabType", newValue);
|
||||||
|
this.slabType = newValue;
|
||||||
|
}
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("slabFrom", this.slabFrom)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("slabFrom");
|
||||||
|
actualChanges.put("slabFrom", newValue);
|
||||||
|
this.slabFrom = newValue;
|
||||||
|
}
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("slabTo", this.slabTo)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("slabTo");
|
||||||
|
actualChanges.put("slabTo", newValue);
|
||||||
|
this.slabTo = newValue;
|
||||||
|
}
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("commissionPercent", this.commissionPercent)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("commissionPercent");
|
||||||
|
actualChanges.put("commissionPercent", newValue);
|
||||||
|
this.commissionPercent = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actualChanges;
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.commissionslab.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a commission slab being corrected no longer exists.
|
||||||
|
*/
|
||||||
|
public class CommissionSlabNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public CommissionSlabNotFoundException(final Long slabId) {
|
||||||
|
super("error.msg.commissionslab.id.invalid", "Commission slab with identifier " + slabId + " does not exist", slabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.commissionslab.exception;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a commission slab's range overlaps one that already covers part of it.
|
||||||
|
*
|
||||||
|
* The commission run picks the first slab whose range contains the agent's collection volume, so
|
||||||
|
* two slabs covering the same figure make the rate that gets applied a matter of which row the
|
||||||
|
* database returns first. The same agent could be paid a different percentage from one month to the
|
||||||
|
* next with nothing in the data to explain it, which is why this is refused rather than warned
|
||||||
|
* about.
|
||||||
|
*/
|
||||||
|
public class OverlappingCommissionSlabException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public OverlappingCommissionSlabException(final String slabType, final BigDecimal from, final BigDecimal to,
|
||||||
|
final BigDecimal existingFrom, final BigDecimal existingTo) {
|
||||||
|
super("error.msg.commissionslab.overlapping",
|
||||||
|
slabType + " slab " + from.toPlainString() + " to " + to.toPlainString() + " overlaps the existing slab "
|
||||||
|
+ existingFrom.toPlainString() + " to " + existingTo.toPlainString(),
|
||||||
|
slabType, from, to, existingFrom, existingTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -61,4 +61,15 @@ public class OfficeRepositoryWrapper {
|
|||||||
public void delete(final Office entity) {
|
public void delete(final Office entity) {
|
||||||
this.repository.delete(entity);
|
this.repository.delete(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes and flushes in the same call, so a foreign-key violation the caller did not already check
|
||||||
|
* for (a savings transaction, a GL journal entry, a teller position -- anything on m_office's long
|
||||||
|
* tail of referencing tables) surfaces synchronously here, inside the caller's own try/catch, rather
|
||||||
|
* than at transaction-commit time where it can no longer be turned into a clean error message.
|
||||||
|
*/
|
||||||
|
public void deleteAndFlush(final Office entity) {
|
||||||
|
this.repository.delete(entity);
|
||||||
|
this.repository.flush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -72,11 +72,26 @@ public class SavingsAccountSummaryData {
|
|||||||
// differentiate deposit accounts Savings, FD and RD accounts
|
// differentiate deposit accounts Savings, FD and RD accounts
|
||||||
private final EnumOptionData depositType;
|
private final EnumOptionData depositType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal
|
||||||
|
* chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo
|
||||||
|
* for an FD that has never been renewed, and is null for non-deposit accounts.
|
||||||
|
*/
|
||||||
|
private final String fdAccountNumber;
|
||||||
|
/**
|
||||||
|
* Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so
|
||||||
|
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
|
||||||
|
*/
|
||||||
|
private final Integer renewalCycleNo;
|
||||||
|
|
||||||
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
|
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
|
||||||
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
||||||
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
||||||
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
||||||
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
|
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
|
||||||
|
final String fdAccountNumber, final Integer renewalCycleNo) {
|
||||||
|
this.fdAccountNumber = fdAccountNumber;
|
||||||
|
this.renewalCycleNo = renewalCycleNo;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.accountNo = accountNo;
|
this.accountNo = accountNo;
|
||||||
this.externalId = externalId;
|
this.externalId = externalId;
|
||||||
|
|||||||
@@ -78,6 +78,30 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "disposition", length = 20)
|
@Column(name = "disposition", length = 20)
|
||||||
private String disposition;
|
private String disposition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other rules this same transaction matched, comma-separated.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* One transaction raises one alert, under the rule that most warrants attention - otherwise a
|
||||||
|
* single deposit arrives as several alerts and an officer has to dispose of each, with nothing
|
||||||
|
* stopping them clearing one while escalating another. The rules that did not win are kept here
|
||||||
|
* so the case file still shows everything that fired.
|
||||||
|
*/
|
||||||
|
@Column(name = "also_matched_rules", length = 500)
|
||||||
|
private String alsoMatchedRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The worst severity band across every rule this transaction matched — 1 Low, 2 Medium, 3 High.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Not the same as the governing rule's own severity. A statutory CTR outranks everything when
|
||||||
|
* the alert is raised, so a high-severity rule can end up recorded in {@link #alsoMatchedRules}
|
||||||
|
* and vanish from the screen. An officer disposing of low and medium alerts in a batch would
|
||||||
|
* then be handed a transaction that had tripped a high-severity rule with nothing to show it.
|
||||||
|
*/
|
||||||
|
@Column(name = "highest_severity")
|
||||||
|
private Integer highestSeverity;
|
||||||
|
|
||||||
protected AmlAlert() {}
|
protected AmlAlert() {}
|
||||||
|
|
||||||
public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule,
|
public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule,
|
||||||
@@ -94,6 +118,58 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
return alert;
|
return alert;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getAlsoMatchedRules() {
|
||||||
|
return this.alsoMatchedRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getHighestSeverity() {
|
||||||
|
return this.highestSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordHighestSeverity(final Integer severity) {
|
||||||
|
this.highestSeverity = severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncated to the column width: the record is a convenience, never a reason to fail a save. */
|
||||||
|
public void recordAlsoMatchedRules(final String ruleCodes) {
|
||||||
|
this.alsoMatchedRules = ruleCodes == null || ruleCodes.isBlank() ? null
|
||||||
|
: (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this alert is being worked by somebody other than the given officer.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* An alert under review belongs to whoever opened it until they decide it or hand it back. A second officer
|
||||||
|
* reading the same case and reaching their own verdict is the failure this exists to prevent: the transaction
|
||||||
|
* would carry two reviews, and whichever was written last would be the one on file.
|
||||||
|
*/
|
||||||
|
public boolean isHeldByAnother(final AppUser officer) {
|
||||||
|
return AmlAlertStatus.UNDER_REVIEW.name().equals(this.status) && this.reviewedBy != null && officer != null
|
||||||
|
&& !this.reviewedBy.getId().equals(officer.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings the alert into line with the Principal Officer's conclusion on the case it opened.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* An escalation is a referral, not a verdict: it says the AML Officer wanted the case looked at. The verdict is
|
||||||
|
* the Principal Officer's, and when they conclude the case clear the alert has to say so too. Left as ESCALATED,
|
||||||
|
* the feed would go on showing a transaction as suspicious after the bank had decided it was not - and the officer
|
||||||
|
* who cleared it would have no way to see their own conclusion reflected where the alert is worked.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Distinct from {@link #whitelist}: this does not pass the alert's own immutability guard, because it is not a
|
||||||
|
* second officer overturning a decision. It is the case this alert was referred into, coming back with its answer.
|
||||||
|
*/
|
||||||
|
public void applyStrConclusion(final boolean suspicious, final AppUser principalOfficer, final String notes) {
|
||||||
|
this.reviewedBy = principalOfficer;
|
||||||
|
this.reviewedOn = LocalDateTime.now();
|
||||||
|
this.reviewNotes = notes;
|
||||||
|
this.disposition = (suspicious ? AmlAlertDisposition.ESCALATE : AmlAlertDisposition.WHITELIST).name();
|
||||||
|
this.status = (suspicious ? AmlAlertStatus.ESCALATED : AmlAlertStatus.WHITELISTED).name();
|
||||||
|
}
|
||||||
|
|
||||||
public void whitelist(AppUser reviewer, String notes) {
|
public void whitelist(AppUser reviewer, String notes) {
|
||||||
this.reviewedBy = reviewer;
|
this.reviewedBy = reviewer;
|
||||||
this.reviewedOn = LocalDateTime.now();
|
this.reviewedOn = LocalDateTime.now();
|
||||||
|
|||||||
+23
@@ -35,4 +35,27 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
|
|||||||
@Param("since") LocalDateTime since);
|
@Param("since") LocalDateTime since);
|
||||||
|
|
||||||
long countByClientId(Long clientId);
|
long countByClientId(Long clientId);
|
||||||
|
|
||||||
|
/** Alerts for one customer inside the recurring-behaviour observation window. */
|
||||||
|
long countByClient_IdAndRaisedOnGreaterThanEqual(Long clientId, LocalDateTime since);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
|
||||||
|
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
|
||||||
|
*/
|
||||||
|
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A customer's still-open alerts inside the observation window, oldest first.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* What a recurring-behaviour review acts on. Open is defined as not yet decided rather than by naming the statuses
|
||||||
|
* that are open, so a status this query does not know about is still treated as work: an alert wrongly included
|
||||||
|
* costs a reviewer a glance, one wrongly excluded is a pattern the bank never looked at. A decision about the
|
||||||
|
* customer's pattern must not reopen alerts an officer has already disposed of individually - and oldest first so
|
||||||
|
* that a partial failure leaves the longest-standing ones dealt with.
|
||||||
|
*/
|
||||||
|
@Query("SELECT a FROM AmlAlert a WHERE a.client.id = :clientId AND a.raisedOn >= :since "
|
||||||
|
+ "AND a.status NOT IN ('WHITELISTED', 'ESCALATED') ORDER BY a.raisedOn ASC")
|
||||||
|
List<AmlAlert> findOpenAlertsForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDateTime since);
|
||||||
}
|
}
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rule a transaction matched, and what the officer decided about that rule.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The alert is the case; this is a finding within it. A single deposit can trip six rules where five are explained by
|
||||||
|
* the customer's declared business and the sixth is not - one verdict over the whole alert either loses that sixth
|
||||||
|
* observation or sends the other five to an STR they do not belong in.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The rule is held by code rather than by a foreign key to {@link AmlTransactionRule}, and the severity is copied in
|
||||||
|
* rather than read back through it. An alert records what was detected at the time; re-banding or retiring a rule
|
||||||
|
* afterwards must not rewrite what the officer was looking at when they decided.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_aml_alert_rule")
|
||||||
|
public class AmlAlertRule {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "alert_id", nullable = false)
|
||||||
|
private AmlAlert alert;
|
||||||
|
|
||||||
|
@Column(name = "rule_code", nullable = false, length = 100)
|
||||||
|
private String ruleCode;
|
||||||
|
|
||||||
|
@Column(name = "risk_category")
|
||||||
|
private Integer riskCategory;
|
||||||
|
|
||||||
|
@Column(name = "is_governing", nullable = false)
|
||||||
|
private boolean governing;
|
||||||
|
|
||||||
|
@Column(name = "disposition", length = 20)
|
||||||
|
private String disposition;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "reviewed_by")
|
||||||
|
private AppUser reviewedBy;
|
||||||
|
|
||||||
|
@Column(name = "reviewed_on")
|
||||||
|
private LocalDateTime reviewedOn;
|
||||||
|
|
||||||
|
@Column(name = "review_notes", length = 1000)
|
||||||
|
private String reviewNotes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the officer named this rule, as against it being closed to keep the case consistent.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Marking one rule suspicious escalates the transaction and closes every other rule under the same escalation -
|
||||||
|
* nothing may stay open behind a filed STR. Without this the record would read as though all of them had been
|
||||||
|
* examined and condemned, and on a transaction that tripped six rules, which one the officer put their name to is
|
||||||
|
* the substance of the finding.
|
||||||
|
*/
|
||||||
|
@Column(name = "directly_decided", nullable = false)
|
||||||
|
private boolean directlyDecided;
|
||||||
|
|
||||||
|
protected AmlAlertRule() {}
|
||||||
|
|
||||||
|
public static AmlAlertRule of(final AmlAlert alert, final String ruleCode, final Integer riskCategory,
|
||||||
|
final boolean governing) {
|
||||||
|
final AmlAlertRule finding = new AmlAlertRule();
|
||||||
|
finding.alert = alert;
|
||||||
|
finding.ruleCode = ruleCode;
|
||||||
|
finding.riskCategory = riskCategory;
|
||||||
|
finding.governing = governing;
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param directlyDecided
|
||||||
|
* true when the officer named this rule; false when it was closed alongside one they named
|
||||||
|
*/
|
||||||
|
public void decide(final AmlAlertDisposition disposition, final AppUser reviewer, final String notes,
|
||||||
|
final boolean directlyDecided) {
|
||||||
|
this.disposition = disposition.name();
|
||||||
|
this.reviewedBy = reviewer;
|
||||||
|
this.reviewedOn = LocalDateTime.now();
|
||||||
|
this.reviewNotes = notes;
|
||||||
|
this.directlyDecided = directlyDecided;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDirectlyDecided() {
|
||||||
|
return this.directlyDecided;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDecided() {
|
||||||
|
return this.disposition != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEscalated() {
|
||||||
|
return AmlAlertDisposition.ESCALATE.name().equals(this.disposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmlAlert getAlert() {
|
||||||
|
return this.alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRuleCode() {
|
||||||
|
return this.ruleCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRiskCategory() {
|
||||||
|
return this.riskCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isGoverning() {
|
||||||
|
return this.governing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisposition() {
|
||||||
|
return this.disposition;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppUser getReviewedBy() {
|
||||||
|
return this.reviewedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getReviewedOn() {
|
||||||
|
return this.reviewedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReviewNotes() {
|
||||||
|
return this.reviewNotes;
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface AmlAlertRuleRepository extends JpaRepository<AmlAlertRule, Long> {
|
||||||
|
|
||||||
|
List<AmlAlertRule> findByAlertId(Long alertId);
|
||||||
|
|
||||||
|
/** Every finding across a page of alerts, in one read rather than one query per alert. */
|
||||||
|
List<AmlAlertRule> findByAlertIdIn(Collection<Long> alertIds);
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a customer's repetition has gone, and what the bank's answer to it has to be.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A customer who surfaces once is a question; one who surfaces for the fortieth time is an answer the bank has not
|
||||||
|
* acted on. Without a ladder every review looked the same - same screen, same three buttons, same outcome - so the
|
||||||
|
* fortieth review cost an officer the same effort as the first and changed as little.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Each tier does two things: it sets how long a review holds the customer out of the queue, and it says what the
|
||||||
|
* reviewer is expected to do. The windows shorten as the tier rises, because a customer the bank is more worried
|
||||||
|
* about should come back sooner, not later.
|
||||||
|
*/
|
||||||
|
public enum AmlRecurringEscalationTier {
|
||||||
|
|
||||||
|
/** Ordinary repetition. A review stands for a quarter unless the count climbs into the next tier. */
|
||||||
|
ROUTINE(0, 90),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enough repetition that the customer's risk rating is no longer just a KYC-time judgement. Reviewed monthly, and
|
||||||
|
* the reviewer is asked to consider whether the rating still fits.
|
||||||
|
*/
|
||||||
|
ELEVATED(7, 30),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beyond what a branch officer should be closing on their own. Reviewed fortnightly, and the review is expected
|
||||||
|
* to be taken by someone senior.
|
||||||
|
*/
|
||||||
|
SENIOR(15, 14),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repetition at a level that is itself the suspicion. An STR is the expected outcome, and clearing at this tier
|
||||||
|
* is a decision that has to be defensible on its own - so the review stands for a week only.
|
||||||
|
*/
|
||||||
|
MANDATORY_STR(30, 7);
|
||||||
|
|
||||||
|
private final int minimumAlertCount;
|
||||||
|
private final int reviewValidityDays;
|
||||||
|
|
||||||
|
AmlRecurringEscalationTier(final int minimumAlertCount, final int reviewValidityDays) {
|
||||||
|
this.minimumAlertCount = minimumAlertCount;
|
||||||
|
this.reviewValidityDays = reviewValidityDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tier a customer sitting on this many alerts falls into. */
|
||||||
|
public static AmlRecurringEscalationTier forAlertCount(final int alertCount) {
|
||||||
|
AmlRecurringEscalationTier tier = ROUTINE;
|
||||||
|
for (final AmlRecurringEscalationTier candidate : values()) {
|
||||||
|
if (alertCount >= candidate.minimumAlertCount) {
|
||||||
|
tier = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMinimumAlertCount() {
|
||||||
|
return minimumAlertCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getReviewValidityDays() {
|
||||||
|
return reviewValidityDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the alert count may climb before a standing review is set aside and the customer returns early.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A validity window alone would hold a customer out while their behaviour changed underneath it: reviewed at four
|
||||||
|
* alerts and then trips forty more the same week, the review would still stand for the full quarter. Allowing the
|
||||||
|
* count to grow by the tier's own span before forcing a return keeps ordinary repetition quiet while letting a
|
||||||
|
* genuine change in behaviour break through. Tighter at the higher tiers, where less change should be tolerated.
|
||||||
|
*/
|
||||||
|
public int alertGrowthBeforeEarlyReturn() {
|
||||||
|
return switch (this) {
|
||||||
|
case ROUTINE -> 5;
|
||||||
|
case ELEVATED -> 4;
|
||||||
|
case SENIOR -> 3;
|
||||||
|
case MANDATORY_STR -> 2;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a review at this tier is expected to raise an STR rather than close the customer off. */
|
||||||
|
public boolean expectsStr() {
|
||||||
|
return this == MANDATORY_STR;
|
||||||
|
}
|
||||||
|
}
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reviewer's judgement on a customer who repeatedly trips the AML engine.
|
||||||
|
*
|
||||||
|
* Distinct from the per-transaction disposition held on {@link AmlAlert}: that answers "was this
|
||||||
|
* transaction acceptable", this answers "is this customer's pattern of behaviour acceptable".
|
||||||
|
* {@link #alertCountAtReview} is what lets the queue clear — see the 0460 changelog.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_aml_recurring_review")
|
||||||
|
public class AmlRecurringReview {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
|
private Client client;
|
||||||
|
|
||||||
|
@Column(name = "alert_count_at_review", nullable = false)
|
||||||
|
private Integer alertCountAtReview;
|
||||||
|
|
||||||
|
@Column(name = "outcome", nullable = false, length = 30)
|
||||||
|
private String outcome;
|
||||||
|
|
||||||
|
@Column(name = "notes", nullable = false, length = 1000)
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "reviewed_by", nullable = false)
|
||||||
|
private AppUser reviewedBy;
|
||||||
|
|
||||||
|
@Column(name = "reviewed_on", nullable = false)
|
||||||
|
private LocalDateTime reviewedOn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date this review stops standing. Null on reviews taken before validity existed, which keep the older
|
||||||
|
* behaviour of lapsing the moment one more alert lands.
|
||||||
|
*/
|
||||||
|
@Column(name = "valid_until")
|
||||||
|
private LocalDate validUntil;
|
||||||
|
|
||||||
|
@Column(name = "escalation_tier", length = 20)
|
||||||
|
private String escalationTier;
|
||||||
|
|
||||||
|
/** How many of the customer's open alerts this review actually disposed of. */
|
||||||
|
@Column(name = "alerts_actioned")
|
||||||
|
private Integer alertsActioned;
|
||||||
|
|
||||||
|
protected AmlRecurringReview() {}
|
||||||
|
|
||||||
|
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
|
||||||
|
final AmlRecurringReviewOutcome outcome, final String notes, final AppUser reviewedBy) {
|
||||||
|
final AmlRecurringReview review = new AmlRecurringReview();
|
||||||
|
review.client = client;
|
||||||
|
review.alertCountAtReview = alertCountAtReview;
|
||||||
|
review.outcome = outcome.name();
|
||||||
|
review.notes = notes;
|
||||||
|
review.reviewedBy = reviewedBy;
|
||||||
|
review.reviewedOn = LocalDateTime.now();
|
||||||
|
|
||||||
|
// The tier follows from the count, not from what the reviewer picked: how often a customer has surfaced is a
|
||||||
|
// fact about them, and letting the outcome set it would let a customer be held at ROUTINE indefinitely by
|
||||||
|
// always being cleared.
|
||||||
|
final AmlRecurringEscalationTier tier = AmlRecurringEscalationTier.forAlertCount(alertCountAtReview);
|
||||||
|
review.escalationTier = tier.name();
|
||||||
|
review.validUntil = LocalDate.now().plusDays(tier.getReviewValidityDays());
|
||||||
|
review.alertsActioned = 0;
|
||||||
|
return review;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records how many open alerts the review disposed of, once the caller has acted on them. */
|
||||||
|
public void recordAlertsActioned(final int count) {
|
||||||
|
this.alertsActioned = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Client getClient() {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getAlertCountAtReview() {
|
||||||
|
return alertCountAtReview;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmlRecurringReviewOutcome getOutcome() {
|
||||||
|
return AmlRecurringReviewOutcome.valueOf(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNotes() {
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppUser getReviewedBy() {
|
||||||
|
return reviewedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getReviewedOn() {
|
||||||
|
return reviewedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getValidUntil() {
|
||||||
|
return validUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmlRecurringEscalationTier getEscalationTier() {
|
||||||
|
return escalationTier == null ? AmlRecurringEscalationTier.ROUTINE : AmlRecurringEscalationTier.valueOf(escalationTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getAlertsActioned() {
|
||||||
|
return alertsActioned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this review still holds the customer out of the review queue.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Two ways it stops standing. Its window runs out, or the customer's behaviour moves on far enough that the
|
||||||
|
* review no longer describes them - judged by how much the alert count has grown since, against the allowance for
|
||||||
|
* the tier it was taken at. A review with no window at all is a pre-existing one, and keeps the original rule of
|
||||||
|
* lapsing as soon as one more alert arrives.
|
||||||
|
*/
|
||||||
|
public boolean stillStands(final int currentAlertCount) {
|
||||||
|
if (validUntil == null) {
|
||||||
|
return currentAlertCount <= alertCountAtReview;
|
||||||
|
}
|
||||||
|
if (LocalDate.now().isAfter(validUntil)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return currentAlertCount - alertCountAtReview < getEscalationTier().alertGrowthBeforeEarlyReturn();
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
/** What a reviewer concluded about a customer's repeated AML activity. */
|
||||||
|
public enum AmlRecurringReviewOutcome {
|
||||||
|
|
||||||
|
/** Behaviour explained and acceptable — nothing further, but the customer stays visible if it recurs. */
|
||||||
|
CLEARED,
|
||||||
|
/** Not conclusive; keep watching this customer more closely. */
|
||||||
|
MONITOR,
|
||||||
|
/** Warrants an STR case; the reviewer escalates the individual alerts separately. */
|
||||||
|
ESCALATED
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface AmlRecurringReviewRepository extends JpaRepository<AmlRecurringReview, Long> {
|
||||||
|
|
||||||
|
/** Newest first, so the caller can take the head as the latest review for a customer. */
|
||||||
|
List<AmlRecurringReview> findByClient_IdOrderByReviewedOnDesc(Long clientId);
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One alert covered by an STR case.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A case escalated from a single transaction covers that one alert. A case raised off a customer's repeated behaviour
|
||||||
|
* covers all the alerts that make up the pattern - which is what an STR to FIU-IND actually describes, and what the
|
||||||
|
* Principal Officer has to be able to read in one place.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_aml_str_case_alert")
|
||||||
|
public class AmlStrCaseAlert {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "str_case_id", nullable = false)
|
||||||
|
private AmlStrCase strCase;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "alert_id", nullable = false)
|
||||||
|
private AmlAlert alert;
|
||||||
|
|
||||||
|
@Column(name = "added_on_utc", nullable = false)
|
||||||
|
private LocalDateTime addedOn;
|
||||||
|
|
||||||
|
protected AmlStrCaseAlert() {}
|
||||||
|
|
||||||
|
public static AmlStrCaseAlert link(final AmlStrCase strCase, final AmlAlert alert) {
|
||||||
|
final AmlStrCaseAlert link = new AmlStrCaseAlert();
|
||||||
|
link.strCase = strCase;
|
||||||
|
link.alert = alert;
|
||||||
|
link.addedOn = LocalDateTime.now();
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmlStrCase getStrCase() {
|
||||||
|
return strCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AmlAlert getAlert() {
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getAddedOn() {
|
||||||
|
return addedOn;
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface AmlStrCaseAlertRepository extends JpaRepository<AmlStrCaseAlert, Long> {
|
||||||
|
|
||||||
|
/** The alerts one case covers, oldest first - the order the pattern happened in. */
|
||||||
|
List<AmlStrCaseAlert> findByStrCase_IdOrderByAlert_RaisedOnAsc(Long strCaseId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The case already covering an alert, if any.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* What stops a transaction reaching FIU-IND twice: an alert already inside a case is not attached to a second
|
||||||
|
* one, however it comes to be escalated again.
|
||||||
|
*/
|
||||||
|
Optional<AmlStrCaseAlert> findByAlert_Id(Long alertId);
|
||||||
|
|
||||||
|
long countByStrCase_Id(Long strCaseId);
|
||||||
|
}
|
||||||
+4
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.aml.domain;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -28,6 +29,9 @@ public interface AmlStrCaseRepository extends JpaRepository<AmlStrCase, Long> {
|
|||||||
|
|
||||||
long countByClient_Id(Long clientId);
|
long countByClient_Id(Long clientId);
|
||||||
|
|
||||||
|
/** The case already open for a transaction, so escalating it again does not open a second one. */
|
||||||
|
Optional<AmlStrCase> findByAlertId(Long alertId);
|
||||||
|
|
||||||
List<AmlStrCase> findByStatus(String status);
|
List<AmlStrCase> findByStatus(String status);
|
||||||
|
|
||||||
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to")
|
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to")
|
||||||
|
|||||||
+102
@@ -52,6 +52,26 @@ public class AmlTransactionRule {
|
|||||||
@Column(name = "threshold_amount", precision = 19, scale = 6)
|
@Column(name = "threshold_amount", precision = 19, scale = 6)
|
||||||
private BigDecimal thresholdAmount;
|
private BigDecimal thresholdAmount;
|
||||||
|
|
||||||
|
/** Risk-category-tiered overrides of {@link #thresholdAmount}. Null on both means the rule is
|
||||||
|
* not tiered; {@link #thresholdAmount} is used for every client regardless of risk category. */
|
||||||
|
@Column(name = "threshold_amount_medium_risk", precision = 19, scale = 6)
|
||||||
|
private BigDecimal thresholdAmountMediumRisk;
|
||||||
|
|
||||||
|
@Column(name = "threshold_amount_high_risk", precision = 19, scale = 6)
|
||||||
|
private BigDecimal thresholdAmountHighRisk;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declarative band the rule covers, shown on the AML Rules Master screen. Purely
|
||||||
|
* descriptive: unlike the tiered columns above these never take part in matching, so
|
||||||
|
* changing them cannot alter which transactions raise an alert. A null maximum means
|
||||||
|
* the rule is genuinely open-ended.
|
||||||
|
*/
|
||||||
|
@Column(name = "min_limit_amount", precision = 19, scale = 6)
|
||||||
|
private BigDecimal minLimitAmount;
|
||||||
|
|
||||||
|
@Column(name = "max_limit_amount", precision = 19, scale = 6)
|
||||||
|
private BigDecimal maxLimitAmount;
|
||||||
|
|
||||||
@Column(name = "rule_type", nullable = false, length = 20)
|
@Column(name = "rule_type", nullable = false, length = 20)
|
||||||
private String ruleType;
|
private String ruleType;
|
||||||
|
|
||||||
@@ -64,8 +84,74 @@ public class AmlTransactionRule {
|
|||||||
@Column(name = "is_active", nullable = false)
|
@Column(name = "is_active", nullable = false)
|
||||||
private boolean active;
|
private boolean active;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CREDIT or DEBIT for a rule written about one direction only; null where either way qualifies.
|
||||||
|
* A rule named for withdrawals must not fire on a deposit.
|
||||||
|
*/
|
||||||
|
@Column(name = "direction", length = 10)
|
||||||
|
private String direction;
|
||||||
|
|
||||||
|
/** True where only physical cash qualifies — a cash-reporting rule is not about transfers. */
|
||||||
|
@Column(name = "cash_only", nullable = false)
|
||||||
|
private boolean cashOnly;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many transactions must make up the window before a pattern rule fires. Structuring is
|
||||||
|
* many small transactions; without this floor a single large one satisfies the window total and
|
||||||
|
* reads as a pattern it is not.
|
||||||
|
*/
|
||||||
|
@Column(name = "min_transaction_count")
|
||||||
|
private Integer minTransactionCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which customers a rule is about, where the rule is about the customer rather than the
|
||||||
|
* transaction: PEP, CHARITABLE_ORG, HIGH_RISK_BUSINESS, NEW_ACCOUNT. Null means the rule is
|
||||||
|
* about the transaction alone and so applies to everyone.
|
||||||
|
*/
|
||||||
|
@Column(name = "subject_scope", length = 30)
|
||||||
|
private String subjectScope;
|
||||||
|
|
||||||
|
public String getSubjectScope() {
|
||||||
|
return subjectScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDirection() {
|
||||||
|
return direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isCashOnly() {
|
||||||
|
return cashOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getMinTransactionCount() {
|
||||||
|
return minTransactionCount;
|
||||||
|
}
|
||||||
|
|
||||||
protected AmlTransactionRule() {}
|
protected AmlTransactionRule() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the live-transaction counterpart of a {@code ClientRiskRule} authored from the AML
|
||||||
|
* Rules Master screen. {@code sourceClientRiskRuleId} is the link the read side joins on, so
|
||||||
|
* the two rows are always created together and the rule code is derived from it.
|
||||||
|
*/
|
||||||
|
public static AmlTransactionRule createForRiskRule(final Long sourceClientRiskRuleId, final String description,
|
||||||
|
final AmlAppliesTo appliesTo, final AmlRuleType ruleType, final Integer windowDays,
|
||||||
|
final BigDecimal thresholdAmount, final BigDecimal minLimitAmount, final BigDecimal maxLimitAmount) {
|
||||||
|
final AmlTransactionRule rule = new AmlTransactionRule();
|
||||||
|
rule.sourceClientRiskRuleId = sourceClientRiskRuleId;
|
||||||
|
// rule_code is UNIQUE; the source rule id is unique per row, so this cannot collide.
|
||||||
|
rule.ruleCode = "CRR-" + sourceClientRiskRuleId;
|
||||||
|
rule.description = description;
|
||||||
|
rule.appliesTo = appliesTo.name();
|
||||||
|
rule.ruleType = ruleType.name();
|
||||||
|
rule.windowDays = windowDays;
|
||||||
|
rule.thresholdAmount = thresholdAmount;
|
||||||
|
rule.minLimitAmount = minLimitAmount;
|
||||||
|
rule.maxLimitAmount = maxLimitAmount;
|
||||||
|
rule.active = true;
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -86,6 +172,22 @@ public class AmlTransactionRule {
|
|||||||
return thresholdAmount;
|
return thresholdAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BigDecimal getThresholdAmountMediumRisk() {
|
||||||
|
return thresholdAmountMediumRisk;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getThresholdAmountHighRisk() {
|
||||||
|
return thresholdAmountHighRisk;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getMinLimitAmount() {
|
||||||
|
return minLimitAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getMaxLimitAmount() {
|
||||||
|
return maxLimitAmount;
|
||||||
|
}
|
||||||
|
|
||||||
public AmlRuleType getRuleType() {
|
public AmlRuleType getRuleType() {
|
||||||
return AmlRuleType.valueOf(ruleType);
|
return AmlRuleType.valueOf(ruleType);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -18,10 +18,25 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.aml.domain;
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
|
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
|
||||||
|
|
||||||
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
|
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks a rule up by its code irrespective of {@code is_active}. Needed for MANUAL_REFERRAL, which is deliberately
|
||||||
|
* seeded inactive so the evaluation engine never fires it, yet must still be attachable to an alert a user raises
|
||||||
|
* by hand from the ledger.
|
||||||
|
*/
|
||||||
|
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every rule named in the given codes, in one read. The alert screen resolves the rules each transaction matched
|
||||||
|
* into descriptions and severities; doing that a code at a time would be one query per rule per alert.
|
||||||
|
*/
|
||||||
|
List<AmlTransactionRule> findByRuleCodeIn(Collection<String> ruleCodes);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -162,6 +162,15 @@ public class ClientApiConstants {
|
|||||||
public static final String ageDaysParamName = "ageDays";
|
public static final String ageDaysParamName = "ageDays";
|
||||||
public static final String nationalityIdParamName = "nationalityId";
|
public static final String nationalityIdParamName = "nationalityId";
|
||||||
public static final String isMinorParamName = "isMinor";
|
public static final String isMinorParamName = "isMinor";
|
||||||
|
/**
|
||||||
|
* Below this age a client is treated as a minor: guardian mapping required and a restricted KYC/OVD
|
||||||
|
* document set. Must stay in step with MINOR_AGE_THRESHOLD in the web app's client-age util -- the
|
||||||
|
* KYC template endpoint is authoritative for the document list, so a mismatch would serve an adult
|
||||||
|
* list (with mandatory PAN) for a client the UI classifies as a minor.
|
||||||
|
*/
|
||||||
|
public static final int MINOR_AGE_THRESHOLD = 10;
|
||||||
|
/** At or above this age a client qualifies for senior-citizen deposit rates. */
|
||||||
|
public static final int SENIOR_CITIZEN_AGE_THRESHOLD = 60;
|
||||||
public static final String guardianClientIdParamName = "guardianClientId";
|
public static final String guardianClientIdParamName = "guardianClientId";
|
||||||
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
|
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
|
||||||
public static final String guardianRelationParamName = "guardianRelation";
|
public static final String guardianRelationParamName = "guardianRelation";
|
||||||
@@ -191,6 +200,7 @@ public class ClientApiConstants {
|
|||||||
public static final String constitutionIdParamName = "constitutionId";
|
public static final String constitutionIdParamName = "constitutionId";
|
||||||
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
|
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
|
||||||
public static final String authorizedSignatoryParamName = "authorizedSignatory";
|
public static final String authorizedSignatoryParamName = "authorizedSignatory";
|
||||||
|
public static final String authorizedSignatoryClientIdsParamName = "authorizedSignatoryClientIds";
|
||||||
public static final String additionalInfoParamName = "additionalInfo";
|
public static final String additionalInfoParamName = "additionalInfo";
|
||||||
public static final String dateOfRegistrationParamName = "dateOfRegistration";
|
public static final String dateOfRegistrationParamName = "dateOfRegistration";
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
private Boolean alternateDipVerified;
|
private Boolean alternateDipVerified;
|
||||||
private Long alternateDipLookupLogId;
|
private Long alternateDipLookupLogId;
|
||||||
private String alternateDipTimestamp;
|
private String alternateDipTimestamp;
|
||||||
|
/**
|
||||||
|
* Live DIP/MNRL status of the number, resolved from the latest lookup rather than the flag
|
||||||
|
* captured at onboarding. A customer cleared at onboarding whose number is later reported or
|
||||||
|
* reassigned would otherwise keep showing "Verified" forever.
|
||||||
|
*/
|
||||||
|
private String dipLiveStatus;
|
||||||
|
private String alternateDipLiveStatus;
|
||||||
|
/**
|
||||||
|
* Disposition of the client's most recent flagged sanctions alert, or null when they carry
|
||||||
|
* none. Like the DIP status this is resolved live: nameVerified is captured at onboarding and
|
||||||
|
* would keep reading "Clear" after the client was added to a later list snapshot.
|
||||||
|
*/
|
||||||
|
private String sanctionsAlertStatus;
|
||||||
private Boolean nameVerified;
|
private Boolean nameVerified;
|
||||||
private Long screeningLogId;
|
private Long screeningLogId;
|
||||||
private Boolean isStaff;
|
private Boolean isStaff;
|
||||||
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||||
|
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||||
|
// here is silently dropped from the payload the client view loads.
|
||||||
|
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||||
|
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||||
|
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||||
data.nameVerified = clientData.nameVerified;
|
data.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
return data;
|
||||||
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||||
|
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||||
|
// here is silently dropped from the payload the client view loads.
|
||||||
|
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||||
|
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||||
|
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||||
data.nameVerified = clientData.nameVerified;
|
data.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
return data;
|
||||||
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||||
|
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||||
|
// here is silently dropped from the payload the client view loads.
|
||||||
|
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||||
|
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||||
|
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||||
data.nameVerified = clientData.nameVerified;
|
data.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 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.data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public final class ClientFamilyMemberChildData implements Serializable {
|
||||||
|
private final Long id;
|
||||||
|
private final String fullName;
|
||||||
|
private final Long genderId;
|
||||||
|
private final String gender;
|
||||||
|
private final LocalDate dateOfBirth;
|
||||||
|
}
|
||||||
+3
@@ -84,6 +84,9 @@ public final class ClientFamilyMembersData implements Serializable {
|
|||||||
private final LocalDate marriageDate;
|
private final LocalDate marriageDate;
|
||||||
private final String marriageAuthority;
|
private final String marriageAuthority;
|
||||||
|
|
||||||
|
@lombok.Setter
|
||||||
|
private java.util.List<ClientFamilyMemberChildData> children;
|
||||||
|
|
||||||
// template holder
|
// template holder
|
||||||
private final Collection<CodeValueData> relationshipIdOptions;
|
private final Collection<CodeValueData> relationshipIdOptions;
|
||||||
private final Collection<CodeValueData> genderIdOptions;
|
private final Collection<CodeValueData> genderIdOptions;
|
||||||
|
|||||||
+14
@@ -34,6 +34,8 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
private final CodeValueData mainBusinessLine;
|
private final CodeValueData mainBusinessLine;
|
||||||
private final String remarks;
|
private final String remarks;
|
||||||
private final String authorizedSignatory;
|
private final String authorizedSignatory;
|
||||||
|
/** Client ids behind {@link #authorizedSignatory}, comma separated and in the same order. */
|
||||||
|
private final String authorizedSignatoryClientIds;
|
||||||
private final String additionalInfo;
|
private final String additionalInfo;
|
||||||
private final LocalDate dateOfRegistration;
|
private final LocalDate dateOfRegistration;
|
||||||
|
|
||||||
@@ -62,13 +64,24 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
this.constitution = null;
|
this.constitution = null;
|
||||||
this.mainBusinessLine = null;
|
this.mainBusinessLine = null;
|
||||||
this.authorizedSignatory = null;
|
this.authorizedSignatory = null;
|
||||||
|
this.authorizedSignatoryClientIds = null;
|
||||||
this.additionalInfo = null;
|
this.additionalInfo = null;
|
||||||
this.dateOfRegistration = null;
|
this.dateOfRegistration = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kept for the list/search mappers, whose SQL does not select the signatory client ids.
|
||||||
|
*/
|
||||||
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
||||||
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||||
LocalDate dateOfRegistration) {
|
LocalDate dateOfRegistration) {
|
||||||
|
this(constitution, incorpNo, incorpValidityTillDate, mainBusinessLine, remarks, authorizedSignatory, additionalInfo,
|
||||||
|
dateOfRegistration, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
||||||
|
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||||
|
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
|
||||||
|
|
||||||
this.constitution = constitution;
|
this.constitution = constitution;
|
||||||
this.incorpNumber = incorpNo;
|
this.incorpNumber = incorpNo;
|
||||||
@@ -76,6 +89,7 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
this.mainBusinessLine = mainBusinessLine;
|
this.mainBusinessLine = mainBusinessLine;
|
||||||
this.remarks = remarks;
|
this.remarks = remarks;
|
||||||
this.authorizedSignatory = authorizedSignatory;
|
this.authorizedSignatory = authorizedSignatory;
|
||||||
|
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds;
|
||||||
this.additionalInfo = additionalInfo;
|
this.additionalInfo = additionalInfo;
|
||||||
this.dateOfRegistration = dateOfRegistration;
|
this.dateOfRegistration = dateOfRegistration;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinTable;
|
|||||||
import jakarta.persistence.ManyToMany;
|
import jakarta.persistence.ManyToMany;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.OneToMany;
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OneToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.UniqueConstraint;
|
import jakarta.persistence.UniqueConstraint;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -249,6 +250,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "is_pwd")
|
@Column(name = "is_pwd")
|
||||||
private Boolean isPwd;
|
private Boolean isPwd;
|
||||||
|
|
||||||
|
@OneToOne(mappedBy = "client", fetch = FetchType.LAZY)
|
||||||
|
private ClientKyc clientKyc;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "pwd_category_cv_id")
|
@JoinColumn(name = "pwd_category_cv_id")
|
||||||
private CodeValue pwdCategory;
|
private CodeValue pwdCategory;
|
||||||
@@ -290,6 +294,17 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "alternate_dip_timestamp")
|
@Column(name = "alternate_dip_timestamp")
|
||||||
private String alternateDipTimestamp;
|
private String alternateDipTimestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DIP/MNRL status the number actually returned — CLEAN, SUSPECTED, DISCONNECTED or
|
||||||
|
* SERVICE_UNAVAILABLE. dipVerified alone cannot tell "checked and clean" apart from "the DoT
|
||||||
|
* service was down", which previously stored both as verified.
|
||||||
|
*/
|
||||||
|
@Column(name = "dip_status", length = 40)
|
||||||
|
private String dipStatus;
|
||||||
|
|
||||||
|
@Column(name = "alternate_dip_status", length = 40)
|
||||||
|
private String alternateDipStatus;
|
||||||
|
|
||||||
@Column(name = "name_verified")
|
@Column(name = "name_verified")
|
||||||
private Boolean nameVerified;
|
private Boolean nameVerified;
|
||||||
|
|
||||||
|
|||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one customer's money actually did over a period, and what that says about their rating.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A risk rating describes behaviour, and behaviour is a period rather than an event. Judging it from the alert feed
|
||||||
|
* meant a customer appeared the moment one transaction tripped a rule and was recommended for re-rating on that alone
|
||||||
|
* - a single shop purchase could argue for lowering a High rating, and a single large deposit for raising a Low one.
|
||||||
|
* Neither is a pattern.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* One row is one window for one customer, computed nightly. Upgrades and downgrades read different windows, because
|
||||||
|
* the questions are not symmetric: a concern is about what the customer is doing now, and confidence needs a year of
|
||||||
|
* it. The suggestion is stored with the figures it was drawn from, so an officer reviewing it months later sees what
|
||||||
|
* the system was looking at rather than a conclusion with no working.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_client_behaviour_window")
|
||||||
|
public class ClientBehaviourWindow {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "client_id", nullable = false)
|
||||||
|
private Long clientId;
|
||||||
|
|
||||||
|
@Column(name = "window_from", nullable = false)
|
||||||
|
private LocalDate windowFrom;
|
||||||
|
|
||||||
|
@Column(name = "window_to", nullable = false)
|
||||||
|
private LocalDate windowTo;
|
||||||
|
|
||||||
|
@Column(name = "txn_count")
|
||||||
|
private Integer txnCount;
|
||||||
|
|
||||||
|
@Column(name = "distinct_months")
|
||||||
|
private Integer distinctMonths;
|
||||||
|
|
||||||
|
@Column(name = "credit_total", precision = 19, scale = 6)
|
||||||
|
private BigDecimal creditTotal;
|
||||||
|
|
||||||
|
@Column(name = "debit_total", precision = 19, scale = 6)
|
||||||
|
private BigDecimal debitTotal;
|
||||||
|
|
||||||
|
@Column(name = "largest_txn", precision = 19, scale = 6)
|
||||||
|
private BigDecimal largestTxn;
|
||||||
|
|
||||||
|
@Column(name = "cash_txn_count")
|
||||||
|
private Integer cashTxnCount;
|
||||||
|
|
||||||
|
@Column(name = "cash_total", precision = 19, scale = 6)
|
||||||
|
private BigDecimal cashTotal;
|
||||||
|
|
||||||
|
@Column(name = "large_txn_count")
|
||||||
|
private Integer largeTxnCount;
|
||||||
|
|
||||||
|
@Column(name = "declared_annual_income", precision = 19, scale = 6)
|
||||||
|
private BigDecimal declaredAnnualIncome;
|
||||||
|
|
||||||
|
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
|
||||||
|
private BigDecimal declaredAnnualTurnover;
|
||||||
|
|
||||||
|
@Column(name = "income_ratio", precision = 19, scale = 6)
|
||||||
|
private BigDecimal incomeRatio;
|
||||||
|
|
||||||
|
@Column(name = "alert_count")
|
||||||
|
private Integer alertCount;
|
||||||
|
|
||||||
|
@Column(name = "str_case_count")
|
||||||
|
private Integer strCaseCount;
|
||||||
|
|
||||||
|
@Column(name = "risk_category_at_review")
|
||||||
|
private Integer riskCategoryAtReview;
|
||||||
|
|
||||||
|
/** UP, DOWN or HOLD. */
|
||||||
|
@Column(name = "suggested_direction", length = 10)
|
||||||
|
private String suggestedDirection;
|
||||||
|
|
||||||
|
@Column(name = "suggested_category")
|
||||||
|
private Integer suggestedCategory;
|
||||||
|
|
||||||
|
@Column(name = "suggestion_reason", length = 500)
|
||||||
|
private String suggestionReason;
|
||||||
|
|
||||||
|
@Column(name = "computed_on")
|
||||||
|
private LocalDateTime computedOn;
|
||||||
|
|
||||||
|
protected ClientBehaviourWindow() {}
|
||||||
|
|
||||||
|
public static ClientBehaviourWindow of(final Long clientId, final LocalDate windowFrom, final LocalDate windowTo) {
|
||||||
|
final ClientBehaviourWindow window = new ClientBehaviourWindow();
|
||||||
|
window.clientId = clientId;
|
||||||
|
window.windowFrom = windowFrom;
|
||||||
|
window.windowTo = windowTo;
|
||||||
|
window.computedOn = LocalDateTime.now();
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordDistinctMonths(final Integer months) {
|
||||||
|
this.distinctMonths = months;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getDistinctMonths() {
|
||||||
|
return this.distinctMonths;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordActivity(final Integer txnCount, final BigDecimal creditTotal, final BigDecimal debitTotal,
|
||||||
|
final BigDecimal largestTxn, final Integer cashTxnCount, final BigDecimal cashTotal, final Integer largeTxnCount) {
|
||||||
|
this.txnCount = txnCount;
|
||||||
|
this.creditTotal = creditTotal;
|
||||||
|
this.debitTotal = debitTotal;
|
||||||
|
this.largestTxn = largestTxn;
|
||||||
|
this.cashTxnCount = cashTxnCount;
|
||||||
|
this.cashTotal = cashTotal;
|
||||||
|
this.largeTxnCount = largeTxnCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordMonitoring(final Integer alertCount, final Integer strCaseCount, final Integer riskCategoryAtReview) {
|
||||||
|
this.alertCount = alertCount;
|
||||||
|
this.strCaseCount = strCaseCount;
|
||||||
|
this.riskCategoryAtReview = riskCategoryAtReview;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turnover measured against what the customer said to expect.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Null rather than zero when no income was declared: the ratio is unknown, not nil, and a rule that treats an
|
||||||
|
* unknown as a benign zero would clear every customer the bank never asked.
|
||||||
|
*/
|
||||||
|
public void recordDeclared(final BigDecimal income, final BigDecimal turnover, final BigDecimal periodTurnover) {
|
||||||
|
this.declaredAnnualIncome = income;
|
||||||
|
this.declaredAnnualTurnover = turnover;
|
||||||
|
final BigDecimal basis = income != null && income.signum() > 0 ? income
|
||||||
|
: (turnover != null && turnover.signum() > 0 ? turnover : null);
|
||||||
|
this.incomeRatio = basis == null || periodTurnover == null ? null
|
||||||
|
: periodTurnover.divide(basis, 6, java.math.RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordSuggestion(final String direction, final Integer category, final String reason) {
|
||||||
|
this.suggestedDirection = direction;
|
||||||
|
this.suggestedCategory = category;
|
||||||
|
this.suggestionReason = reason == null || reason.length() <= 500 ? reason : reason.substring(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getClientId() {
|
||||||
|
return this.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getWindowFrom() {
|
||||||
|
return this.windowFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getWindowTo() {
|
||||||
|
return this.windowTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTxnCount() {
|
||||||
|
return this.txnCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getCashTotal() {
|
||||||
|
return this.cashTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLargeTxnCount() {
|
||||||
|
return this.largeTxnCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getAlertCount() {
|
||||||
|
return this.alertCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getStrCaseCount() {
|
||||||
|
return this.strCaseCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRiskCategoryAtReview() {
|
||||||
|
return this.riskCategoryAtReview;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSuggestedDirection() {
|
||||||
|
return this.suggestedDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSuggestedCategory() {
|
||||||
|
return this.suggestedCategory;
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface ClientBehaviourWindowRepository extends JpaRepository<ClientBehaviourWindow, Long> {
|
||||||
|
|
||||||
|
List<ClientBehaviourWindow> findByClientId(Long clientId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the windows computed for a given day before the job writes that day's.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The job is a recomputation, not an append. Re-running it - after a failure, or twice in a night - must leave one
|
||||||
|
* set of windows rather than a customer appearing several times on the review screen with slightly different
|
||||||
|
* figures.
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM ClientBehaviourWindow w WHERE w.windowTo = :windowTo")
|
||||||
|
void deleteByWindowTo(@Param("windowTo") LocalDate windowTo);
|
||||||
|
}
|
||||||
+474
-11
@@ -25,10 +25,15 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Transient;
|
||||||
import jakarta.persistence.UniqueConstraint;
|
import jakarta.persistence.UniqueConstraint;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||||
@@ -60,6 +65,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@JoinColumn(name = "client_id", nullable = false)
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
private Client client;
|
private Client client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scratch record of what the in-flight {@link #update} replaced, read back via
|
||||||
|
* {@link #getFieldsOverwrittenByLastUpdate()}. Never persisted and never read outside the
|
||||||
|
* transaction that called update(), which clears it first.
|
||||||
|
*/
|
||||||
|
@Transient
|
||||||
|
private final transient Set<String> fieldsOverwrittenByLastUpdate = new LinkedHashSet<>();
|
||||||
|
|
||||||
/** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */
|
/** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */
|
||||||
@Column(name = "kyc_status", nullable = false)
|
@Column(name = "kyc_status", nullable = false)
|
||||||
private Integer kycStatus;
|
private Integer kycStatus;
|
||||||
@@ -103,6 +116,36 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "pan_status", length = 20)
|
@Column(name = "pan_status", length = 20)
|
||||||
private String panStatus;
|
private String panStatus;
|
||||||
|
|
||||||
|
// ── PAN declaration ──────────────────────────────────────────────────────
|
||||||
|
// Accepted in place of a passing PAN check. The Income Tax check matches on name AND date of
|
||||||
|
// birth, so a customer whose PAN card carries a different spelling — or a different DOB — can
|
||||||
|
// never clear it however many times the officer retries. Rather than leave such a client
|
||||||
|
// permanently in KYC exception, the branch takes a signed declaration recording what differs.
|
||||||
|
// Holding that declaration is what suppresses the exception, so it is stored, not just shown.
|
||||||
|
|
||||||
|
/** True once a signed declaration has been accepted for a PAN that could not be verified. */
|
||||||
|
@Column(name = "pan_declaration_taken")
|
||||||
|
private Boolean panDeclarationTaken;
|
||||||
|
|
||||||
|
/** What the declaration covers: "NAME", "DOB", "NAME,DOB", or "NONE" when PAN simply failed. */
|
||||||
|
@Column(name = "pan_declaration_mismatch_fields", length = 100)
|
||||||
|
private String panDeclarationMismatchFields;
|
||||||
|
|
||||||
|
/** Name exactly as printed on the physical PAN card, keyed in by the officer. */
|
||||||
|
@Column(name = "pan_declaration_name_on_pan", length = 200)
|
||||||
|
private String panDeclarationNameOnPan;
|
||||||
|
|
||||||
|
/** Date of birth exactly as printed on the physical PAN card. */
|
||||||
|
@Column(name = "pan_declaration_dob_on_pan")
|
||||||
|
private LocalDate panDeclarationDobOnPan;
|
||||||
|
|
||||||
|
/** Officer's free-text note — why the mismatch exists and what proof was sighted. */
|
||||||
|
@Column(name = "pan_declaration_remarks", length = 500)
|
||||||
|
private String panDeclarationRemarks;
|
||||||
|
|
||||||
|
@Column(name = "pan_declaration_taken_on")
|
||||||
|
private LocalDate panDeclarationTakenOn;
|
||||||
|
|
||||||
// ── Aadhaar ──────────────────────────────────────────────────────────────
|
// ── Aadhaar ──────────────────────────────────────────────────────────────
|
||||||
/** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */
|
/** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */
|
||||||
@Column(name = "aadhaar_number_masked", length = 12)
|
@Column(name = "aadhaar_number_masked", length = 12)
|
||||||
@@ -114,6 +157,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "aadhaar_verified_on")
|
@Column(name = "aadhaar_verified_on")
|
||||||
private LocalDate aadhaarVerifiedOn;
|
private LocalDate aadhaarVerifiedOn;
|
||||||
|
|
||||||
|
// ── Aadhaar enrolment receipt ────────────────────────────────────────────
|
||||||
|
// The counterpart of the PAN declaration, for a customer who has applied for Aadhaar but does
|
||||||
|
// not hold one yet. UIDAI issues an enrolment slip carrying a 28-digit EID, and until Aadhaar
|
||||||
|
// itself is issued there is no number to put through the OTP flow — the check cannot pass
|
||||||
|
// however long the branch waits. Recording the receipt is what waives the exception.
|
||||||
|
|
||||||
|
/** True once the customer's Aadhaar enrolment slip has been sighted and recorded. */
|
||||||
|
@Column(name = "aadhaar_receipt_taken")
|
||||||
|
private Boolean aadhaarReceiptTaken;
|
||||||
|
|
||||||
|
/** 28-digit Enrolment ID from the slip, stored with its printed separators. */
|
||||||
|
@Column(name = "aadhaar_enrolment_id", length = 40)
|
||||||
|
private String aadhaarEnrolmentId;
|
||||||
|
|
||||||
|
/** Enrolment date printed on the slip — a receipt is temporary, so its age matters. */
|
||||||
|
@Column(name = "aadhaar_enrolment_date")
|
||||||
|
private LocalDate aadhaarEnrolmentDate;
|
||||||
|
|
||||||
|
/** Officer's note on what was sighted. */
|
||||||
|
@Column(name = "aadhaar_receipt_remarks", length = 500)
|
||||||
|
private String aadhaarReceiptRemarks;
|
||||||
|
|
||||||
|
@Column(name = "aadhaar_receipt_taken_on")
|
||||||
|
private LocalDate aadhaarReceiptTakenOn;
|
||||||
|
|
||||||
// ── GSTIN (mandatory for NON_INDIVIDUAL clients) ─────────────────────────
|
// ── GSTIN (mandatory for NON_INDIVIDUAL clients) ─────────────────────────
|
||||||
@Column(name = "gstin_number", length = 15)
|
@Column(name = "gstin_number", length = 15)
|
||||||
private String gstinNumber;
|
private String gstinNumber;
|
||||||
@@ -195,6 +263,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "freeze_authority", length = 100)
|
@Column(name = "freeze_authority", length = 100)
|
||||||
private String freezeAuthority;
|
private String freezeAuthority;
|
||||||
|
|
||||||
|
@Column(name = "freeze_date")
|
||||||
|
private LocalDate freezeDate;
|
||||||
|
|
||||||
|
@Column(name = "freeze_amount", precision = 19, scale = 6)
|
||||||
|
private BigDecimal freezeAmount;
|
||||||
|
|
||||||
|
@Column(name = "unfreeze_date")
|
||||||
|
private LocalDate unfreezeDate;
|
||||||
|
|
||||||
|
@Column(name = "no_debit_date")
|
||||||
|
private LocalDate noDebitDate;
|
||||||
|
|
||||||
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
|
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
|
||||||
@Column(name = "form60_type", length = 20)
|
@Column(name = "form60_type", length = 20)
|
||||||
private String form60Type;
|
private String form60Type;
|
||||||
@@ -211,6 +291,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "form15_fy", length = 10)
|
@Column(name = "form15_fy", length = 10)
|
||||||
private String form15Fy;
|
private String form15Fy;
|
||||||
|
|
||||||
|
// ── Risk profile ─────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Customer facts the risk re-rating policy tests against, as against anything the customer transacted.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A politically exposed person is not downgraded because their activity looks ordinary — that is precisely the
|
||||||
|
* risk — and the same holds for a sanctions or adverse-media match. False means "not recorded as such", which is
|
||||||
|
* the correct reading for a book where nobody was ever asked; it is not an assertion that the customer is clean.
|
||||||
|
*/
|
||||||
|
@Column(name = "is_pep", nullable = false)
|
||||||
|
private boolean isPep;
|
||||||
|
|
||||||
|
@Column(name = "sanctions_match", nullable = false)
|
||||||
|
private boolean sanctionsMatch;
|
||||||
|
|
||||||
|
@Column(name = "adverse_media_match", nullable = false)
|
||||||
|
private boolean adverseMediaMatch;
|
||||||
|
|
||||||
|
/** What the customer told the bank to expect, which their actual turnover is measured against. */
|
||||||
|
@Column(name = "declared_annual_income", precision = 19, scale = 6)
|
||||||
|
private BigDecimal declaredAnnualIncome;
|
||||||
|
|
||||||
|
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
|
||||||
|
private BigDecimal declaredAnnualTurnover;
|
||||||
|
|
||||||
// ── CKYCRR ───────────────────────────────────────────────────────────────
|
// ── CKYCRR ───────────────────────────────────────────────────────────────
|
||||||
/** Central KYC Records Registry number issued by CERSAI */
|
/** Central KYC Records Registry number issued by CERSAI */
|
||||||
@Column(name = "ckyc_number", length = 14)
|
@Column(name = "ckyc_number", length = 14)
|
||||||
@@ -241,6 +346,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "remarks", length = 500)
|
@Column(name = "remarks", length = 500)
|
||||||
private String remarks;
|
private String remarks;
|
||||||
|
|
||||||
|
@Column(name = "is_exception", nullable = false)
|
||||||
|
private Boolean isException;
|
||||||
|
|
||||||
|
@Column(name = "exception_reason", length = 200)
|
||||||
|
private String exceptionReason;
|
||||||
|
|
||||||
|
@Column(name = "exception_resolved", nullable = false)
|
||||||
|
private Boolean exceptionResolved;
|
||||||
|
|
||||||
|
@Column(name = "exception_resolved_on")
|
||||||
|
private LocalDate exceptionResolvedOn;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
protected ClientKyc() {}
|
protected ClientKyc() {}
|
||||||
@@ -261,7 +378,9 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
this.gstinVerified = false;
|
this.gstinVerified = false;
|
||||||
this.cinVerified = false;
|
this.cinVerified = false;
|
||||||
this.dinVerified = false;
|
this.dinVerified = false;
|
||||||
this.initiatedOn = DateUtils.getLocalDateOfTenant();
|
this.isException = false;
|
||||||
|
this.exceptionResolved = false;
|
||||||
|
this.initiatedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
|
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
|
||||||
@@ -288,6 +407,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
private void applyExtendedFields(final JsonCommand command) {
|
private void applyExtendedFields(final JsonCommand command) {
|
||||||
this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty(
|
this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty(
|
||||||
command.stringValueOfParameterNamed("panAadhaarSeedingStatus"), this.panAadhaarSeedingStatus);
|
command.stringValueOfParameterNamed("panAadhaarSeedingStatus"), this.panAadhaarSeedingStatus);
|
||||||
|
final Boolean panDeclarationTakenVal = command.booleanObjectValueOfParameterNamed("panDeclarationTaken");
|
||||||
|
this.panDeclarationTaken = panDeclarationTakenVal != null ? panDeclarationTakenVal : false;
|
||||||
|
this.panDeclarationMismatchFields = StringUtils.defaultIfEmpty(
|
||||||
|
command.stringValueOfParameterNamed("panDeclarationMismatchFields"), null);
|
||||||
|
this.panDeclarationNameOnPan = StringUtils.defaultIfEmpty(
|
||||||
|
command.stringValueOfParameterNamed("panDeclarationNameOnPan"), null);
|
||||||
|
this.panDeclarationDobOnPan = command.localDateValueOfParameterNamed("panDeclarationDobOnPan");
|
||||||
|
this.panDeclarationRemarks = StringUtils.defaultIfEmpty(
|
||||||
|
command.stringValueOfParameterNamed("panDeclarationRemarks"), null);
|
||||||
|
this.panDeclarationTakenOn = command.localDateValueOfParameterNamed("panDeclarationTakenOn");
|
||||||
|
final Boolean aadhaarReceiptTakenVal = command.booleanObjectValueOfParameterNamed("aadhaarReceiptTaken");
|
||||||
|
this.aadhaarReceiptTaken = aadhaarReceiptTakenVal != null ? aadhaarReceiptTakenVal : false;
|
||||||
|
this.aadhaarEnrolmentId = StringUtils.defaultIfEmpty(
|
||||||
|
command.stringValueOfParameterNamed("aadhaarEnrolmentId"), null);
|
||||||
|
this.aadhaarEnrolmentDate = command.localDateValueOfParameterNamed("aadhaarEnrolmentDate");
|
||||||
|
this.aadhaarReceiptRemarks = StringUtils.defaultIfEmpty(
|
||||||
|
command.stringValueOfParameterNamed("aadhaarReceiptRemarks"), null);
|
||||||
|
this.aadhaarReceiptTakenOn = command.localDateValueOfParameterNamed("aadhaarReceiptTakenOn");
|
||||||
this.enterprisePan = StringUtils.defaultIfEmpty(
|
this.enterprisePan = StringUtils.defaultIfEmpty(
|
||||||
command.stringValueOfParameterNamed("enterprisePan"), null);
|
command.stringValueOfParameterNamed("enterprisePan"), null);
|
||||||
this.cinNumber = StringUtils.defaultIfEmpty(
|
this.cinNumber = StringUtils.defaultIfEmpty(
|
||||||
@@ -317,6 +454,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
command.stringValueOfParameterNamed("freezeReason"), null);
|
command.stringValueOfParameterNamed("freezeReason"), null);
|
||||||
this.freezeAuthority = StringUtils.defaultIfEmpty(
|
this.freezeAuthority = StringUtils.defaultIfEmpty(
|
||||||
command.stringValueOfParameterNamed("freezeAuthority"), null);
|
command.stringValueOfParameterNamed("freezeAuthority"), null);
|
||||||
|
this.freezeDate = command.localDateValueOfParameterNamed("freezeDate");
|
||||||
|
this.freezeAmount = command.bigDecimalValueOfParameterNamed("freezeAmount");
|
||||||
|
this.unfreezeDate = command.localDateValueOfParameterNamed("unfreezeDate");
|
||||||
|
this.noDebitDate = command.localDateValueOfParameterNamed("noDebitDate");
|
||||||
this.form60Type = StringUtils.defaultIfEmpty(
|
this.form60Type = StringUtils.defaultIfEmpty(
|
||||||
command.stringValueOfParameterNamed("form60Type"), null);
|
command.stringValueOfParameterNamed("form60Type"), null);
|
||||||
this.form60Date = command.localDateValueOfParameterNamed("form60Date");
|
this.form60Date = command.localDateValueOfParameterNamed("form60Date");
|
||||||
@@ -325,15 +466,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
command.stringValueOfParameterNamed("form15Type"), null);
|
command.stringValueOfParameterNamed("form15Type"), null);
|
||||||
this.form15Fy = StringUtils.defaultIfEmpty(
|
this.form15Fy = StringUtils.defaultIfEmpty(
|
||||||
command.stringValueOfParameterNamed("form15FY"), null);
|
command.stringValueOfParameterNamed("form15FY"), null);
|
||||||
|
Boolean isExceptionVal = command.booleanObjectValueOfParameterNamed("isException");
|
||||||
|
this.isException = isExceptionVal != null ? isExceptionVal : false;
|
||||||
|
this.exceptionReason = StringUtils.defaultIfEmpty(
|
||||||
|
command.stringValueOfParameterNamed("exceptionReason"), null);
|
||||||
|
Boolean exceptionResolvedVal = command.booleanObjectValueOfParameterNamed("exceptionResolved");
|
||||||
|
this.exceptionResolved = exceptionResolvedVal != null ? exceptionResolvedVal : false;
|
||||||
|
this.exceptionResolvedOn = command.localDateValueOfParameterNamed("exceptionResolvedOn");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> update(final JsonCommand command) {
|
public Map<String, Object> update(final JsonCommand command) {
|
||||||
final Map<String, Object> actualChanges = new LinkedHashMap<>();
|
final Map<String, Object> actualChanges = new LinkedHashMap<>();
|
||||||
|
this.fieldsOverwrittenByLastUpdate.clear();
|
||||||
|
|
||||||
final String panNumberParam = "panNumber";
|
final String panNumberParam = "panNumber";
|
||||||
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
|
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
|
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
|
||||||
actualChanges.put(panNumberParam, newValue);
|
actualChanges.put(panNumberParam, newValue);
|
||||||
|
recordOverwrite(panNumberParam, this.panNumber);
|
||||||
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
|
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +491,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
|
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
|
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
|
||||||
actualChanges.put(aadhaarParam, newValue);
|
actualChanges.put(aadhaarParam, newValue);
|
||||||
|
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
||||||
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +499,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
|
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(gstinParam);
|
final String newValue = command.stringValueOfParameterNamed(gstinParam);
|
||||||
actualChanges.put(gstinParam, newValue);
|
actualChanges.put(gstinParam, newValue);
|
||||||
|
recordOverwrite(gstinParam, this.gstinNumber);
|
||||||
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
|
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,9 +507,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
|
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
|
||||||
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
|
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
|
||||||
actualChanges.put(riskParam, newValue);
|
actualChanges.put(riskParam, newValue);
|
||||||
|
recordOverwrite(riskParam, this.riskCategory);
|
||||||
this.riskCategory = newValue;
|
this.riskCategory = newValue;
|
||||||
|
|
||||||
|
// The re-KYC cycle is a property of the risk category, so moving the category moves the
|
||||||
|
// due date with it — otherwise a customer raised to High keeps a Low customer's ten-year
|
||||||
|
// renewal and is never called back in for the two-yearly review the rating requires.
|
||||||
|
final LocalDate newExpiry = recalculateExpiryForRiskCategory();
|
||||||
|
if (newExpiry != null) {
|
||||||
|
actualChanges.put("expiryDate", newExpiry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remarks is the audit justification itself, so a change to it is deliberately not recorded
|
||||||
|
// as an overwrite — requiring remarks in order to write remarks would be circular.
|
||||||
final String remarksParam = "remarks";
|
final String remarksParam = "remarks";
|
||||||
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
|
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(remarksParam);
|
final String newValue = command.stringValueOfParameterNamed(remarksParam);
|
||||||
@@ -367,6 +530,41 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
|
|
||||||
updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges,
|
updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges,
|
||||||
v -> this.panAadhaarSeedingStatus = v);
|
v -> this.panAadhaarSeedingStatus = v);
|
||||||
|
updateBooleanField(command, "panDeclarationTaken", this.panDeclarationTaken, actualChanges,
|
||||||
|
v -> this.panDeclarationTaken = v != null ? v : false);
|
||||||
|
updateStringField(command, "panDeclarationMismatchFields", this.panDeclarationMismatchFields, actualChanges,
|
||||||
|
v -> this.panDeclarationMismatchFields = v);
|
||||||
|
updateStringField(command, "panDeclarationNameOnPan", this.panDeclarationNameOnPan, actualChanges,
|
||||||
|
v -> this.panDeclarationNameOnPan = v);
|
||||||
|
updateDateField(command, "panDeclarationDobOnPan", this.panDeclarationDobOnPan, actualChanges,
|
||||||
|
v -> this.panDeclarationDobOnPan = v);
|
||||||
|
updateStringField(command, "panDeclarationRemarks", this.panDeclarationRemarks, actualChanges,
|
||||||
|
v -> this.panDeclarationRemarks = v);
|
||||||
|
updateDateField(command, "panDeclarationTakenOn", this.panDeclarationTakenOn, actualChanges,
|
||||||
|
v -> this.panDeclarationTakenOn = v);
|
||||||
|
updateBooleanField(command, "aadhaarReceiptTaken", this.aadhaarReceiptTaken, actualChanges,
|
||||||
|
v -> this.aadhaarReceiptTaken = v != null ? v : false);
|
||||||
|
updateStringField(command, "aadhaarEnrolmentId", this.aadhaarEnrolmentId, actualChanges,
|
||||||
|
v -> this.aadhaarEnrolmentId = v);
|
||||||
|
updateDateField(command, "aadhaarEnrolmentDate", this.aadhaarEnrolmentDate, actualChanges,
|
||||||
|
v -> this.aadhaarEnrolmentDate = v);
|
||||||
|
updateStringField(command, "aadhaarReceiptRemarks", this.aadhaarReceiptRemarks, actualChanges,
|
||||||
|
v -> this.aadhaarReceiptRemarks = v);
|
||||||
|
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
|
||||||
|
v -> this.aadhaarReceiptTakenOn = v);
|
||||||
|
// Risk profile. These are determinations about the customer rather than about a transaction, and the
|
||||||
|
// re-rating policy reads them directly: a PEP or a sanctions match holds a rating at High however ordinary the
|
||||||
|
// period's activity looks.
|
||||||
|
updateBooleanField(command, "isPep", this.isPep, actualChanges, v -> this.isPep = v != null ? v : false);
|
||||||
|
updateBooleanField(command, "sanctionsMatch", this.sanctionsMatch, actualChanges,
|
||||||
|
v -> this.sanctionsMatch = v != null ? v : false);
|
||||||
|
updateBooleanField(command, "adverseMediaMatch", this.adverseMediaMatch, actualChanges,
|
||||||
|
v -> this.adverseMediaMatch = v != null ? v : false);
|
||||||
|
updateBigDecimalField(command, "declaredAnnualIncome", this.declaredAnnualIncome, actualChanges,
|
||||||
|
v -> this.declaredAnnualIncome = v);
|
||||||
|
updateBigDecimalField(command, "declaredAnnualTurnover", this.declaredAnnualTurnover, actualChanges,
|
||||||
|
v -> this.declaredAnnualTurnover = v);
|
||||||
|
|
||||||
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
|
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
|
||||||
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
|
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
|
||||||
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
|
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
|
||||||
@@ -392,12 +590,34 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v);
|
updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v);
|
||||||
updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges,
|
updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges,
|
||||||
v -> this.freezeAuthority = v);
|
v -> this.freezeAuthority = v);
|
||||||
|
updateDateField(command, "freezeDate", this.freezeDate, actualChanges, v -> this.freezeDate = v);
|
||||||
|
updateBigDecimalField(command, "freezeAmount", this.freezeAmount, actualChanges, v -> this.freezeAmount = v);
|
||||||
|
updateDateField(command, "unfreezeDate", this.unfreezeDate, actualChanges, v -> this.unfreezeDate = v);
|
||||||
|
updateDateField(command, "noDebitDate", this.noDebitDate, actualChanges, v -> this.noDebitDate = v);
|
||||||
updateStringField(command, "form60Type", this.form60Type, actualChanges, v -> this.form60Type = v);
|
updateStringField(command, "form60Type", this.form60Type, actualChanges, v -> this.form60Type = v);
|
||||||
updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v);
|
updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v);
|
||||||
updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges,
|
updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges,
|
||||||
v -> this.form15Applicable = v);
|
v -> this.form15Applicable = v);
|
||||||
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
||||||
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
||||||
|
// The four exception columns are derived from whether the identity checks have passed, not
|
||||||
|
// data an officer types, so they are updated without recording an overwrite. A verified
|
||||||
|
// record demands remarks for any field it already held (see
|
||||||
|
// getFieldsOverwrittenByLastUpdate), and is_exception is never null once written — so
|
||||||
|
// counting these would make closing a settled exception impossible without a justification
|
||||||
|
// for something nobody chose. That is what left clients verified on the KYC tab while the
|
||||||
|
// exceptions register still listed them as Pending.
|
||||||
|
updateDerivedBooleanField(command, "isException", this.isException, actualChanges,
|
||||||
|
v -> this.isException = v != null ? v : false);
|
||||||
|
updateDerivedStringField(command, "exceptionReason", this.exceptionReason, actualChanges,
|
||||||
|
v -> this.exceptionReason = v);
|
||||||
|
updateDerivedBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges,
|
||||||
|
v -> this.exceptionResolved = v != null ? v : false);
|
||||||
|
updateDerivedDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges,
|
||||||
|
v -> this.exceptionResolvedOn = v);
|
||||||
|
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
||||||
|
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
||||||
|
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
||||||
|
|
||||||
return actualChanges;
|
return actualChanges;
|
||||||
}
|
}
|
||||||
@@ -407,6 +627,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(param, current)) {
|
if (command.isChangeInStringParameterNamed(param, current)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(param);
|
final String newValue = command.stringValueOfParameterNamed(param);
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,6 +637,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
||||||
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(newValue);
|
setter.accept(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -426,11 +648,89 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
||||||
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(newValue);
|
setter.accept(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateBigDecimalField(final JsonCommand command, final String param, final BigDecimal current,
|
||||||
|
final Map<String, Object> changes, final java.util.function.Consumer<BigDecimal> setter) {
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed(param, current)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
|
||||||
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
|
setter.accept(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes that {@code param} replaced a value the record already held. A field going from empty to
|
||||||
|
* populated is the record being completed rather than altered, so it is deliberately not
|
||||||
|
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* Derived-field variants of the update helpers above.
|
||||||
|
*
|
||||||
|
* Identical, minus the recordOverwrite call: these columns are computed from whether the
|
||||||
|
* identity checks have passed rather than entered by an officer, so replacing one is not the
|
||||||
|
* kind of change a verified record demands a justification for. They still report into
|
||||||
|
* `changes`, so the update is persisted and audited like any other.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private void updateDerivedBooleanField(final JsonCommand command, final String param, final Boolean current,
|
||||||
|
final Map<String, Object> changes, final java.util.function.Consumer<Boolean> setter) {
|
||||||
|
if (command.parameterExists(param)) {
|
||||||
|
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
||||||
|
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
||||||
|
changes.put(param, newValue);
|
||||||
|
setter.accept(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDerivedStringField(final JsonCommand command, final String param, final String current,
|
||||||
|
final Map<String, Object> changes, final java.util.function.Consumer<String> setter) {
|
||||||
|
if (command.isChangeInStringParameterNamed(param, current)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed(param);
|
||||||
|
changes.put(param, newValue);
|
||||||
|
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDerivedDateField(final JsonCommand command, final String param, final LocalDate current,
|
||||||
|
final Map<String, Object> changes, final java.util.function.Consumer<LocalDate> setter) {
|
||||||
|
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
||||||
|
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
||||||
|
changes.put(param, newValue);
|
||||||
|
setter.accept(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordOverwrite(final String param, final Object currentValue) {
|
||||||
|
final boolean wasPopulated = currentValue instanceof String text
|
||||||
|
? StringUtils.isNotBlank(text)
|
||||||
|
: currentValue != null;
|
||||||
|
if (wasPopulated) {
|
||||||
|
this.fieldsOverwrittenByLastUpdate.add(param);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters whose already-populated value the most recent {@link #update} replaced.
|
||||||
|
*
|
||||||
|
* <p>Distinguishes altering a record from finishing one: a non-individual KYC auto-verifies as
|
||||||
|
* soon as PAN and GSTIN pass, which happens part-way through the create wizard, and the officer
|
||||||
|
* still has to save the KYC document details afterwards. Those fields are empty at that point,
|
||||||
|
* so filling them in leaves this set empty and no audit justification is demanded — while
|
||||||
|
* changing a document number that was already on the verified record does populate it.
|
||||||
|
*
|
||||||
|
* <p>Reset at the start of every {@link #update} and never persisted: it describes one call.
|
||||||
|
*/
|
||||||
|
public Set<String> getFieldsOverwrittenByLastUpdate() {
|
||||||
|
return Collections.unmodifiableSet(this.fieldsOverwrittenByLastUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
/** Called when documents are submitted and 3rd-party verification is initiated. */
|
/** Called when documents are submitted and 3rd-party verification is initiated. */
|
||||||
public void markInProgress() {
|
public void markInProgress() {
|
||||||
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
||||||
@@ -457,13 +757,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
public void markCkycUploaded(final String ckycNumber, final String batchId) {
|
public void markCkycUploaded(final String ckycNumber, final String batchId) {
|
||||||
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
||||||
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
|
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
|
||||||
this.ckycUploadedOn = DateUtils.getLocalDateOfTenant();
|
this.ckycUploadedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called after successful 3rd-party verification of all required documents. */
|
/** Called after successful 3rd-party verification of all required documents. */
|
||||||
public void markVerified(final String ckycNumber) {
|
public void markVerified(final String ckycNumber) {
|
||||||
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
|
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
|
||||||
this.verifiedOn = DateUtils.getLocalDateOfTenant();
|
this.verifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
||||||
|
|
||||||
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
|
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
|
||||||
@@ -472,17 +772,45 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-dates the re-KYC due date after the risk category moves.
|
||||||
|
*
|
||||||
|
* The cycle length belongs to the category the customer is in <em>now</em> (Low 10, Medium 8,
|
||||||
|
* High 2), and it is counted from the day of the change rather than from the original
|
||||||
|
* verification. A customer moved to High in year two of a Low rating is then due in two years'
|
||||||
|
* time, not immediately — dating it from {@code verifiedOn} would push most of the book into
|
||||||
|
* an overdue queue the moment a re-rating ran.
|
||||||
|
*
|
||||||
|
* <p>Only a verified KYC carries an expiry: an unverified record has nothing to renew, and
|
||||||
|
* {@link #markVerified} sets the date when it is first verified.
|
||||||
|
*
|
||||||
|
* @return the new expiry date, or {@code null} if the record carries no expiry
|
||||||
|
*/
|
||||||
|
private LocalDate recalculateExpiryForRiskCategory() {
|
||||||
|
if (this.verifiedOn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
|
||||||
|
if (risk.getReKycYears() <= 0) {
|
||||||
|
return this.expiryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.expiryDate = DateUtils.getBusinessLocalDate().plusYears(risk.getReKycYears());
|
||||||
|
return this.expiryDate;
|
||||||
|
}
|
||||||
|
|
||||||
/** Called when verification fails. */
|
/** Called when verification fails. */
|
||||||
public void markRejected(final String remarks) {
|
public void markRejected(final String remarks) {
|
||||||
this.kycStatus = ClientKycStatus.REJECTED.getValue();
|
this.kycStatus = ClientKycStatus.REJECTED.getValue();
|
||||||
this.rejectedOn = DateUtils.getLocalDateOfTenant();
|
this.rejectedOn = DateUtils.getBusinessLocalDate();
|
||||||
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
|
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markPanVerified(final String fullName, final String firstName, final String lastName,
|
public void markPanVerified(final String fullName, final String firstName, final String lastName,
|
||||||
final String aadhaarSeedingStatus, final String category, final String status) {
|
final String aadhaarSeedingStatus, final String category, final String status) {
|
||||||
this.panVerified = true;
|
this.panVerified = true;
|
||||||
this.panVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.panVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
|
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
|
||||||
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
|
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
|
||||||
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
|
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
|
||||||
@@ -508,28 +836,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
|
|
||||||
public void markAadhaarVerified() {
|
public void markAadhaarVerified() {
|
||||||
this.aadhaarVerified = true;
|
this.aadhaarVerified = true;
|
||||||
this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.aadhaarVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markGstinVerified() {
|
public void markGstinVerified() {
|
||||||
this.gstinVerified = true;
|
this.gstinVerified = true;
|
||||||
this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.gstinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markCinVerified() {
|
public void markCinVerified() {
|
||||||
this.cinVerified = true;
|
this.cinVerified = true;
|
||||||
this.cinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.cinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markDinVerified() {
|
public void markDinVerified() {
|
||||||
this.dinVerified = true;
|
this.dinVerified = true;
|
||||||
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
|
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
|
||||||
this.dinNumber = dinNumber;
|
this.dinNumber = dinNumber;
|
||||||
this.dinVerified = true;
|
this.dinVerified = true;
|
||||||
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||||
this.isDirector = true;
|
this.isDirector = true;
|
||||||
this.dinMcaResponse = mcaResponseJson;
|
this.dinMcaResponse = mcaResponseJson;
|
||||||
}
|
}
|
||||||
@@ -550,7 +878,17 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
} else if (type != null && type.isNonIndividual()) {
|
} else if (type != null && type.isNonIndividual()) {
|
||||||
allDone = this.panVerified && this.gstinVerified;
|
allDone = this.panVerified && this.gstinVerified;
|
||||||
}
|
}
|
||||||
if (allDone && !getKycStatus().isVerified()) {
|
// Re-stamped even when the record already reads VERIFIED, because that is exactly what a
|
||||||
|
// periodic re-KYC is: an already-verified customer verified afresh.
|
||||||
|
//
|
||||||
|
// Skipping it there left verifiedOn — and with it the expiry date, and the dormancy clock
|
||||||
|
// that now measures from it — frozen at the original onboarding. An officer who re-verified
|
||||||
|
// both PAN and Aadhaar at the counter saw both rows stamped with today's date and the
|
||||||
|
// account still sitting on the inactive report, with nothing to say what was missing.
|
||||||
|
//
|
||||||
|
// Guarded on the verifications rather than the status, so this only fires when the checks
|
||||||
|
// that constitute a verification have actually been done again.
|
||||||
|
if (allDone) {
|
||||||
markVerified(this.ckycNumber);
|
markVerified(this.ckycNumber);
|
||||||
}
|
}
|
||||||
return allDone;
|
return allDone;
|
||||||
@@ -593,6 +931,29 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
*/
|
*/
|
||||||
public void escalateRiskCategory(Integer newCategory) {
|
public void escalateRiskCategory(Integer newCategory) {
|
||||||
this.riskCategory = newCategory;
|
this.riskCategory = newCategory;
|
||||||
|
// An auto-escalation moves the rating for the same reason an officer would, so it moves the
|
||||||
|
// re-KYC due date on the same terms — see recalculateExpiryForRiskCategory().
|
||||||
|
recalculateExpiryForRiskCategory();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPep() {
|
||||||
|
return this.isPep;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSanctionsMatch() {
|
||||||
|
return this.sanctionsMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAdverseMediaMatch() {
|
||||||
|
return this.adverseMediaMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getDeclaredAnnualIncome() {
|
||||||
|
return this.declaredAnnualIncome;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getDeclaredAnnualTurnover() {
|
||||||
|
return this.declaredAnnualTurnover;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getPanNumber() {
|
public String getPanNumber() {
|
||||||
@@ -631,6 +992,60 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
return panStatus;
|
return panStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getPanDeclarationTaken() {
|
||||||
|
return panDeclarationTaken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null-safe form for callers deciding whether the PAN exception is waived. */
|
||||||
|
public boolean isPanDeclarationTaken() {
|
||||||
|
return Boolean.TRUE.equals(this.panDeclarationTaken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPanDeclarationMismatchFields() {
|
||||||
|
return panDeclarationMismatchFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPanDeclarationNameOnPan() {
|
||||||
|
return panDeclarationNameOnPan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getPanDeclarationDobOnPan() {
|
||||||
|
return panDeclarationDobOnPan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPanDeclarationRemarks() {
|
||||||
|
return panDeclarationRemarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getPanDeclarationTakenOn() {
|
||||||
|
return panDeclarationTakenOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getAadhaarReceiptTaken() {
|
||||||
|
return aadhaarReceiptTaken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null-safe form for callers deciding whether the Aadhaar exception is waived. */
|
||||||
|
public boolean isAadhaarReceiptTaken() {
|
||||||
|
return Boolean.TRUE.equals(this.aadhaarReceiptTaken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAadhaarEnrolmentId() {
|
||||||
|
return aadhaarEnrolmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getAadhaarEnrolmentDate() {
|
||||||
|
return aadhaarEnrolmentDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAadhaarReceiptRemarks() {
|
||||||
|
return aadhaarReceiptRemarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getAadhaarReceiptTakenOn() {
|
||||||
|
return aadhaarReceiptTakenOn;
|
||||||
|
}
|
||||||
|
|
||||||
public String getAadhaarNumberMasked() {
|
public String getAadhaarNumberMasked() {
|
||||||
return aadhaarNumberMasked;
|
return aadhaarNumberMasked;
|
||||||
}
|
}
|
||||||
@@ -795,6 +1210,38 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
return form15Fy;
|
return form15Fy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getIsException() {
|
||||||
|
return isException;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsException(Boolean isException) {
|
||||||
|
this.isException = isException;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getExceptionReason() {
|
||||||
|
return exceptionReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExceptionReason(String exceptionReason) {
|
||||||
|
this.exceptionReason = exceptionReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getExceptionResolved() {
|
||||||
|
return exceptionResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExceptionResolved(Boolean exceptionResolved) {
|
||||||
|
this.exceptionResolved = exceptionResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getExceptionResolvedOn() {
|
||||||
|
return exceptionResolvedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExceptionResolvedOn(LocalDate exceptionResolvedOn) {
|
||||||
|
this.exceptionResolvedOn = exceptionResolvedOn;
|
||||||
|
}
|
||||||
|
|
||||||
@Column(name = "is_active", nullable = false)
|
@Column(name = "is_active", nullable = false)
|
||||||
private Boolean isActive = Boolean.TRUE;
|
private Boolean isActive = Boolean.TRUE;
|
||||||
|
|
||||||
@@ -846,4 +1293,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
public Long getVersion() {
|
public Long getVersion() {
|
||||||
return this.version;
|
return this.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LocalDate getFreezeDate() {
|
||||||
|
return this.freezeDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getFreezeAmount() {
|
||||||
|
return this.freezeAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getUnfreezeDate() {
|
||||||
|
return this.unfreezeDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getNoDebitDate() {
|
||||||
|
return this.noDebitDate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One customer-outreach attempt against an open KYC exception.
|
||||||
|
*
|
||||||
|
* <p>Append-only by design — there is no setter-driven update path and the API exposes no PUT or
|
||||||
|
* DELETE. A contact log that can be edited after the fact is worthless as evidence, and RBI's KYC
|
||||||
|
* Master Direction expects the bank to be able to show what it told the customer and when.
|
||||||
|
*
|
||||||
|
* <p>Destinations are stored masked ({@code destinationMasked}); the full mobile number / email
|
||||||
|
* lives on {@code m_client} and is resolved server-side at send time, never accepted from the
|
||||||
|
* browser.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_client_kyc_contact_log")
|
||||||
|
public class ClientKycContactLog {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "id")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
|
private Client client;
|
||||||
|
|
||||||
|
/** The KYC record the exception belongs to. Nullable — outreach can precede a KYC row. */
|
||||||
|
@Column(name = "kyc_id")
|
||||||
|
private Long kycId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "channel", length = 20, nullable = false)
|
||||||
|
private KycContactChannel channel;
|
||||||
|
|
||||||
|
@Column(name = "notes", length = 1000, nullable = false)
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@Column(name = "officer_name", length = 200)
|
||||||
|
private String officerName;
|
||||||
|
|
||||||
|
@Column(name = "logged_by_user_id")
|
||||||
|
private Long loggedByUserId;
|
||||||
|
|
||||||
|
@Column(name = "exception_reason", length = 200)
|
||||||
|
private String exceptionReason;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "delivery_status", length = 30, nullable = false)
|
||||||
|
private KycContactDeliveryStatus deliveryStatus;
|
||||||
|
|
||||||
|
@Column(name = "destination_masked", length = 120)
|
||||||
|
private String destinationMasked;
|
||||||
|
|
||||||
|
@Column(name = "provider", length = 40)
|
||||||
|
private String provider;
|
||||||
|
|
||||||
|
@Column(name = "provider_reference", length = 120)
|
||||||
|
private String providerReference;
|
||||||
|
|
||||||
|
@Column(name = "failure_reason", length = 500)
|
||||||
|
private String failureReason;
|
||||||
|
|
||||||
|
@Column(name = "message_body", length = 2000)
|
||||||
|
private String messageBody;
|
||||||
|
|
||||||
|
@Column(name = "contacted_at", nullable = false)
|
||||||
|
private LocalDateTime contactedAt;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
protected ClientKycContactLog() {
|
||||||
|
// JPA
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClientKycContactLog(final Client client, final Long kycId, final KycContactChannel channel, final String notes,
|
||||||
|
final String officerName, final Long loggedByUserId, final String exceptionReason, final LocalDateTime contactedAt) {
|
||||||
|
this.client = client;
|
||||||
|
this.kycId = kycId;
|
||||||
|
this.channel = channel;
|
||||||
|
this.notes = notes;
|
||||||
|
this.officerName = officerName;
|
||||||
|
this.loggedByUserId = loggedByUserId;
|
||||||
|
this.exceptionReason = exceptionReason;
|
||||||
|
this.contactedAt = contactedAt;
|
||||||
|
this.createdAt = contactedAt;
|
||||||
|
this.deliveryStatus = KycContactDeliveryStatus.NOT_APPLICABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp the outcome of a platform send onto this log row. Called once, before the row is
|
||||||
|
* persisted — the entity is append-only afterwards.
|
||||||
|
*/
|
||||||
|
public void recordDeliveryOutcome(final KycContactDeliveryStatus status, final String destinationMasked, final String provider,
|
||||||
|
final String providerReference, final String failureReason, final String messageBody) {
|
||||||
|
this.deliveryStatus = status;
|
||||||
|
this.destinationMasked = destinationMasked;
|
||||||
|
this.provider = provider;
|
||||||
|
this.providerReference = providerReference;
|
||||||
|
this.failureReason = truncate(failureReason, 500);
|
||||||
|
this.messageBody = truncate(messageBody, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(final String value, final int max) {
|
||||||
|
if (value == null || value.length() <= max) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value.substring(0, max - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Client getClient() {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getKycId() {
|
||||||
|
return this.kycId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public KycContactChannel getChannel() {
|
||||||
|
return this.channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNotes() {
|
||||||
|
return this.notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOfficerName() {
|
||||||
|
return this.officerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getLoggedByUserId() {
|
||||||
|
return this.loggedByUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getExceptionReason() {
|
||||||
|
return this.exceptionReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public KycContactDeliveryStatus getDeliveryStatus() {
|
||||||
|
return this.deliveryStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDestinationMasked() {
|
||||||
|
return this.destinationMasked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getProvider() {
|
||||||
|
return this.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getProviderReference() {
|
||||||
|
return this.providerReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFailureReason() {
|
||||||
|
return this.failureReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessageBody() {
|
||||||
|
return this.messageBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getContactedAt() {
|
||||||
|
return this.contactedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return this.createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface ClientKycContactLogRepository extends JpaRepository<ClientKycContactLog, Long> {
|
||||||
|
|
||||||
|
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every contact log for the given clients in one round trip, so the exceptions grid can render
|
||||||
|
* "last contacted" for the whole page without an N+1 query per row.
|
||||||
|
*/
|
||||||
|
@Query("SELECT l FROM ClientKycContactLog l WHERE l.client.id IN :clientIds ORDER BY l.contactedAt ASC")
|
||||||
|
List<ClientKycContactLog> findByClientIds(@Param("clientIds") List<Long> clientIds);
|
||||||
|
}
|
||||||
+25
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.client.domain;
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@@ -33,9 +34,33 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
|
|||||||
|
|
||||||
boolean existsByClientId(Long clientId);
|
boolean existsByClientId(Long clientId);
|
||||||
|
|
||||||
|
List<ClientKyc> findByIsExceptionTrueAndExceptionResolvedFalse();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved exceptions, keyed on {@code exceptionResolved} alone.
|
||||||
|
*
|
||||||
|
* <p>Deliberately does NOT also require {@code isException = true}. Resolving an exception
|
||||||
|
* clears {@code isException} — that flag means "has an OPEN exception", so leaving it set on a
|
||||||
|
* closed one would be wrong. An earlier version of this query required both flags, which made
|
||||||
|
* every resolved record invisible in both tabs the moment it was resolved.
|
||||||
|
*/
|
||||||
|
List<ClientKyc> findByExceptionResolvedTrue();
|
||||||
|
|
||||||
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
||||||
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
|
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KYC records whose periodic re-KYC due date has passed, oldest first.
|
||||||
|
*
|
||||||
|
* <p>Only records that carry an expiry are considered: an expiry is set when the KYC is first
|
||||||
|
* verified, so one without a date has never been verified and has no renewal cycle to lapse.
|
||||||
|
* A rejected record is excluded for the same reason — it is not a lapsed KYC, it is one that
|
||||||
|
* never took effect, and restricting an account over it would punish the wrong thing.
|
||||||
|
*/
|
||||||
|
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate <= :asOn "
|
||||||
|
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
|
||||||
|
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
|
||||||
|
|
||||||
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
||||||
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
||||||
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
||||||
|
|||||||
-7
@@ -63,13 +63,6 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
|
|||||||
@Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId")
|
@Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId")
|
||||||
void removeSignatureId(@Param("clientId") Long clientId);
|
void removeSignatureId(@Param("clientId") Long clientId);
|
||||||
|
|
||||||
/**
|
|
||||||
* Count clients whose cif_number begins with the given prefix. Used by the CIF number generator
|
|
||||||
* (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket.
|
|
||||||
*/
|
|
||||||
@Query("SELECT COUNT(c) FROM Client c WHERE c.cifNumber LIKE CONCAT(:prefix, '%')")
|
|
||||||
long countByCifNumberPrefix(@Param("prefix") String prefix);
|
|
||||||
|
|
||||||
// ── Pre-save uniqueness probes ───────────────────────────────────────────
|
// ── Pre-save uniqueness probes ───────────────────────────────────────────
|
||||||
// Why: DB has UNIQUE on mobile_no / email_address, but we want to fail fast with a clear
|
// Why: DB has UNIQUE on mobile_no / email_address, but we want to fail fast with a clear
|
||||||
// field-level error BEFORE saveAndFlush — and importantly before the external KYC API call.
|
// field-level error BEFORE saveAndFlush — and importantly before the external KYC API call.
|
||||||
|
|||||||
+72
@@ -93,12 +93,84 @@ public class ClientRiskRule {
|
|||||||
@Column(name = "order_position", nullable = false)
|
@Column(name = "order_position", nullable = false)
|
||||||
private Integer orderPosition;
|
private Integer orderPosition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the two rule families this row belongs to - {@link #KIND_KYC} or {@link #KIND_AML}.
|
||||||
|
*
|
||||||
|
* This table carries both, and they are not interchangeable. A KYC rule is profile-driven: it
|
||||||
|
* reads occupation / income / turnover to categorise a customer at onboarding (RBI Master
|
||||||
|
* Direction on KYC, Section 12). An AML rule is behaviour-driven: it describes a transaction
|
||||||
|
* pattern and may only fire against posted transactions, surfacing in {@code m_aml_alert}
|
||||||
|
* (Section 13).
|
||||||
|
*
|
||||||
|
* The distinction used to be inferred from "has no criteria set", which migration 0448 erased
|
||||||
|
* by writing 0.0 over the NULLs - after which transaction-monitoring rules began matching
|
||||||
|
* ordinary customers at onboarding and pre-flagging them for AML. Recording the kind means a
|
||||||
|
* rule states what it is; nothing has to guess from the shape of its other columns.
|
||||||
|
*/
|
||||||
|
@Column(name = "rule_kind", length = 10)
|
||||||
|
private String ruleKind;
|
||||||
|
|
||||||
|
public static final String KIND_KYC = "KYC";
|
||||||
|
public static final String KIND_AML = "AML";
|
||||||
|
|
||||||
protected ClientRiskRule() {}
|
protected ClientRiskRule() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
||||||
|
* are attached separately by the caller, which owns the CodeValue lookup.
|
||||||
|
*/
|
||||||
|
public static ClientRiskRule create(final String ruleName, final String reason, final Integer riskCategory,
|
||||||
|
final Integer orderPosition, final Integer legalFormId, final BigDecimal minMonthlyIncome,
|
||||||
|
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
|
||||||
|
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
|
||||||
|
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
|
||||||
|
final String dormantFlag, final String ruleKind) {
|
||||||
|
final ClientRiskRule rule = new ClientRiskRule();
|
||||||
|
rule.ruleKind = ruleKind;
|
||||||
|
rule.ruleName = ruleName;
|
||||||
|
rule.reason = reason;
|
||||||
|
rule.riskCategory = riskCategory;
|
||||||
|
rule.orderPosition = orderPosition;
|
||||||
|
rule.legalFormId = legalFormId;
|
||||||
|
rule.minMonthlyIncome = minMonthlyIncome;
|
||||||
|
rule.maxMonthlyIncome = maxMonthlyIncome;
|
||||||
|
rule.minAnnualIncome = minAnnualIncome;
|
||||||
|
rule.maxAnnualIncome = maxAnnualIncome;
|
||||||
|
rule.minAnnualTurnover = minAnnualTurnover;
|
||||||
|
rule.maxAnnualTurnover = maxAnnualTurnover;
|
||||||
|
rule.amlCheckStatus = amlCheckStatus;
|
||||||
|
rule.bannedEntityMatch = bannedEntityMatch;
|
||||||
|
rule.willfulDefaulter = willfulDefaulter;
|
||||||
|
rule.ecgcSalFlag = ecgcSalFlag;
|
||||||
|
rule.dormantFlag = dormantFlag;
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOccupationCategory(final CodeValue occupationCategory) {
|
||||||
|
this.occupationCategory = occupationCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOccupation(final CodeValue occupation) {
|
||||||
|
this.occupation = occupation;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getRuleKind() {
|
||||||
|
return ruleKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this rule may take part in onboarding risk categorisation. Rows predating the
|
||||||
|
* rule_kind column are treated as KYC, matching how they behaved before AML rules were seeded
|
||||||
|
* into the same table.
|
||||||
|
*/
|
||||||
|
public boolean isKycRule() {
|
||||||
|
return !KIND_AML.equalsIgnoreCase(this.ruleKind);
|
||||||
|
}
|
||||||
|
|
||||||
public String getRuleName() {
|
public String getRuleName() {
|
||||||
return ruleName;
|
return ruleName;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -26,4 +26,10 @@ public interface ClientRiskRuleRepository extends JpaRepository<ClientRiskRule,
|
|||||||
|
|
||||||
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
|
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
|
||||||
List<ClientRiskRule> findAllOrderedByOrderPosition();
|
List<ClientRiskRule> findAllOrderedByOrderPosition();
|
||||||
|
|
||||||
|
/** Highest position in use, so a new rule can be appended to the end of the catalog. */
|
||||||
|
@Query("SELECT MAX(r.orderPosition) FROM ClientRiskRule r")
|
||||||
|
Integer findMaxOrderPosition();
|
||||||
|
|
||||||
|
boolean existsByRuleNameIgnoreCase(String ruleName);
|
||||||
}
|
}
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The channel a KYC officer used (or asked the platform to use) to reach a customer about an open
|
||||||
|
* KYC exception.
|
||||||
|
*
|
||||||
|
* <p>The critical distinction is {@link #isPlatformSent()}:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Offline channels</b> ({@code PHONE_CALL}, {@code BRANCH_VISIT}, {@code FIELD_VISIT}) are
|
||||||
|
* recorded after the fact — the officer already did the thing, the platform only writes it
|
||||||
|
* down.</li>
|
||||||
|
* <li><b>Sending channels</b> ({@code SMS}, {@code EMAIL}, {@code WHATSAPP}) cause the platform to
|
||||||
|
* actually transmit a notice to the customer at save time.</li>
|
||||||
|
* </ul>
|
||||||
|
* Mixing these up would let a "logged" row imply the customer was told something they never
|
||||||
|
* received, so every call site branches on this flag rather than on the channel name.
|
||||||
|
*/
|
||||||
|
public enum KycContactChannel {
|
||||||
|
|
||||||
|
PHONE_CALL("Phone Call", false),
|
||||||
|
SMS("SMS", true),
|
||||||
|
EMAIL("Email", true),
|
||||||
|
WHATSAPP("WhatsApp Message", true),
|
||||||
|
BRANCH_VISIT("In-Branch Visit", false),
|
||||||
|
FIELD_VISIT("Agent Field Visit", false);
|
||||||
|
|
||||||
|
private final String displayLabel;
|
||||||
|
private final boolean platformSent;
|
||||||
|
|
||||||
|
KycContactChannel(final String displayLabel, final boolean platformSent) {
|
||||||
|
this.displayLabel = displayLabel;
|
||||||
|
this.platformSent = platformSent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDisplayLabel() {
|
||||||
|
return this.displayLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when saving a log of this channel must actually transmit a message to the customer. */
|
||||||
|
public boolean isPlatformSent() {
|
||||||
|
return this.platformSent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lenient parse of whatever the UI sent. Accepts the enum name ({@code SMS}) as well as the
|
||||||
|
* display label, and tolerates the descriptive suffixes the dialog uses — e.g.
|
||||||
|
* {@code "SMS (Aadhaar OTP request)"} and {@code "Email (PAN Link request)"} — because those
|
||||||
|
* labels describe the *purpose* of the message, not a different channel.
|
||||||
|
*
|
||||||
|
* @return the matching channel, or {@code null} when nothing matches.
|
||||||
|
*/
|
||||||
|
public static KycContactChannel fromString(final String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String normalised = raw.trim().toUpperCase();
|
||||||
|
for (final KycContactChannel channel : values()) {
|
||||||
|
if (normalised.equals(channel.name()) || normalised.equals(channel.displayLabel.toUpperCase())) {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prefix match handles the "(…request)" suffixes. Longest label first so WHATSAPP's
|
||||||
|
// "WHATSAPP MESSAGE" is preferred over a shorter accidental prefix.
|
||||||
|
KycContactChannel best = null;
|
||||||
|
for (final KycContactChannel channel : values()) {
|
||||||
|
final String label = channel.displayLabel.toUpperCase();
|
||||||
|
if (normalised.startsWith(label) && (best == null || label.length() > best.displayLabel.length())) {
|
||||||
|
best = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a platform-sent KYC contact notice.
|
||||||
|
*
|
||||||
|
* <p>Every state other than {@link #SENT} means the customer was <em>not</em> reached. The officer
|
||||||
|
* is shown the distinction explicitly, because a contact log that silently swallows a gateway
|
||||||
|
* failure would understate the bank's outreach obligation during a KYC audit.
|
||||||
|
*/
|
||||||
|
public enum KycContactDeliveryStatus {
|
||||||
|
|
||||||
|
/** Offline channel — nothing was transmitted and nothing was supposed to be. */
|
||||||
|
NOT_APPLICABLE,
|
||||||
|
|
||||||
|
/** The gateway accepted the message for delivery. */
|
||||||
|
SENT,
|
||||||
|
|
||||||
|
/** The gateway was reached but rejected the message, or the call errored. */
|
||||||
|
FAILED,
|
||||||
|
|
||||||
|
/** The customer has no mobile number / email address on file for this channel. */
|
||||||
|
SKIPPED_NO_DESTINATION,
|
||||||
|
|
||||||
|
/** No gateway is configured for this channel in this deployment. */
|
||||||
|
PROVIDER_NOT_CONFIGURED;
|
||||||
|
|
||||||
|
public boolean isDelivered() {
|
||||||
|
return this == SENT;
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far an account has travelled down the restriction ladder after its periodic re-KYC lapsed.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* RBI's Master Direction on KYC does not treat an overdue re-KYC as grounds to shut a customer out.
|
||||||
|
* The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not to be
|
||||||
|
* closed merely because periodic KYC is pending, and that any restriction follows notice and a fair
|
||||||
|
* opportunity to comply. The ladder therefore leaves the account untouched for a grace period,
|
||||||
|
* stops debits before it stops anything else, and never reaches closure on its own — closure stays
|
||||||
|
* a manual decision with its own notice.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The day counts are bank policy rather than a regulatory figure — the Direction requires "adequate
|
||||||
|
* notice" without fixing a number — so they are declared here once and read from here everywhere.
|
||||||
|
*/
|
||||||
|
public enum KycExpiryStage {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inside the grace window. Nothing is restricted; the customer is being chased.
|
||||||
|
*/
|
||||||
|
GRACE(0),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debits stopped, credits still accepted. The first actual restriction, and the one that brings
|
||||||
|
* the customer into the branch without putting their incoming money at risk.
|
||||||
|
*/
|
||||||
|
PARTIAL_FREEZE(90),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both directions restricted, barring the credits that must always be let through.
|
||||||
|
*/
|
||||||
|
INOPERATIVE(180);
|
||||||
|
|
||||||
|
/** Days past the expiry date at which this stage begins. */
|
||||||
|
private final int fromDaysOverdue;
|
||||||
|
|
||||||
|
KycExpiryStage(final int fromDaysOverdue) {
|
||||||
|
this.fromDaysOverdue = fromDaysOverdue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getFromDaysOverdue() {
|
||||||
|
return fromDaysOverdue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stage an account sits at, given how far past its re-KYC due date it is.
|
||||||
|
*
|
||||||
|
* @param daysOverdue
|
||||||
|
* days since the expiry date; zero or negative means the KYC has not lapsed
|
||||||
|
* @return the stage, or {@code null} where the KYC is still current and nothing applies
|
||||||
|
*/
|
||||||
|
public static KycExpiryStage fromDaysOverdue(final long daysOverdue) {
|
||||||
|
if (daysOverdue < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (daysOverdue >= INOPERATIVE.fromDaysOverdue) {
|
||||||
|
return INOPERATIVE;
|
||||||
|
}
|
||||||
|
if (daysOverdue >= PARTIAL_FREEZE.fromDaysOverdue) {
|
||||||
|
return PARTIAL_FREEZE;
|
||||||
|
}
|
||||||
|
return GRACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isGrace() {
|
||||||
|
return this == GRACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPartialFreeze() {
|
||||||
|
return this == PARTIAL_FREEZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isInoperative() {
|
||||||
|
return this == INOPERATIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether debits should be stopped at this stage. */
|
||||||
|
public boolean blocksDebits() {
|
||||||
|
return this == PARTIAL_FREEZE || this == INOPERATIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether ordinary credits should be stopped at this stage. */
|
||||||
|
public boolean blocksCredits() {
|
||||||
|
return this == INOPERATIVE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.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.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One number the risk re-rating policy is made of - a window length, a minimum transaction count, a cash threshold.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Held as data because these are the bank's risk appetite rather than engineering constants. How long a customer must
|
||||||
|
* behave before a High rating comes down is a compliance judgement, and a co-operative bank whose members transact
|
||||||
|
* three times a month needs different figures from one lending to traders.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_risk_policy_parameter")
|
||||||
|
public class RiskPolicyParameter {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "parameter_key", nullable = false, length = 80)
|
||||||
|
private String parameterKey;
|
||||||
|
|
||||||
|
@Column(name = "parameter_value", nullable = false, precision = 19, scale = 2)
|
||||||
|
private BigDecimal parameterValue;
|
||||||
|
|
||||||
|
@Column(name = "description", length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "last_modified_by")
|
||||||
|
private Long lastModifiedBy;
|
||||||
|
|
||||||
|
@Column(name = "last_modified_on")
|
||||||
|
private LocalDateTime lastModifiedOn;
|
||||||
|
|
||||||
|
protected RiskPolicyParameter() {}
|
||||||
|
|
||||||
|
public String getParameterKey() {
|
||||||
|
return this.parameterKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getParameterValue() {
|
||||||
|
return this.parameterValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return this.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface RiskPolicyParameterRepository extends JpaRepository<RiskPolicyParameter, Long> {}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.accounttype.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable data object representing a DDS Account Type (the legacy Account Type Creation record).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder(toBuilder = true)
|
||||||
|
public final class DdsAccountTypeData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final String accountTypeGroup;
|
||||||
|
private final String accountTypeCode;
|
||||||
|
private final String accountTypeDescription;
|
||||||
|
private final Boolean autoInterestPost;
|
||||||
|
private final String autoInterestPostWhen;
|
||||||
|
private final Integer autoInterestPostDay;
|
||||||
|
/** Whether an account on this scheme closes itself on its maturity date. */
|
||||||
|
private final Boolean maturityAutoClose;
|
||||||
|
/**
|
||||||
|
* What this scheme charges for closing an account before its term is up: how long it may not be
|
||||||
|
* closed at all, what comes off the customer's rate if they bear the cost, and what comes off the
|
||||||
|
* agent's commission either way. Read at the moment of closure, so a change on the scheme takes
|
||||||
|
* effect on accounts already open.
|
||||||
|
*/
|
||||||
|
private final Integer prematureLockinMonths;
|
||||||
|
private final BigDecimal prematureInterestReduction;
|
||||||
|
private final BigDecimal prematureAgentCommissionPenalty;
|
||||||
|
/** The scheme's annual interest rate, which an account opened on it takes. */
|
||||||
|
private final BigDecimal interestRate;
|
||||||
|
private final Boolean active;
|
||||||
|
private final LocalDateTime createdDate;
|
||||||
|
private final LocalDateTime lastModifiedDate;
|
||||||
|
}
|
||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.accounttype.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DDS scheme variant, as registered on the legacy "Account Type Creation" screen: a group (e.g.
|
||||||
|
* "DDS"), a short code (e.g. "DS"), a description (e.g. "DAILY DEPOSIT"), and whether interest is
|
||||||
|
* posted automatically and how often.
|
||||||
|
*
|
||||||
|
* This is master/reference data, not a deposit product - a Party picks one of these by code in its
|
||||||
|
* "Sub Type" field. Nothing here drives interest calculation: the DDS batch job accrues interest
|
||||||
|
* straight off each Party's own rate and balance.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_account_type", uniqueConstraints = {
|
||||||
|
@UniqueConstraint(columnNames = { "account_type_code" }, name = "uq_dds_account_type_code") })
|
||||||
|
public class DdsAccountType extends AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@Column(name = "account_type_group", length = 60, nullable = false)
|
||||||
|
private String accountTypeGroup;
|
||||||
|
|
||||||
|
@Column(name = "account_type_code", length = 20, nullable = false)
|
||||||
|
private String accountTypeCode;
|
||||||
|
|
||||||
|
@Column(name = "account_type_description", length = 200, nullable = false)
|
||||||
|
private String accountTypeDescription;
|
||||||
|
|
||||||
|
@Column(name = "auto_interest_post", nullable = false)
|
||||||
|
private boolean autoInterestPost;
|
||||||
|
|
||||||
|
@Column(name = "auto_interest_post_when", length = 20)
|
||||||
|
private String autoInterestPostWhen;
|
||||||
|
|
||||||
|
/** Day of the month interest posts on. Only meaningful when {@code autoInterestPost} is set. */
|
||||||
|
@Column(name = "auto_interest_post_day")
|
||||||
|
private Integer autoInterestPostDay;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an account on this scheme closes itself the day it matures, instead of waiting for a
|
||||||
|
* clerk to pay it out by hand.
|
||||||
|
*
|
||||||
|
* Kept on the scheme rather than switched on bank-wide, for the same reason interest posting is:
|
||||||
|
* a bank runs several schemes at once and will not want them all behaving the same way. It says
|
||||||
|
* only whether the closing should happen, never when - the batch run decides that, exactly as it
|
||||||
|
* does for {@code autoInterestPost}.
|
||||||
|
*
|
||||||
|
* A scheme with this set still only closes an account that has somewhere for the money to go. If
|
||||||
|
* the account names no savings account to be paid into, nothing happens automatically and the
|
||||||
|
* clerk pays it out as before: money is never sent somewhere nobody chose.
|
||||||
|
*/
|
||||||
|
@Column(name = "maturity_auto_close", nullable = false)
|
||||||
|
private boolean maturityAutoClose;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long an account on this scheme must run before it may be closed early at all. Below this
|
||||||
|
* the request is refused outright rather than charged for.
|
||||||
|
*
|
||||||
|
* Per-scheme rather than one bank-wide figure: a lock-in longer than the scheme's own term would
|
||||||
|
* make early closure unreachable, so a two-month scheme and a five-year one cannot share one.
|
||||||
|
*/
|
||||||
|
@Column(name = "premature_lockin_months", nullable = false)
|
||||||
|
private Integer prematureLockinMonths;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percentage points off the customer's interest rate when the customer bears the cost of closing
|
||||||
|
* early - a 2% scheme pays 1% over the whole period, not only the part left to run.
|
||||||
|
*/
|
||||||
|
@Column(name = "premature_interest_reduction", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal prematureInterestReduction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percentage off the collecting agent's commission on an early closure, applied whichever way the
|
||||||
|
* shortfall is met - an account closed early is a round the agent did not keep going.
|
||||||
|
*/
|
||||||
|
@Column(name = "premature_agent_commission_penalty", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal prematureAgentCommissionPenalty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scheme's annual interest rate. Mandatory: an account opened on the scheme takes this rate,
|
||||||
|
* rather than one typed at the counter for each customer.
|
||||||
|
*/
|
||||||
|
@Column(name = "interest_rate", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal interestRate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account type is retired by deactivating it, never by deleting: Parties are opened against
|
||||||
|
* it, so removing the row would orphan every account already using it. Inactive types stay out
|
||||||
|
* of the pick lists for new accounts but leave existing accounts untouched.
|
||||||
|
*/
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private boolean active;
|
||||||
|
|
||||||
|
protected DdsAccountType() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
private DdsAccountType(final JsonCommand command) {
|
||||||
|
this.accountTypeGroup = command.stringValueOfParameterNamed("accountTypeGroup");
|
||||||
|
this.accountTypeCode = normaliseCode(command.stringValueOfParameterNamed("accountTypeCode"));
|
||||||
|
this.accountTypeDescription = command.stringValueOfParameterNamed("accountTypeDescription");
|
||||||
|
this.autoInterestPost = command.booleanPrimitiveValueOfParameterNamed("autoInterestPost");
|
||||||
|
this.autoInterestPostWhen = StringUtils
|
||||||
|
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("autoInterestPostWhen"), null);
|
||||||
|
this.autoInterestPostDay = this.autoInterestPost
|
||||||
|
? command.integerValueOfParameterNamedDefaultToNullIfZero("autoInterestPostDay")
|
||||||
|
: null;
|
||||||
|
this.maturityAutoClose = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose");
|
||||||
|
this.prematureLockinMonths = valueOr(command, "prematureLockinMonths", DEFAULT_LOCKIN_MONTHS);
|
||||||
|
this.prematureInterestReduction = valueOr(command, "prematureInterestReduction", DEFAULT_INTEREST_REDUCTION);
|
||||||
|
this.prematureAgentCommissionPenalty = valueOr(command, "prematureAgentCommissionPenalty", DEFAULT_AGENT_PENALTY);
|
||||||
|
this.interestRate = command.bigDecimalValueOfParameterNamed("interestRate");
|
||||||
|
// A newly created account type is available for use straight away.
|
||||||
|
this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bank's stated terms, used when a scheme is created without saying otherwise. They are
|
||||||
|
* defaults rather than fixed rules - the screen offers them and the bank changes them per scheme.
|
||||||
|
*/
|
||||||
|
private static final int DEFAULT_LOCKIN_MONTHS = 12;
|
||||||
|
private static final BigDecimal DEFAULT_INTEREST_REDUCTION = BigDecimal.ONE;
|
||||||
|
private static final BigDecimal DEFAULT_AGENT_PENALTY = BigDecimal.ONE;
|
||||||
|
|
||||||
|
private static Integer valueOr(final JsonCommand command, final String parameter, final int fallback) {
|
||||||
|
final Integer supplied = command.parameterExists(parameter)
|
||||||
|
? command.integerValueOfParameterNamedDefaultToNullIfZero(parameter)
|
||||||
|
: null;
|
||||||
|
return supplied == null ? fallback : supplied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal valueOr(final JsonCommand command, final String parameter, final BigDecimal fallback) {
|
||||||
|
// Deliberately not "default to null if zero": zero is a real answer here - a scheme that
|
||||||
|
// charges the customer nothing for closing early - and must not be read as "unset".
|
||||||
|
final BigDecimal supplied = command.parameterExists(parameter)
|
||||||
|
? command.bigDecimalValueOfParameterNamed(parameter)
|
||||||
|
: null;
|
||||||
|
return supplied == null ? fallback : supplied;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codes are the account type's identity and are matched against elsewhere, so "ds" and "DS" must
|
||||||
|
* not become two different types - they are stored upper-cased and trimmed.
|
||||||
|
*/
|
||||||
|
private static String normaliseCode(final String code) {
|
||||||
|
return code == null ? null : code.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DdsAccountType fromJson(final JsonCommand command) {
|
||||||
|
return new DdsAccountType(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> update(final JsonCommand command) {
|
||||||
|
|
||||||
|
final Map<String, Object> actualChanges = new LinkedHashMap<>(6);
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("accountTypeGroup", this.accountTypeGroup)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("accountTypeGroup");
|
||||||
|
actualChanges.put("accountTypeGroup", newValue);
|
||||||
|
this.accountTypeGroup = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String incomingCode = normaliseCode(command.stringValueOfParameterNamedAllowingNull("accountTypeCode"));
|
||||||
|
if (incomingCode != null && !incomingCode.equals(this.accountTypeCode)) {
|
||||||
|
actualChanges.put("accountTypeCode", incomingCode);
|
||||||
|
this.accountTypeCode = incomingCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("accountTypeDescription", this.accountTypeDescription)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("accountTypeDescription");
|
||||||
|
actualChanges.put("accountTypeDescription", newValue);
|
||||||
|
this.accountTypeDescription = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("autoInterestPost", this.autoInterestPost)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("autoInterestPost");
|
||||||
|
actualChanges.put("autoInterestPost", newValue);
|
||||||
|
this.autoInterestPost = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed("autoInterestPostWhen", this.autoInterestPostWhen)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed("autoInterestPostWhen");
|
||||||
|
actualChanges.put("autoInterestPostWhen", newValue);
|
||||||
|
this.autoInterestPostWhen = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInIntegerParameterNamed("autoInterestPostDay", this.autoInterestPostDay)) {
|
||||||
|
final Integer newValue = command.integerValueOfParameterNamedDefaultToNullIfZero("autoInterestPostDay");
|
||||||
|
actualChanges.put("autoInterestPostDay", newValue);
|
||||||
|
this.autoInterestPostDay = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("maturityAutoClose", this.maturityAutoClose)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose");
|
||||||
|
actualChanges.put("maturityAutoClose", newValue);
|
||||||
|
this.maturityAutoClose = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A posting day only means anything while auto-posting is on.
|
||||||
|
if (!this.autoInterestPost) {
|
||||||
|
this.autoInterestPostDay = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInIntegerParameterNamed("prematureLockinMonths", this.prematureLockinMonths)) {
|
||||||
|
final Integer newValue = command.integerValueOfParameterNamedDefaultToNullIfZero("prematureLockinMonths");
|
||||||
|
actualChanges.put("prematureLockinMonths", newValue);
|
||||||
|
// Zero lock-in is meaningful - it lets an account be closed early from day one - but the
|
||||||
|
// column cannot hold null, so it is kept as zero rather than reset to the default.
|
||||||
|
this.prematureLockinMonths = newValue == null ? 0 : newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("prematureInterestReduction", this.prematureInterestReduction)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("prematureInterestReduction");
|
||||||
|
actualChanges.put("prematureInterestReduction", newValue);
|
||||||
|
this.prematureInterestReduction = newValue == null ? BigDecimal.ZERO : newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("prematureAgentCommissionPenalty", this.prematureAgentCommissionPenalty)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("prematureAgentCommissionPenalty");
|
||||||
|
actualChanges.put("prematureAgentCommissionPenalty", newValue);
|
||||||
|
this.prematureAgentCommissionPenalty = newValue == null ? BigDecimal.ZERO : newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scheme's rate - every open account on it follows it (see the account type service).
|
||||||
|
if (command.parameterExists("interestRate") && command.isChangeInBigDecimalParameterNamed("interestRate", this.interestRate)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("interestRate");
|
||||||
|
actualChanges.put("interestRate", newValue);
|
||||||
|
this.interestRate = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("active", this.active)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
|
||||||
|
actualChanges.put("active", newValue);
|
||||||
|
this.active = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actualChanges;
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.accounttype.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RuntimeException} thrown when a DDS account type resource is not found.
|
||||||
|
*/
|
||||||
|
public class DdsAccountTypeNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public DdsAccountTypeNotFoundException(final Long id) {
|
||||||
|
super("error.msg.dds.account.type.id.invalid", "DDS account type with identifier " + id + " does not exist", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DdsAccountTypeNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||||
|
super("error.msg.dds.account.type.id.invalid", "DDS account type with identifier " + id + " does not exist", id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.batch.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collection;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder(toBuilder = true)
|
||||||
|
public final class BatchRunData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final String jobType;
|
||||||
|
private final String branchScope;
|
||||||
|
|
||||||
|
/** Which DDS scheme the run covered, or null for every scheme. Set by the automatic posting job. */
|
||||||
|
private final String subType;
|
||||||
|
|
||||||
|
private final LocalDate jobDate;
|
||||||
|
private final Long agentFrom;
|
||||||
|
private final Long agentTo;
|
||||||
|
private final Long partyFrom;
|
||||||
|
private final Long partyTo;
|
||||||
|
private final String status;
|
||||||
|
private final Integer totalRows;
|
||||||
|
/** How many rows a crediting run has applied. Equal to totalRows once it has finished. */
|
||||||
|
private final Integer rowsPosted;
|
||||||
|
/** Why a run stopped, when it did - a crediting run happens in the background and has no other voice. */
|
||||||
|
private final String failureReason;
|
||||||
|
private final BigDecimal totalAmount;
|
||||||
|
private final LocalDate generatedOnDate;
|
||||||
|
private final LocalDate postedOnDate;
|
||||||
|
private final Collection<BatchRunRowData> rows;
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.batch.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public final class BatchRunRowData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long partyId;
|
||||||
|
private final String partyCode;
|
||||||
|
private final String partyName;
|
||||||
|
private final Long agentId;
|
||||||
|
private final String agentCode;
|
||||||
|
private final String agentName;
|
||||||
|
|
||||||
|
/** What the row applies: interest, or commission net of every deduction. */
|
||||||
|
private final BigDecimal amount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How that amount was arrived at. Null on rows where it does not apply - a posting row moves a
|
||||||
|
* figure an earlier calculation run worked out, so the rate and base belong to that run.
|
||||||
|
*/
|
||||||
|
private final BigDecimal baseAmount;
|
||||||
|
private final BigDecimal ratePercent;
|
||||||
|
private final BigDecimal grossAmount;
|
||||||
|
private final BigDecimal tdsAmount;
|
||||||
|
private final BigDecimal deductionAmount;
|
||||||
|
private final LocalDate fromDate;
|
||||||
|
private final LocalDate toDate;
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.batch.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OrderBy;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Calculate-then-Post batch run - the deliberate two-step control from the legacy Interest Part
|
||||||
|
* menu (Interest Calculation -> Interest Posting, OD Interest Calculation -> OD Interest Posting,
|
||||||
|
* Commission Calculation -> Commission Posting): a run is generated (rows computed and saved as a
|
||||||
|
* preview, nothing else changes), then posted (a separate, explicit step that applies exactly the
|
||||||
|
* rows that were previewed - never a fresh recalculation, so what gets applied is always what was
|
||||||
|
* seen).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_batch_run")
|
||||||
|
public class BatchRun extends AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@Column(name = "job_type", length = 40, nullable = false)
|
||||||
|
private String jobType;
|
||||||
|
|
||||||
|
@Column(name = "branch_scope", length = 20, nullable = false)
|
||||||
|
private String branchScope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which DDS scheme this run covered, or null for every scheme.
|
||||||
|
*
|
||||||
|
* A posting day belongs to an account type rather than to the bank, so a run started by the
|
||||||
|
* scheduled job covers one scheme at a time - otherwise the scheme due on the 15th would drag the
|
||||||
|
* month-end scheme's accounts along with it. A run started by hand from the screen leaves this
|
||||||
|
* null and covers everything, as it always did.
|
||||||
|
*/
|
||||||
|
@Column(name = "sub_type", length = 60)
|
||||||
|
private String subType;
|
||||||
|
|
||||||
|
@Column(name = "job_date", nullable = false)
|
||||||
|
private LocalDate jobDate;
|
||||||
|
|
||||||
|
@Column(name = "agent_from", nullable = false)
|
||||||
|
private Long agentFrom;
|
||||||
|
|
||||||
|
@Column(name = "agent_to", nullable = false)
|
||||||
|
private Long agentTo;
|
||||||
|
|
||||||
|
@Column(name = "party_from")
|
||||||
|
private Long partyFrom;
|
||||||
|
|
||||||
|
@Column(name = "party_to")
|
||||||
|
private Long partyTo;
|
||||||
|
|
||||||
|
@Column(name = "status", length = 20, nullable = false)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "total_rows", nullable = false)
|
||||||
|
private Integer totalRows;
|
||||||
|
|
||||||
|
/** How many of those rows have been credited so far - what a screen reads to show progress. */
|
||||||
|
@Column(name = "rows_posted", nullable = false)
|
||||||
|
private Integer rowsPosted = 0;
|
||||||
|
|
||||||
|
/** Why a run stopped, when it did. A background run has nowhere else to say so. */
|
||||||
|
@Column(name = "failure_reason", length = 500)
|
||||||
|
private String failureReason;
|
||||||
|
|
||||||
|
@Column(name = "total_amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal totalAmount;
|
||||||
|
|
||||||
|
@Column(name = "generated_on_date", nullable = false)
|
||||||
|
private LocalDate generatedOnDate;
|
||||||
|
|
||||||
|
@Column(name = "posted_on_date")
|
||||||
|
private LocalDate postedOnDate;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "batchRun", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||||
|
@OrderBy("id")
|
||||||
|
private List<BatchRunRow> rows = new ArrayList<>();
|
||||||
|
|
||||||
|
protected BatchRun() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public BatchRun(final String jobType, final String branchScope, final LocalDate jobDate, final Long agentFrom,
|
||||||
|
final Long agentTo, final Long partyFrom, final Long partyTo, final LocalDate generatedOnDate) {
|
||||||
|
this(jobType, branchScope, jobDate, agentFrom, agentTo, partyFrom, partyTo, generatedOnDate, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BatchRun(final String jobType, final String branchScope, final LocalDate jobDate, final Long agentFrom,
|
||||||
|
final Long agentTo, final Long partyFrom, final Long partyTo, final LocalDate generatedOnDate, final String subType) {
|
||||||
|
this.jobType = jobType;
|
||||||
|
this.branchScope = branchScope;
|
||||||
|
this.subType = subType;
|
||||||
|
this.jobDate = jobDate;
|
||||||
|
this.agentFrom = agentFrom;
|
||||||
|
this.agentTo = agentTo;
|
||||||
|
this.partyFrom = partyFrom;
|
||||||
|
this.partyTo = partyTo;
|
||||||
|
this.status = "CALCULATED";
|
||||||
|
this.totalRows = 0;
|
||||||
|
this.totalAmount = BigDecimal.ZERO;
|
||||||
|
this.generatedOnDate = generatedOnDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addRow(final BatchRunRow row) {
|
||||||
|
row.setBatchRun(this);
|
||||||
|
this.rows.add(row);
|
||||||
|
this.totalRows = this.rows.size();
|
||||||
|
this.totalAmount = this.totalAmount.add(row.getAmount());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPosted() {
|
||||||
|
return STATUS_POSTED.equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Under way. Set before the work starts, so a second press finds it already running. */
|
||||||
|
public boolean isPosting() {
|
||||||
|
return STATUS_POSTING.equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markPosting() {
|
||||||
|
this.status = STATUS_POSTING;
|
||||||
|
this.rowsPosted = 0;
|
||||||
|
this.failureReason = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordProgress(final int posted) {
|
||||||
|
this.rowsPosted = posted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markPosted(final LocalDate postedOnDate) {
|
||||||
|
this.status = STATUS_POSTED;
|
||||||
|
this.postedOnDate = postedOnDate;
|
||||||
|
this.rowsPosted = this.totalRows;
|
||||||
|
this.failureReason = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records why a run stopped, and leaves it able to be run again.
|
||||||
|
*
|
||||||
|
* Nothing it had credited stands: the whole run is one transaction, so a failure takes all of it
|
||||||
|
* back. That is the point - the branch is left with a run that plainly did not happen rather than
|
||||||
|
* one it has to reconcile by hand.
|
||||||
|
*/
|
||||||
|
public void markFailed(final String reason) {
|
||||||
|
this.status = STATUS_FAILED;
|
||||||
|
this.failureReason = reason == null ? "Unknown error" : reason.substring(0, Math.min(reason.length(), 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String STATUS_POSTED = "POSTED";
|
||||||
|
private static final String STATUS_POSTING = "POSTING";
|
||||||
|
private static final String STATUS_FAILED = "FAILED";
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.batch.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One previewed/posted line of a {@link BatchRun}: for an interest job, one Party's interest; for a
|
||||||
|
* commission job, one Agent's commission on their collected volume (partyId is null there).
|
||||||
|
*
|
||||||
|
* {@code amount} is always what actually gets applied. The remaining figures record how that amount
|
||||||
|
* was arrived at, which is what the Agent Commission Statement prints and what an agent querying a
|
||||||
|
* payout needs to see. They are stored rather than recomputed on demand because the inputs move: the
|
||||||
|
* slab chart can be corrected and the standing deductions change, so a later recomputation would not
|
||||||
|
* reproduce the figure that was actually paid.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_batch_run_row")
|
||||||
|
public class BatchRunRow extends AbstractPersistableCustom<Long> {
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "batch_run_id", nullable = false)
|
||||||
|
private BatchRun batchRun;
|
||||||
|
|
||||||
|
@Column(name = "party_id")
|
||||||
|
private Long partyId;
|
||||||
|
|
||||||
|
@Column(name = "agent_id", nullable = false)
|
||||||
|
private Long agentId;
|
||||||
|
|
||||||
|
/** What this row applies: interest accrued or posted, or commission net of every deduction. */
|
||||||
|
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the rate was applied to: the collection volume for commission, or the daily product
|
||||||
|
* (balance summed over the days it was held) for interest.
|
||||||
|
*/
|
||||||
|
@Column(name = "base_amount", scale = 6, precision = 19)
|
||||||
|
private BigDecimal baseAmount;
|
||||||
|
|
||||||
|
@Column(name = "rate_percent", scale = 6, precision = 19)
|
||||||
|
private BigDecimal ratePercent;
|
||||||
|
|
||||||
|
/** Before deductions. Equal to {@code amount} on rows that have none. */
|
||||||
|
@Column(name = "gross_amount", scale = 6, precision = 19)
|
||||||
|
private BigDecimal grossAmount;
|
||||||
|
|
||||||
|
@Column(name = "tds_amount", scale = 6, precision = 19)
|
||||||
|
private BigDecimal tdsAmount;
|
||||||
|
|
||||||
|
@Column(name = "deduction_amount", scale = 6, precision = 19)
|
||||||
|
private BigDecimal deductionAmount;
|
||||||
|
|
||||||
|
@Column(name = "from_date")
|
||||||
|
private LocalDate fromDate;
|
||||||
|
|
||||||
|
@Column(name = "to_date")
|
||||||
|
private LocalDate toDate;
|
||||||
|
|
||||||
|
protected BatchRunRow() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
private BatchRunRow(final Long partyId, final Long agentId, final BigDecimal amount, final BigDecimal baseAmount,
|
||||||
|
final BigDecimal ratePercent, final BigDecimal grossAmount, final BigDecimal tdsAmount, final BigDecimal deductionAmount,
|
||||||
|
final LocalDate fromDate, final LocalDate toDate) {
|
||||||
|
this.partyId = partyId;
|
||||||
|
this.agentId = agentId;
|
||||||
|
this.amount = amount;
|
||||||
|
this.baseAmount = baseAmount;
|
||||||
|
this.ratePercent = ratePercent;
|
||||||
|
this.grossAmount = grossAmount;
|
||||||
|
this.tdsAmount = tdsAmount;
|
||||||
|
this.deductionAmount = deductionAmount;
|
||||||
|
this.fromDate = fromDate;
|
||||||
|
this.toDate = toDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interest earned on a Party's balance over the days it was held. Nothing is deducted from it. */
|
||||||
|
public static BatchRunRow forInterest(final Long partyId, final Long agentId, final BigDecimal amount,
|
||||||
|
final BigDecimal dailyProduct, final BigDecimal ratePercent, final LocalDate fromDate, final LocalDate toDate) {
|
||||||
|
return new BatchRunRow(partyId, agentId, amount, dailyProduct, ratePercent, amount, null, null, fromDate, toDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An Agent's commission on their collection volume, net of TDS and the standing deductions. */
|
||||||
|
public static BatchRunRow forCommission(final Long agentId, final BigDecimal netAmount, final BigDecimal collectionVolume,
|
||||||
|
final BigDecimal ratePercent, final BigDecimal grossAmount, final BigDecimal tdsAmount, final BigDecimal deductionAmount,
|
||||||
|
final LocalDate fromDate, final LocalDate toDate) {
|
||||||
|
return new BatchRunRow(null, agentId, netAmount, collectionVolume, ratePercent, grossAmount, tdsAmount, deductionAmount,
|
||||||
|
fromDate, toDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A posting row: it moves an amount that a prior calculation run already worked out, so there is
|
||||||
|
* no rate or base to record against it - the run that calculated it holds those.
|
||||||
|
*/
|
||||||
|
public static BatchRunRow forPosting(final Long partyId, final Long agentId, final BigDecimal amount, final LocalDate asOnDate) {
|
||||||
|
return new BatchRunRow(partyId, agentId, amount, null, null, amount, null, null, asOnDate, asOnDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setBatchRun(final BatchRun batchRun) {
|
||||||
|
this.batchRun = batchRun;
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.batch.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when Post is attempted on a batch run that has already been posted - a run applies once,
|
||||||
|
* matching the calculate-then-post safety control (never re-post the same preview).
|
||||||
|
*/
|
||||||
|
public class BatchRunAlreadyPostedException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public BatchRunAlreadyPostedException(final Long batchRunId) {
|
||||||
|
super("error.msg.ddsbatchrun.already.posted", "DDS batch run " + batchRunId + " has already been posted", batchRunId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.batch.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
|
||||||
|
public class BatchRunNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public BatchRunNotFoundException(final Long id) {
|
||||||
|
super("error.msg.ddsbatchrun.id.invalid", "DDS batch run with identifier " + id + " does not exist", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.batch.exception;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when an agent's collection volume falls in a gap in the commission chart, so there is no rate
|
||||||
|
* to pay them at.
|
||||||
|
*
|
||||||
|
* The run stops rather than skipping the agent. A gap is a mistake in the chart, and an agent who
|
||||||
|
* worked all month reading as earning nothing looks exactly like an agent who collected nothing - the
|
||||||
|
* one failure nobody would query until the agent complained. Naming the agent and the amount that
|
||||||
|
* found no slab points straight at the range that needs adding.
|
||||||
|
*/
|
||||||
|
public class NoCommissionSlabException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public NoCommissionSlabException(final String agentCode, final BigDecimal collectionVolume) {
|
||||||
|
super("error.msg.ddsbatchrun.no.commission.slab",
|
||||||
|
"No commission slab covers " + collectionVolume.toPlainString() + ", the collection volume for agent " + agentCode
|
||||||
|
+ ". Add a slab covering this amount to the Commission Slab Master and run the calculation again.",
|
||||||
|
agentCode, collectionVolume);
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.calculation;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a collection agent has earned on a period's collections, and what is left after deductions.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* gross the slab's percentage of everything collected
|
||||||
|
* tds withheld from the gross, when the agent is liable for it
|
||||||
|
* deductions the four standing amounts the bank recovers from them
|
||||||
|
* net what the agent is actually paid
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* Every figure is kept, not just the net: an agent asking why their commission was short needs the
|
||||||
|
* whole breakdown, and a later change to the slab must not alter what was already paid.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Kept apart from the job that runs it so the sum can be checked on its own.
|
||||||
|
*/
|
||||||
|
public record DdsCommissionCalculation(BigDecimal collectionVolume, BigDecimal ratePercent, BigDecimal gross, BigDecimal tds,
|
||||||
|
BigDecimal deductions, BigDecimal net) {
|
||||||
|
|
||||||
|
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||||
|
private static final int MONEY_SCALE = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Works out a period's commission.
|
||||||
|
*
|
||||||
|
* @param collectionVolume
|
||||||
|
* everything the agent collected in the period.
|
||||||
|
* @param ratePercent
|
||||||
|
* the matching slab's percentage.
|
||||||
|
* @param tdsApplicable
|
||||||
|
* whether tax is withheld from this agent at all.
|
||||||
|
* @param tdsPercent
|
||||||
|
* the rate withheld when it is.
|
||||||
|
* @param deductions
|
||||||
|
* the standing amounts the bank recovers - see {@code Agent.totalDeductions()}.
|
||||||
|
*/
|
||||||
|
public static DdsCommissionCalculation of(final BigDecimal collectionVolume, final BigDecimal ratePercent,
|
||||||
|
final boolean tdsApplicable, final BigDecimal tdsPercent, final BigDecimal deductions) {
|
||||||
|
|
||||||
|
final BigDecimal volume = collectionVolume == null ? BigDecimal.ZERO : collectionVolume;
|
||||||
|
final BigDecimal rate = ratePercent == null ? BigDecimal.ZERO : ratePercent;
|
||||||
|
final BigDecimal recovered = deductions == null ? BigDecimal.ZERO : deductions;
|
||||||
|
|
||||||
|
final BigDecimal gross = volume.multiply(rate).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP);
|
||||||
|
final BigDecimal withheld = tdsApplicable && tdsPercent != null
|
||||||
|
? gross.multiply(tdsPercent).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP)
|
||||||
|
: BigDecimal.ZERO;
|
||||||
|
|
||||||
|
return new DdsCommissionCalculation(volume, rate, gross, withheld, recovered, gross.subtract(withheld).subtract(recovered));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether there is anything to pay.
|
||||||
|
*
|
||||||
|
* The deductions can swallow a period's commission entirely. Nothing is owed then, and the agent
|
||||||
|
* is not put into debt over it - the deductions are standing figures the bank adjusts, not a
|
||||||
|
* balance a commission run is entitled to carry forward.
|
||||||
|
*/
|
||||||
|
public boolean isPayable() {
|
||||||
|
return this.net.compareTo(BigDecimal.ZERO) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.calculation;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.MathContext;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a DDS account has earned over a period.
|
||||||
|
*
|
||||||
|
* Interest is on the <em>daily product</em>: the balance summed over the days it was actually held.
|
||||||
|
* A customer paying in daily holds each instalment for a different length of time, so interest on
|
||||||
|
* the closing balance for the whole period would pay them for money they had not yet deposited -
|
||||||
|
* roughly double what they are owed, which is what this replaced.
|
||||||
|
*
|
||||||
|
* This is the same basis {@code Party.calculateMaturityAmount()} quotes at opening, so what the
|
||||||
|
* interest runs add up to over a term matches the figure the customer was promised.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Kept apart from the job that runs it so the sum can be checked on its own. It reads nothing and
|
||||||
|
* writes nothing - given the same movements it always answers the same, which is what makes it
|
||||||
|
* worth testing.
|
||||||
|
*/
|
||||||
|
public final class DdsInterestCalculation {
|
||||||
|
|
||||||
|
/** Wide enough that the intermediate division never loses precision before the final rounding. */
|
||||||
|
private static final MathContext MATH_CONTEXT = new MathContext(19, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
|
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||||
|
private static final BigDecimal DAYS_IN_YEAR = BigDecimal.valueOf(365);
|
||||||
|
private static final int MONEY_SCALE = 2;
|
||||||
|
|
||||||
|
private DdsInterestCalculation() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One movement on the account: a collection is positive, a payout negative.
|
||||||
|
*
|
||||||
|
* A plain pair rather than the transaction itself, so this sum has no opinion about how
|
||||||
|
* transactions are stored and can be checked with a handful of literals.
|
||||||
|
*/
|
||||||
|
public record Movement(LocalDate date, BigDecimal signedAmount) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The balance summed over the days it was held, between {@code from} (exclusive) and {@code to}
|
||||||
|
* (inclusive).
|
||||||
|
*
|
||||||
|
* @param openingBalance
|
||||||
|
* what the account held before any of these movements.
|
||||||
|
* @param movements
|
||||||
|
* every movement up to the end of the period, oldest first. Anything dated on or
|
||||||
|
* before {@code from} has already happened and makes up the balance this period opens
|
||||||
|
* with rather than a movement within it.
|
||||||
|
*/
|
||||||
|
public static BigDecimal dailyProduct(final BigDecimal openingBalance, final List<Movement> movements, final LocalDate from,
|
||||||
|
final LocalDate to) {
|
||||||
|
|
||||||
|
BigDecimal balance = openingBalance == null ? BigDecimal.ZERO : openingBalance;
|
||||||
|
int index = 0;
|
||||||
|
while (index < movements.size() && !movements.get(index).date().isAfter(from)) {
|
||||||
|
balance = balance.add(movements.get(index).signedAmount());
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal product = BigDecimal.ZERO;
|
||||||
|
LocalDate cursor = from;
|
||||||
|
|
||||||
|
while (index < movements.size()) {
|
||||||
|
final LocalDate movementDate = movements.get(index).date();
|
||||||
|
if (movementDate.isAfter(to)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The untouched days between the last movement and this one, at the balance they stood at.
|
||||||
|
final long daysAtOpeningBalance = ChronoUnit.DAYS.between(cursor, movementDate) - 1;
|
||||||
|
if (daysAtOpeningBalance > 0) {
|
||||||
|
product = product.add(balance.multiply(BigDecimal.valueOf(daysAtOpeningBalance)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply every movement dated that day before counting the day itself, so a deposit earns
|
||||||
|
// from the day it was made.
|
||||||
|
while (index < movements.size() && movementDate.equals(movements.get(index).date())) {
|
||||||
|
balance = balance.add(movements.get(index).signedAmount());
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
product = product.add(balance);
|
||||||
|
cursor = movementDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
final long remainingDays = ChronoUnit.DAYS.between(cursor, to);
|
||||||
|
if (remainingDays > 0) {
|
||||||
|
product = product.add(balance.multiply(BigDecimal.valueOf(remainingDays)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple interest on a daily product at an annual rate, rounded to paise. */
|
||||||
|
public static BigDecimal interestOn(final BigDecimal dailyProduct, final BigDecimal ratePercent) {
|
||||||
|
if (dailyProduct == null || ratePercent == null || dailyProduct.compareTo(BigDecimal.ZERO) <= 0
|
||||||
|
|| ratePercent.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
return BigDecimal.ZERO.setScale(MONEY_SCALE);
|
||||||
|
}
|
||||||
|
return dailyProduct.multiply(ratePercent).divide(HUNDRED.multiply(DAYS_IN_YEAR), MATH_CONTEXT).setScale(MONEY_SCALE,
|
||||||
|
RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.calculation;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a daily deposit account stands against the instalments it owes.
|
||||||
|
*
|
||||||
|
* The bank's rule has two sides and they are not symmetrical. A customer may run ahead, but only a few
|
||||||
|
* days - the scheme is a habit of saving daily, not a deposit paid in one go. A customer may fall
|
||||||
|
* behind and catch up, but only within a short window; instalments older than that are skipped, and
|
||||||
|
* skipped means gone - the agent does not go back for them, and the account matures on what was
|
||||||
|
* actually paid.
|
||||||
|
*
|
||||||
|
* So a shortfall is split rather than shown as one figure. What can still be collected is worth a
|
||||||
|
* clerk chasing today; what has passed the window is not, and showing them together would have the
|
||||||
|
* clerk asking for money the bank has already written off.
|
||||||
|
*
|
||||||
|
* Kept here, outside either service, because the screen that shows these figures and the check that
|
||||||
|
* refuses a collection exceeding them have to agree. Two copies would drift, and the day they did the
|
||||||
|
* screen would quote a customer an amount the server then refused.
|
||||||
|
*
|
||||||
|
* @param expected what should have been collected by the day this was worked out
|
||||||
|
* @param advance signed: positive is paid ahead, negative is behind
|
||||||
|
* @param advanceDays whole instalments covered ahead - a part-paid day does not count
|
||||||
|
* @param arrearsDays whole or part instalments behind
|
||||||
|
* @param recoverable the part of a shortfall still inside the catch-up window
|
||||||
|
* @param skippedDays instalments past that window
|
||||||
|
* @param skipped what those come to
|
||||||
|
*/
|
||||||
|
public record DdsSchedule(BigDecimal expected, BigDecimal advance, int advanceDays, int arrearsDays, BigDecimal recoverable,
|
||||||
|
int skippedDays, BigDecimal skipped) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far behind an account may fall and still be caught up. Past this the instalments are
|
||||||
|
* skipped - the agent does not go back for them and the customer matures on less.
|
||||||
|
*/
|
||||||
|
public static final int CATCH_UP_DAYS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most a customer may pay ahead, so a scheme is not simply prepaid into a lump deposit. A few
|
||||||
|
* days is the bank's allowance for someone going away or paid weekly.
|
||||||
|
*/
|
||||||
|
public static final int MAX_ADVANCE_DAYS = 5;
|
||||||
|
|
||||||
|
private static final DdsSchedule NOTHING_DUE = new DdsSchedule(BigDecimal.ZERO, BigDecimal.ZERO, 0, 0, BigDecimal.ZERO, 0,
|
||||||
|
BigDecimal.ZERO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many instalments should have been paid by the given day.
|
||||||
|
*
|
||||||
|
* The first falls on the opening day itself - the same day the maturity figure starts counting
|
||||||
|
* from, so the two agree - and the count stops at the term's own length, so a matured account does
|
||||||
|
* not keep falling further behind.
|
||||||
|
*/
|
||||||
|
public static int instalmentsDue(final LocalDate openingDate, final Integer termDays, final LocalDate on) {
|
||||||
|
if (openingDate == null || termDays == null || termDays <= 0 || on == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final long elapsed = ChronoUnit.DAYS.between(openingDate, on) + 1;
|
||||||
|
return (int) Math.max(0, Math.min(termDays, elapsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DdsSchedule of(final BigDecimal daily, final int instalmentsDue, final BigDecimal collected) {
|
||||||
|
if (daily == null || daily.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
// Nothing is due daily, so there is no schedule to be ahead or behind of.
|
||||||
|
return NOTHING_DUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal paid = collected == null ? BigDecimal.ZERO : collected;
|
||||||
|
final BigDecimal expected = daily.multiply(BigDecimal.valueOf(instalmentsDue));
|
||||||
|
final BigDecimal advance = paid.subtract(expected);
|
||||||
|
|
||||||
|
if (advance.compareTo(BigDecimal.ZERO) >= 0) {
|
||||||
|
// Ahead. Only whole instalments count as days covered - a customer eighty rupees into a
|
||||||
|
// hundred-rupee day still owes today, and the agent should still call.
|
||||||
|
final int daysCovered = advance.divideToIntegralValue(daily).intValue();
|
||||||
|
return new DdsSchedule(expected, advance, daysCovered, 0, BigDecimal.ZERO, 0, BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behind. A part-paid day counts as a day behind for the same reason.
|
||||||
|
final BigDecimal shortfall = advance.negate();
|
||||||
|
final int daysBehind = shortfall.divide(daily, 0, RoundingMode.CEILING).intValue();
|
||||||
|
final BigDecimal window = daily.multiply(BigDecimal.valueOf(CATCH_UP_DAYS));
|
||||||
|
final BigDecimal recoverable = shortfall.min(window);
|
||||||
|
return new DdsSchedule(expected, advance, 0, daysBehind, recoverable, Math.max(0, daysBehind - CATCH_UP_DAYS),
|
||||||
|
shortfall.subtract(recoverable));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole instalments a customer may still pay off, which is what the arrears option may offer. */
|
||||||
|
public int recoverableDays(final BigDecimal daily) {
|
||||||
|
if (daily == null || daily.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return this.recoverable.divideToIntegralValue(daily).intValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.collection.data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An agent's day, as the clerk sees it when the agent walks up to the counter.
|
||||||
|
*
|
||||||
|
* The figure that matters is {@code expectedAmount}: what the agent's own entries add up to, and so
|
||||||
|
* what should be in their bag. It is given as a total beside the lines it came from, because a clerk
|
||||||
|
* who is short counts the lines to find out where.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class AgentCollectionSummaryData {
|
||||||
|
|
||||||
|
private final Long agentId;
|
||||||
|
private final String agentCode;
|
||||||
|
private final String agentName;
|
||||||
|
private final LocalDate entryDate;
|
||||||
|
|
||||||
|
private final int pendingCount;
|
||||||
|
|
||||||
|
/** What the agent's outstanding entries add up to - the cash the branch should be handed. */
|
||||||
|
private final BigDecimal expectedAmount;
|
||||||
|
|
||||||
|
/** What has already been counted and posted for this agent on this date. */
|
||||||
|
private final int settledCount;
|
||||||
|
private final BigDecimal settledAmount;
|
||||||
|
|
||||||
|
private final List<CollectionEntryData> entries;
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.collection.data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One written-down collection, as the agent's screen and the clerk's settlement list show it.
|
||||||
|
*
|
||||||
|
* Carries the party's code, name and balance beside the amount, because both readers are checking
|
||||||
|
* the line against a person rather than an id - the agent against the customer they just left, the
|
||||||
|
* clerk against the passbook in front of them.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class CollectionEntryData {
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
|
||||||
|
private final Long agentId;
|
||||||
|
private final String agentCode;
|
||||||
|
private final String agentName;
|
||||||
|
|
||||||
|
private final Long partyId;
|
||||||
|
private final String partyCode;
|
||||||
|
private final String partyName;
|
||||||
|
|
||||||
|
/** The account's balance as it stands now - before this entry, which has not been posted. */
|
||||||
|
private final BigDecimal partyBalance;
|
||||||
|
|
||||||
|
/** What this scheme takes each day, so an amount that is not a day's instalment stands out. */
|
||||||
|
private final BigDecimal dailyDepositAmount;
|
||||||
|
|
||||||
|
private final LocalDate entryDate;
|
||||||
|
private final BigDecimal amount;
|
||||||
|
private final String transactionMode;
|
||||||
|
private final String chequeNo;
|
||||||
|
private final String tokenNo;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
private final String status;
|
||||||
|
private final LocalDate settledOn;
|
||||||
|
private final Long partyTransactionId;
|
||||||
|
/** Days paid ahead, or days of arrears cleared - empty on an ordinary day's collection. */
|
||||||
|
private final Integer advanceDays;
|
||||||
|
private final Integer arrearsDays;
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.collection.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||||
|
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||||
|
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line an agent wrote down on their round: this customer, this much, in this form.
|
||||||
|
*
|
||||||
|
* It is not a transaction and deliberately reads nothing like one. No balance is carried on it, no
|
||||||
|
* ledger entry stands behind it, and no report of the bank's position counts it. It says only what
|
||||||
|
* the agent says they collected - the bank's own record of it begins when a clerk has counted the
|
||||||
|
* cash and confirmed the day's entries, and what that writes is an ordinary
|
||||||
|
* {@code m_dds_party_transaction} row.
|
||||||
|
*
|
||||||
|
* The distinction is the whole point of the table. Credited on the spot, the books showed customers
|
||||||
|
* paid in notes the branch had never held, and there was nothing to count the agent's bag against
|
||||||
|
* when they arrived. Held here, the agent's word and the bank's books stay separate until somebody
|
||||||
|
* puts them side by side.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_collection_entry")
|
||||||
|
public class CollectionEntry extends AbstractAuditableCustom {
|
||||||
|
|
||||||
|
/** Waiting for a clerk to count the cash behind it. */
|
||||||
|
public static final String PENDING = "PENDING";
|
||||||
|
|
||||||
|
/** Counted, posted, and now standing behind a real transaction. */
|
||||||
|
public static final String SETTLED = "SETTLED";
|
||||||
|
|
||||||
|
/** Withdrawn before it was ever posted - a wrong line the agent took back. */
|
||||||
|
public static final String CANCELLED = "CANCELLED";
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "agent_id", nullable = false)
|
||||||
|
private Agent agent;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "party_id", nullable = false)
|
||||||
|
private Party party;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day the money changed hands, which is the agent's day and not the branch's. A round
|
||||||
|
* collected on Friday and brought in on Monday is still Friday's collection, and the customer's
|
||||||
|
* statement has to say so.
|
||||||
|
*/
|
||||||
|
@Column(name = "entry_date", nullable = false)
|
||||||
|
private LocalDate entryDate;
|
||||||
|
|
||||||
|
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column(name = "transaction_mode", length = 20, nullable = false)
|
||||||
|
private String transactionMode;
|
||||||
|
|
||||||
|
@Column(name = "cheque_no", length = 50)
|
||||||
|
private String chequeNo;
|
||||||
|
|
||||||
|
@Column(name = "token_no", length = 50)
|
||||||
|
private String tokenNo;
|
||||||
|
|
||||||
|
@Column(name = "description", length = 200)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "status", length = 20, nullable = false)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "settled_on")
|
||||||
|
private LocalDate settledOn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transaction this entry became, once it was posted.
|
||||||
|
*
|
||||||
|
* Held as a plain id rather than a mapped association: this table is the paper the bank checked
|
||||||
|
* against, not part of the ledger, and a link that pulled a transaction in whenever an entry was
|
||||||
|
* read would make the two look like halves of one thing. Anyone who needs the transaction can
|
||||||
|
* fetch it; most callers only need to know there is one.
|
||||||
|
*/
|
||||||
|
@Column(name = "party_transaction_id")
|
||||||
|
private Long partyTransactionId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application of the cashier's DDS receipts that settled this entry, when it was settled that
|
||||||
|
* way - see 0517, which records which vouchers it used. A plain id for the same reason as the
|
||||||
|
* transaction.
|
||||||
|
*/
|
||||||
|
@Column(name = "receipt_application_id")
|
||||||
|
private Long receiptApplicationId;
|
||||||
|
|
||||||
|
/** Days paid ahead, or days of arrears cleared - see 0520. Both empty on an ordinary day's collection. */
|
||||||
|
@Column(name = "advance_days")
|
||||||
|
private Integer advanceDays;
|
||||||
|
|
||||||
|
@Column(name = "arrears_days")
|
||||||
|
private Integer arrearsDays;
|
||||||
|
|
||||||
|
protected CollectionEntry() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public CollectionEntry(final Agent agent, final Party party, final LocalDate entryDate, final BigDecimal amount,
|
||||||
|
final String transactionMode, final String chequeNo, final String tokenNo, final String description) {
|
||||||
|
this.agent = agent;
|
||||||
|
this.party = party;
|
||||||
|
this.entryDate = entryDate;
|
||||||
|
this.amount = amount;
|
||||||
|
this.transactionMode = transactionMode;
|
||||||
|
this.chequeNo = chequeNo;
|
||||||
|
this.tokenNo = tokenNo;
|
||||||
|
this.description = description;
|
||||||
|
this.status = PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records what the collection covers beyond today, once its amount has been checked against it. */
|
||||||
|
public void covering(final Integer advanceDays, final Integer arrearsDays) {
|
||||||
|
this.advanceDays = advanceDays;
|
||||||
|
this.arrearsDays = arrearsDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPending() {
|
||||||
|
return PENDING.equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that this entry has been counted and posted, and what it became.
|
||||||
|
*
|
||||||
|
* The transaction's id is kept so the two can always be put back together - a clerk asked six
|
||||||
|
* months later which entry a credit came from has an answer, rather than a matching exercise on
|
||||||
|
* date and amount.
|
||||||
|
*/
|
||||||
|
public void markSettled(final LocalDate settledOn, final Long partyTransactionId) {
|
||||||
|
this.status = SETTLED;
|
||||||
|
this.settledOn = settledOn;
|
||||||
|
this.partyTransactionId = partyTransactionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settled against the cashier's receipts: the same as {@link #markSettled}, and which application did it. */
|
||||||
|
public void markSettledByReceipt(final LocalDate settledOn, final Long partyTransactionId, final Long receiptApplicationId) {
|
||||||
|
markSettled(settledOn, partyTransactionId);
|
||||||
|
this.receiptApplicationId = receiptApplicationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Withdraws a line that was never posted. Kept, not deleted - the agent wrote it, so it stands. */
|
||||||
|
public void markCancelled() {
|
||||||
|
this.status = CANCELLED;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.collection.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
|
||||||
|
public class CollectionEntryNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public CollectionEntryNotFoundException(final Long entryId) {
|
||||||
|
super("error.msg.dds.collection.entry.not.found", "Collection entry " + entryId + " does not exist", entryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.collection.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a written-down collection is taken back after it has already been settled.
|
||||||
|
*
|
||||||
|
* By then it is not a note any more - a customer has been credited and an agent's cash recorded
|
||||||
|
* against it. Withdrawing it here would leave the note cancelled and the credit standing, which is
|
||||||
|
* the one state nobody could reconcile. A posted collection is undone the way any posting is: with
|
||||||
|
* an entry the other way, on the ledger, where it can be seen.
|
||||||
|
*/
|
||||||
|
public class CollectionEntryNotPendingException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public CollectionEntryNotPendingException(final Long entryId, final String status) {
|
||||||
|
super("error.msg.dds.collection.entry.not.pending",
|
||||||
|
"Collection entry " + entryId + " is " + status.toLowerCase() + " and can no longer be cancelled", entryId, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.collection.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a settlement is asked for and the agent has nothing outstanding on that date.
|
||||||
|
*
|
||||||
|
* Said plainly rather than passed off as a settlement of nothing, because the clerk is standing at
|
||||||
|
* the counter with an agent in front of them: either they have the wrong date, or the round has been
|
||||||
|
* settled already, and both are worth knowing before the cash is taken.
|
||||||
|
*/
|
||||||
|
public class NothingToSettleException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public NothingToSettleException(final String agentCode, final String entryDate) {
|
||||||
|
super("error.msg.dds.collection.nothing.to.settle",
|
||||||
|
"Agent " + agentCode + " has no collection entries outstanding for " + entryDate, agentCode, entryDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.party.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collection;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable data object representing a DDS Party (customer scheme account), including its nominees.
|
||||||
|
* Built via the Lombok builder rather than a long positional constructor - the field count here makes
|
||||||
|
* positional args unreadable and error-prone to keep in sync.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder(toBuilder = true)
|
||||||
|
public final class PartyData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long clientId;
|
||||||
|
private final String clientName;
|
||||||
|
private final Long agentId;
|
||||||
|
private final String agentCode;
|
||||||
|
private final String agentName;
|
||||||
|
/** The branch this account belongs to - its own, not the collecting agent's. */
|
||||||
|
private final Long officeId;
|
||||||
|
private final String officeName;
|
||||||
|
/**
|
||||||
|
* The customer's real deposit account, opened when the party was created.
|
||||||
|
*
|
||||||
|
* The id alone was carried here before, which is a number only the database means anything by -
|
||||||
|
* a screen showing "36" tells an officer nothing. The account's own number, product, status and
|
||||||
|
* balance are read from the account itself, so what a party shows is what the bank's books say
|
||||||
|
* rather than a second copy that can drift. All four are null for a party opened before DDS
|
||||||
|
* began opening real accounts, and that is worth showing plainly rather than hiding.
|
||||||
|
*/
|
||||||
|
private final Long savingsAccountId;
|
||||||
|
private final String savingsAccountNo;
|
||||||
|
private final String savingsProductName;
|
||||||
|
private final String savingsAccountStatus;
|
||||||
|
private final BigDecimal savingsAccountBalance;
|
||||||
|
/**
|
||||||
|
* What on this account is pledged against a loan and cannot leave.
|
||||||
|
*
|
||||||
|
* Shown beside the balance on the closure list because a deposit standing as security is owed but
|
||||||
|
* not payable, and a clerk who reads only the balance would try to pay it and be refused.
|
||||||
|
*/
|
||||||
|
private final BigDecimal amountHeldUnderLien;
|
||||||
|
private final String subType;
|
||||||
|
/**
|
||||||
|
* The account type's description, resolved from the DDS Account Type master that {@code subType}
|
||||||
|
* holds the code of - so callers can show the scheme by the name officers know it by without
|
||||||
|
* fetching the master separately.
|
||||||
|
*/
|
||||||
|
private final String subTypeDescription;
|
||||||
|
private final String partyCode;
|
||||||
|
/** The DDS account's own number. */
|
||||||
|
private final String accountNo;
|
||||||
|
private final String lastAccount;
|
||||||
|
private final Boolean nominal;
|
||||||
|
private final String aadharNo;
|
||||||
|
private final String partyName;
|
||||||
|
private final String partyName1;
|
||||||
|
private final String address1;
|
||||||
|
private final String address2;
|
||||||
|
private final String address3;
|
||||||
|
private final String city;
|
||||||
|
private final String pincode;
|
||||||
|
private final String gender;
|
||||||
|
private final String panCardNo;
|
||||||
|
private final String telephone;
|
||||||
|
private final String mobileNoSms;
|
||||||
|
private final String operation;
|
||||||
|
private final LocalDate birthDate;
|
||||||
|
private final String occupation;
|
||||||
|
private final String risk;
|
||||||
|
private final String ckycId;
|
||||||
|
private final String adultMinor;
|
||||||
|
private final LocalDate minorDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day a minor account holder comes of age, and whether it has passed.
|
||||||
|
*
|
||||||
|
* Worked out from the birth date rather than taken from {@code minorDate}, which is typed in by
|
||||||
|
* hand and has never been filled on any record. Null on an adult's account.
|
||||||
|
*
|
||||||
|
* The guardian's authority to operate the account ends on that day, so the branch has to take
|
||||||
|
* fresh KYC, a signature and a new mandate from the customer themselves. Nothing acts on this
|
||||||
|
* yet - whether the account should also be restricted until that is done is the bank's rule, and
|
||||||
|
* blocking one wrongly would strand a customer's money. It is carried here so the day stops
|
||||||
|
* passing unnoticed, which is what happened before.
|
||||||
|
*/
|
||||||
|
private final LocalDate majorOn;
|
||||||
|
private final boolean minorTurnedMajor;
|
||||||
|
private final Long guardianClientId;
|
||||||
|
private final String minAccountOpt;
|
||||||
|
private final String relation;
|
||||||
|
private final LocalDate openingDate;
|
||||||
|
private final Integer month;
|
||||||
|
private final LocalDate maturityDate;
|
||||||
|
private final LocalDate reopenDate;
|
||||||
|
private final BigDecimal depositAmount;
|
||||||
|
private final BigDecimal dailyDepositAmount;
|
||||||
|
private final Integer days;
|
||||||
|
private final String ledgerNo;
|
||||||
|
private final BigDecimal maturityAmount;
|
||||||
|
private final BigDecimal interestRate;
|
||||||
|
private final String withdrawType;
|
||||||
|
private final Boolean od1Flag;
|
||||||
|
private final Boolean od2Flag;
|
||||||
|
private final Boolean lien;
|
||||||
|
/** Why the money is held - null when it is not. */
|
||||||
|
private final String lienReason;
|
||||||
|
private final LocalDate payDate;
|
||||||
|
private final BigDecimal paymentAmount;
|
||||||
|
private final BigDecimal currentBalance;
|
||||||
|
private final BigDecimal openingBalance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this account stands against the instalments it should have paid by today.
|
||||||
|
*
|
||||||
|
* A daily scheme is a promise to pay a fixed sum every day, and the only figure the screen used to
|
||||||
|
* show was the running balance - which says how much has been paid but not whether it was paid on
|
||||||
|
* time. A customer eleven instalments behind and one who is four ahead can hold the same balance.
|
||||||
|
*
|
||||||
|
* {@code advanceAmount} is signed: positive is paid ahead, negative is behind. The rest of these
|
||||||
|
* read it in the bank's own terms - how many days that comes to, how much of a shortfall can still
|
||||||
|
* be recovered within the catch-up window, and how much has fallen outside it and is gone.
|
||||||
|
*/
|
||||||
|
private final Integer instalmentsDue;
|
||||||
|
private final BigDecimal expectedToDate;
|
||||||
|
private final BigDecimal collectedToDate;
|
||||||
|
private final BigDecimal advanceAmount;
|
||||||
|
private final Integer advanceDays;
|
||||||
|
private final Integer arrearsDays;
|
||||||
|
private final BigDecimal recoverableAmount;
|
||||||
|
/** Instalments now past the catch-up window. These are not collected again - they are lost. */
|
||||||
|
private final Integer skippedDays;
|
||||||
|
private final BigDecimal skippedAmount;
|
||||||
|
private final Boolean isSmsActive;
|
||||||
|
private final Boolean isEmailActive;
|
||||||
|
/** Interest worked out but not yet credited, and how far it is calculated through. */
|
||||||
|
private final BigDecimal interestCalc;
|
||||||
|
private final LocalDate interestDate;
|
||||||
|
|
||||||
|
/** The same pair for the OD interest stream, which runs and posts independently. */
|
||||||
|
private final BigDecimal odInterestCalc;
|
||||||
|
private final LocalDate odInterestDate;
|
||||||
|
private final String spDescription;
|
||||||
|
private final String freezeCode;
|
||||||
|
private final String advCode;
|
||||||
|
private final String creditToBranch;
|
||||||
|
private final String creditToAccountType;
|
||||||
|
private final String creditToAccount;
|
||||||
|
private final Boolean nomineeApplicable;
|
||||||
|
private final String nomineeSplitMode;
|
||||||
|
private final String status;
|
||||||
|
private final LocalDate closeDate;
|
||||||
|
private final Collection<PartyNomineeData> nominees;
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.party.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public final class PartyNomineeData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final LocalDate nominationDate;
|
||||||
|
private final String name;
|
||||||
|
private final String mobileNo;
|
||||||
|
private final LocalDate birthDate;
|
||||||
|
private final Boolean minor;
|
||||||
|
private final String relation;
|
||||||
|
private final String gender;
|
||||||
|
private final String guardianName;
|
||||||
|
/** Null under a Sequence split, where the order the nominees are paid in is the entitlement. */
|
||||||
|
private final BigDecimal sharePercent;
|
||||||
|
/** Null under a Percentage split, where everyone is paid at once and there is no first. */
|
||||||
|
private final Integer sequenceNo;
|
||||||
|
|
||||||
|
public static PartyNomineeData instance(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
|
||||||
|
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName,
|
||||||
|
final BigDecimal sharePercent, final Integer sequenceNo) {
|
||||||
|
return new PartyNomineeData(id, nominationDate, name, mobileNo, birthDate, minor, relation, gender, guardianName, sharePercent,
|
||||||
|
sequenceNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PartyNomineeData(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
|
||||||
|
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName,
|
||||||
|
final BigDecimal sharePercent, final Integer sequenceNo) {
|
||||||
|
this.id = id;
|
||||||
|
this.nominationDate = nominationDate;
|
||||||
|
this.name = name;
|
||||||
|
this.mobileNo = mobileNo;
|
||||||
|
this.birthDate = birthDate;
|
||||||
|
this.minor = minor;
|
||||||
|
this.relation = relation;
|
||||||
|
this.gender = gender;
|
||||||
|
this.guardianName = guardianName;
|
||||||
|
this.sharePercent = sharePercent;
|
||||||
|
this.sequenceNo = sequenceNo;
|
||||||
|
}
|
||||||
|
}
|
||||||
+791
@@ -0,0 +1,791 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.party.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OrderBy;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.MathContext;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||||
|
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||||
|
import org.apache.fineract.organisation.office.domain.Office;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DDS Party - the customer's daily-deposit scheme account, linked to the Agent who collects for it.
|
||||||
|
* Carries the scheme's KYC, terms and two-stage overdue flags, plus a nominee list.
|
||||||
|
*
|
||||||
|
* The underlying RD (recurring deposit) account this scheme conceptually reuses is not wired up yet -
|
||||||
|
* {@code savingsAccountId} is a nullable placeholder for that future linkage (see the DDS architecture
|
||||||
|
* decision to reuse the RD engine for the deposit half). Until then the scheme/balance fields below
|
||||||
|
* are this record's own, not mirrored from any m_savings_account row.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
|
||||||
|
public class Party extends AbstractAuditableCustom {
|
||||||
|
|
||||||
|
/** DDS accounts carry a flat interest rate unless the officer explicitly overrides it at creation. */
|
||||||
|
private static final BigDecimal DEFAULT_INTEREST_RATE = new BigDecimal("2");
|
||||||
|
|
||||||
|
/** Interest is quoted per annum, so the daily instalments are pro-rated over a year. */
|
||||||
|
private static final int DAYS_IN_YEAR = 365;
|
||||||
|
|
||||||
|
private static final MathContext MATH_CONTEXT = new MathContext(19, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
|
public static final String STATUS_ACTIVE = "ACTIVE";
|
||||||
|
public static final String STATUS_CLOSED = "CLOSED";
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
|
private Client client;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "agent_id", nullable = false)
|
||||||
|
private Agent agent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branch this account belongs to.
|
||||||
|
*
|
||||||
|
* Its own, not the collecting agent's. An agent working out of one branch can hold accounts for
|
||||||
|
* customers of another, and reassigning an account to a different agent should not move the
|
||||||
|
* customer's branch with it. Defaults to the agent's branch when the caller does not name one,
|
||||||
|
* which is what the account was read as before it had a branch of its own.
|
||||||
|
*/
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "office_id")
|
||||||
|
private Office office;
|
||||||
|
|
||||||
|
@Column(name = "savings_account_id")
|
||||||
|
private Long savingsAccountId;
|
||||||
|
|
||||||
|
@Column(name = "sub_type", length = 60, nullable = false)
|
||||||
|
private String subType;
|
||||||
|
|
||||||
|
@Column(name = "party_code", length = 20, nullable = false)
|
||||||
|
private String partyCode;
|
||||||
|
|
||||||
|
/** The DDS account's own number - see 0514. Given once the party has an id, and never changed. */
|
||||||
|
@Column(name = "account_no", length = 20)
|
||||||
|
private String accountNo;
|
||||||
|
|
||||||
|
@Column(name = "last_account", length = 50)
|
||||||
|
private String lastAccount;
|
||||||
|
|
||||||
|
@Column(name = "nominal", nullable = false)
|
||||||
|
private boolean nominal;
|
||||||
|
|
||||||
|
@Column(name = "aadhar_no", length = 20)
|
||||||
|
private String aadharNo;
|
||||||
|
|
||||||
|
@Column(name = "party_name", length = 100, nullable = false)
|
||||||
|
private String partyName;
|
||||||
|
|
||||||
|
@Column(name = "party_name1", length = 100)
|
||||||
|
private String partyName1;
|
||||||
|
|
||||||
|
@Column(name = "address1", length = 200, nullable = false)
|
||||||
|
private String address1;
|
||||||
|
|
||||||
|
@Column(name = "address2", length = 200)
|
||||||
|
private String address2;
|
||||||
|
|
||||||
|
@Column(name = "address3", length = 200)
|
||||||
|
private String address3;
|
||||||
|
|
||||||
|
@Column(name = "city", length = 100, nullable = false)
|
||||||
|
private String city;
|
||||||
|
|
||||||
|
@Column(name = "pincode", length = 20, nullable = false)
|
||||||
|
private String pincode;
|
||||||
|
|
||||||
|
@Column(name = "gender", length = 20)
|
||||||
|
private String gender;
|
||||||
|
|
||||||
|
@Column(name = "pan_card_no", length = 20)
|
||||||
|
private String panCardNo;
|
||||||
|
|
||||||
|
@Column(name = "telephone", length = 20)
|
||||||
|
private String telephone;
|
||||||
|
|
||||||
|
@Column(name = "mobile_no_sms", length = 20, nullable = false)
|
||||||
|
private String mobileNoSms;
|
||||||
|
|
||||||
|
@Column(name = "operation", length = 20, nullable = false)
|
||||||
|
private String operation;
|
||||||
|
|
||||||
|
@Column(name = "birth_date")
|
||||||
|
private LocalDate birthDate;
|
||||||
|
|
||||||
|
@Column(name = "occupation", length = 40)
|
||||||
|
private String occupation;
|
||||||
|
|
||||||
|
@Column(name = "risk", length = 20)
|
||||||
|
private String risk;
|
||||||
|
|
||||||
|
@Column(name = "ckyc_id", length = 40)
|
||||||
|
private String ckycId;
|
||||||
|
|
||||||
|
@Column(name = "adult_minor", length = 20, nullable = false)
|
||||||
|
private String adultMinor;
|
||||||
|
|
||||||
|
@Column(name = "minor_date")
|
||||||
|
private LocalDate minorDate;
|
||||||
|
|
||||||
|
@Column(name = "guardian_client_id")
|
||||||
|
private Long guardianClientId;
|
||||||
|
|
||||||
|
@Column(name = "min_account_opt", length = 40)
|
||||||
|
private String minAccountOpt;
|
||||||
|
|
||||||
|
@Column(name = "relation", length = 40)
|
||||||
|
private String relation;
|
||||||
|
|
||||||
|
@Column(name = "opening_date", nullable = false)
|
||||||
|
private LocalDate openingDate;
|
||||||
|
|
||||||
|
@Column(name = "months")
|
||||||
|
private Integer month;
|
||||||
|
|
||||||
|
@Column(name = "maturity_date")
|
||||||
|
private LocalDate maturityDate;
|
||||||
|
|
||||||
|
@Column(name = "reopen_date")
|
||||||
|
private LocalDate reopenDate;
|
||||||
|
|
||||||
|
@Column(name = "deposit_amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal depositAmount;
|
||||||
|
|
||||||
|
@Column(name = "daily_deposit_amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal dailyDepositAmount;
|
||||||
|
|
||||||
|
@Column(name = "days", nullable = false)
|
||||||
|
private Integer days;
|
||||||
|
|
||||||
|
@Column(name = "ledger_no", length = 40)
|
||||||
|
private String ledgerNo;
|
||||||
|
|
||||||
|
@Column(name = "maturity_amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal maturityAmount;
|
||||||
|
|
||||||
|
@Column(name = "interest_rate", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal interestRate;
|
||||||
|
|
||||||
|
@Column(name = "withdraw_type", length = 20)
|
||||||
|
private String withdrawType;
|
||||||
|
|
||||||
|
@Column(name = "od1_flag", nullable = false)
|
||||||
|
private boolean od1Flag;
|
||||||
|
|
||||||
|
@Column(name = "od2_flag", nullable = false)
|
||||||
|
private boolean od2Flag;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this account's money is being held, and so cannot leave.
|
||||||
|
*
|
||||||
|
* Deposits are unaffected - the agent collects as before and interest posts as before. What is
|
||||||
|
* refused is every way out: the maturity payout, an early closure, and a transfer to another
|
||||||
|
* party. A lien that stopped the collections would defeat the scheme it is securing.
|
||||||
|
*/
|
||||||
|
@Column(name = "lien", nullable = false)
|
||||||
|
private boolean lien;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the money is held.
|
||||||
|
*
|
||||||
|
* The flag alone says an account is frozen and never says what for, which is how a held deposit
|
||||||
|
* outlives the loan it secured: months later nobody can tell whether it has been repaid, so
|
||||||
|
* nobody will release it and nobody can justify keeping it.
|
||||||
|
*
|
||||||
|
* It falls to a line of text because DDS and the loan module are not connected - nothing else in
|
||||||
|
* the system links a held deposit to the loan behind it.
|
||||||
|
*/
|
||||||
|
@Column(name = "lien_reason", length = 200)
|
||||||
|
private String lienReason;
|
||||||
|
|
||||||
|
@Column(name = "pay_date")
|
||||||
|
private LocalDate payDate;
|
||||||
|
|
||||||
|
@Column(name = "payment_amount", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal paymentAmount;
|
||||||
|
|
||||||
|
@Column(name = "current_balance", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal currentBalance;
|
||||||
|
|
||||||
|
@Column(name = "opening_balance", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal openingBalance;
|
||||||
|
|
||||||
|
@Column(name = "is_sms_active", nullable = false)
|
||||||
|
private boolean isSmsActive;
|
||||||
|
|
||||||
|
@Column(name = "is_email_active", nullable = false)
|
||||||
|
private boolean isEmailActive;
|
||||||
|
|
||||||
|
/** Interest worked out but not yet credited - the legacy screen's "Int.Calc". */
|
||||||
|
@Column(name = "interest_calc", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal interestCalc;
|
||||||
|
|
||||||
|
/** How far regular interest has been calculated through. */
|
||||||
|
@Column(name = "interest_date")
|
||||||
|
private LocalDate interestDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OD interest is a second stream over the same account, run and posted independently of regular
|
||||||
|
* interest, so it keeps its own accrued figure and through-date rather than sharing them.
|
||||||
|
*/
|
||||||
|
@Column(name = "od_interest_calc", scale = 6, precision = 19, nullable = false)
|
||||||
|
private BigDecimal odInterestCalc;
|
||||||
|
|
||||||
|
@Column(name = "od_interest_date")
|
||||||
|
private LocalDate odInterestDate;
|
||||||
|
|
||||||
|
@Column(name = "sp_description", length = 500)
|
||||||
|
private String spDescription;
|
||||||
|
|
||||||
|
@Column(name = "freeze_code", length = 20, nullable = false)
|
||||||
|
private String freezeCode;
|
||||||
|
|
||||||
|
@Column(name = "adv_code", length = 40, nullable = false)
|
||||||
|
private String advCode;
|
||||||
|
|
||||||
|
@Column(name = "credit_to_branch", length = 100)
|
||||||
|
private String creditToBranch;
|
||||||
|
|
||||||
|
@Column(name = "credit_to_account_type", length = 100)
|
||||||
|
private String creditToAccountType;
|
||||||
|
|
||||||
|
@Column(name = "credit_to_account", length = 50)
|
||||||
|
private String creditToAccount;
|
||||||
|
|
||||||
|
@Column(name = "nominee_applicable", nullable = false)
|
||||||
|
private boolean nomineeApplicable;
|
||||||
|
|
||||||
|
@Column(name = "nominee_split_mode", length = 20)
|
||||||
|
private String nomineeSplitMode;
|
||||||
|
|
||||||
|
@Column(name = "status", length = 20, nullable = false)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "close_date")
|
||||||
|
private LocalDate closeDate;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||||
|
@OrderBy("id")
|
||||||
|
private List<PartyNominee> nominees = new ArrayList<>();
|
||||||
|
|
||||||
|
protected Party() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
private Party(final Client client, final Agent agent, final Office office, final String partyCode, final JsonCommand command) {
|
||||||
|
this.client = client;
|
||||||
|
this.agent = agent;
|
||||||
|
this.office = office;
|
||||||
|
this.partyCode = partyCode;
|
||||||
|
this.subType = command.stringValueOfParameterNamed("subType");
|
||||||
|
this.lastAccount = command.stringValueOfParameterNamedAllowingNull("lastAccount");
|
||||||
|
this.nominal = command.booleanPrimitiveValueOfParameterNamed("nominal");
|
||||||
|
this.aadharNo = command.stringValueOfParameterNamedAllowingNull("aadharNo");
|
||||||
|
this.partyName = command.stringValueOfParameterNamed("partyName");
|
||||||
|
this.partyName1 = command.stringValueOfParameterNamedAllowingNull("partyName1");
|
||||||
|
this.address1 = command.stringValueOfParameterNamed("address1");
|
||||||
|
this.address2 = command.stringValueOfParameterNamedAllowingNull("address2");
|
||||||
|
this.address3 = command.stringValueOfParameterNamedAllowingNull("address3");
|
||||||
|
this.city = command.stringValueOfParameterNamed("city");
|
||||||
|
this.pincode = command.stringValueOfParameterNamed("pincode");
|
||||||
|
this.gender = command.stringValueOfParameterNamedAllowingNull("gender");
|
||||||
|
this.panCardNo = command.stringValueOfParameterNamedAllowingNull("panCardNo");
|
||||||
|
this.telephone = command.stringValueOfParameterNamedAllowingNull("telephone");
|
||||||
|
this.mobileNoSms = command.stringValueOfParameterNamed("mobileNoSms");
|
||||||
|
this.operation = command.stringValueOfParameterNamed("operation");
|
||||||
|
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
|
||||||
|
this.occupation = command.stringValueOfParameterNamedAllowingNull("occupation");
|
||||||
|
this.risk = command.stringValueOfParameterNamedAllowingNull("risk");
|
||||||
|
this.ckycId = command.stringValueOfParameterNamedAllowingNull("ckycId");
|
||||||
|
this.adultMinor = command.stringValueOfParameterNamed("adultMinor");
|
||||||
|
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
|
||||||
|
this.guardianClientId = command.longValueOfParameterNamed("guardianClientId");
|
||||||
|
this.minAccountOpt = command.stringValueOfParameterNamedAllowingNull("minAccountOpt");
|
||||||
|
this.relation = command.stringValueOfParameterNamedAllowingNull("relation");
|
||||||
|
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
|
||||||
|
this.month = command.integerValueOfParameterNamedDefaultToNullIfZero("month");
|
||||||
|
// maturityDate and maturityAmount are not read from the request - recalculateTerm() below
|
||||||
|
// works both out from the opening date and the terms.
|
||||||
|
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
|
||||||
|
this.depositAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("depositAmount"));
|
||||||
|
this.dailyDepositAmount = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
|
||||||
|
this.days = command.integerValueOfParameterNamed("days");
|
||||||
|
this.ledgerNo = command.stringValueOfParameterNamedAllowingNull("ledgerNo");
|
||||||
|
this.maturityAmount = BigDecimal.ZERO;
|
||||||
|
this.interestRate = command.parameterExists("interestRate")
|
||||||
|
? command.bigDecimalValueOfParameterNamed("interestRate")
|
||||||
|
: DEFAULT_INTEREST_RATE;
|
||||||
|
this.withdrawType = command.stringValueOfParameterNamedAllowingNull("withdrawType");
|
||||||
|
this.od1Flag = false;
|
||||||
|
this.od2Flag = false;
|
||||||
|
this.lien = command.booleanPrimitiveValueOfParameterNamed("lien");
|
||||||
|
this.lienReason = StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull("lienReason"));
|
||||||
|
this.payDate = command.localDateValueOfParameterNamed("payDate");
|
||||||
|
this.paymentAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("paymentAmount"));
|
||||||
|
this.openingBalance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("openingBalance"));
|
||||||
|
this.currentBalance = this.openingBalance;
|
||||||
|
this.isSmsActive = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
|
||||||
|
this.isEmailActive = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
|
||||||
|
this.interestCalc = BigDecimal.ZERO;
|
||||||
|
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
|
||||||
|
this.odInterestCalc = BigDecimal.ZERO;
|
||||||
|
this.odInterestDate = null;
|
||||||
|
this.spDescription = command.stringValueOfParameterNamedAllowingNull("spDescription");
|
||||||
|
this.freezeCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("freezeCode"), "Normal");
|
||||||
|
this.advCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("advCode"), "None");
|
||||||
|
this.creditToBranch = command.stringValueOfParameterNamedAllowingNull("creditToBranch");
|
||||||
|
this.creditToAccountType = command.stringValueOfParameterNamedAllowingNull("creditToAccountType");
|
||||||
|
this.creditToAccount = command.stringValueOfParameterNamedAllowingNull("creditToAccount");
|
||||||
|
this.nomineeApplicable = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
|
||||||
|
this.nomineeSplitMode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("nomineeSplitMode"),
|
||||||
|
"Percentage");
|
||||||
|
this.status = STATUS_ACTIVE;
|
||||||
|
|
||||||
|
recalculateTerm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the scheme's interest rate. The rate belongs to the scheme, not to the counter: it is set
|
||||||
|
* when the account is opened or moved to another scheme, and again on every open account whenever
|
||||||
|
* the bank changes the scheme's rate, with the maturity figure worked out again on it.
|
||||||
|
*/
|
||||||
|
public void takeSchemeRate(final BigDecimal schemeRate) {
|
||||||
|
this.interestRate = schemeRate == null ? BigDecimal.ZERO : schemeRate;
|
||||||
|
recalculateTerm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Works out the maturity date and amount from the terms just set.
|
||||||
|
*
|
||||||
|
* Both are quoted to the customer when the account is opened, so neither is taken from the
|
||||||
|
* request: a figure the caller supplies is a figure the caller can get wrong or tamper with.
|
||||||
|
* The screen shows the same numbers as a preview, worked out the same way.
|
||||||
|
*/
|
||||||
|
private void recalculateTerm() {
|
||||||
|
if (this.openingDate != null) {
|
||||||
|
if (this.month != null && this.month > 0) {
|
||||||
|
this.maturityDate = this.openingDate.plusMonths(this.month);
|
||||||
|
// Collection runs every day of the term without a break, so the number of instalments
|
||||||
|
// is the number of days - it is not a separate thing for anyone to type.
|
||||||
|
//
|
||||||
|
// It was asked for, and a figure below the term's own length quietly broke the
|
||||||
|
// maturity quote: an account collecting a hundred rupees a day for thirty days with
|
||||||
|
// ten instalments keyed against it was quoted a thousand rupees and would collect
|
||||||
|
// three. Working it out here means the two can no longer disagree.
|
||||||
|
this.days = (int) ChronoUnit.DAYS.between(this.openingDate, this.maturityDate);
|
||||||
|
} else if (this.days != null && this.days > 0) {
|
||||||
|
// No month given, so the term is the run of days itself and days stands as entered.
|
||||||
|
this.maturityDate = this.openingDate.plusDays(this.days);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.maturityAmount = calculateMaturityAmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the account matures to: every instalment paid in, plus simple interest on each for the
|
||||||
|
* time it sits until maturity.
|
||||||
|
*
|
||||||
|
* The first instalment earns over the whole term and the last over none, which comes to
|
||||||
|
* n(n+1)/2 instalment-days of interest across n instalments. At a zero rate this is just the
|
||||||
|
* instalments themselves.
|
||||||
|
*/
|
||||||
|
private BigDecimal calculateMaturityAmount() {
|
||||||
|
if (this.dailyDepositAmount == null || this.days == null || this.days <= 0
|
||||||
|
|| this.dailyDepositAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal instalments = BigDecimal.valueOf(this.days);
|
||||||
|
final BigDecimal principal = this.dailyDepositAmount.multiply(instalments);
|
||||||
|
|
||||||
|
final BigDecimal rate = this.interestRate == null ? BigDecimal.ZERO : this.interestRate;
|
||||||
|
if (rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
return principal.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal instalmentDays = instalments.multiply(instalments.add(BigDecimal.ONE))
|
||||||
|
.divide(BigDecimal.valueOf(2), MATH_CONTEXT);
|
||||||
|
final BigDecimal interest = this.dailyDepositAmount.multiply(instalmentDays).multiply(rate)
|
||||||
|
.divide(BigDecimal.valueOf(100), MATH_CONTEXT)
|
||||||
|
.divide(BigDecimal.valueOf(DAYS_IN_YEAR), MATH_CONTEXT);
|
||||||
|
|
||||||
|
return principal.add(interest).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Party fromJson(final Client client, final Agent agent, final Office office, final String partyCode,
|
||||||
|
final JsonCommand command) {
|
||||||
|
return new Party(client, agent, office, partyCode, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves this account to another branch, which a party update may do on its own. */
|
||||||
|
public void changeOffice(final Office newOffice) {
|
||||||
|
this.office = newOffice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal orZero(final BigDecimal value) {
|
||||||
|
return value == null ? BigDecimal.ZERO : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addNominee(final PartyNominee nominee) {
|
||||||
|
nominee.setParty(this);
|
||||||
|
this.nominees.add(nominee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void resetNominees() {
|
||||||
|
this.nominees.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void changeClient(final Client newClient) {
|
||||||
|
this.client = newClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void changeAgent(final Agent newAgent) {
|
||||||
|
this.agent = newAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links this Party to an existing real Savings Account, resolved by account number in the write
|
||||||
|
* service. There is no unlink - once a Party's DDS scheme is tied to a real account, it stays
|
||||||
|
* tied.
|
||||||
|
*/
|
||||||
|
public void linkSavingsAccount(final Long savingsAccountId) {
|
||||||
|
this.savingsAccountId = savingsAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numbers the account from its id, the same way 0514 numbered the accounts already open. */
|
||||||
|
public void assignAccountNo() {
|
||||||
|
if (this.accountNo == null && getId() != null) {
|
||||||
|
this.accountNo = "DDS" + String.format("%09d", getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal credit(final BigDecimal amount) {
|
||||||
|
this.currentBalance = this.currentBalance.add(amount);
|
||||||
|
return this.currentBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal debit(final BigDecimal amount) {
|
||||||
|
this.currentBalance = this.currentBalance.subtract(amount);
|
||||||
|
return this.currentBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a minor's account over to its holder, now that they have come of age and been identified.
|
||||||
|
*
|
||||||
|
* Until this the guardian operates the account and the bank deals with them. After it the customer
|
||||||
|
* holds it in their own right, so the guardian is kept only as a record of who stood for them
|
||||||
|
* before - the account is no longer theirs to operate.
|
||||||
|
*
|
||||||
|
* Not done on the birthday alone. An eighteen-year-old the bank has not yet identified is somebody
|
||||||
|
* it cannot pay, so the turnover waits on their own KYC being verified.
|
||||||
|
*/
|
||||||
|
public void becomeAdult() {
|
||||||
|
this.adultMinor = ADULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMinor() {
|
||||||
|
return MINOR.equalsIgnoreCase(this.adultMinor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String MINOR = "Minor";
|
||||||
|
private static final String ADULT = "Adult";
|
||||||
|
|
||||||
|
public boolean isClosed() {
|
||||||
|
return STATUS_CLOSED.equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes this Party's account as of the given date. The balance must already be zero - a Party
|
||||||
|
* is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money
|
||||||
|
* still owed to it.
|
||||||
|
*/
|
||||||
|
public void close(final LocalDate closeDate) {
|
||||||
|
this.status = STATUS_CLOSED;
|
||||||
|
this.closeDate = closeDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interest Calculation: parks the interest earned since {@code interestDate} in the account's
|
||||||
|
* accrued-interest figure - the legacy screen's "Int.Calc" - and stamps the date interest is now
|
||||||
|
* calculated up to. The balance is untouched, because interest is not the customer's to draw on
|
||||||
|
* until it is posted.
|
||||||
|
*
|
||||||
|
* The stamp is what keeps this safe to re-run: a second calculation for the same date finds no
|
||||||
|
* days left to accrue, so the same interest cannot be earned twice.
|
||||||
|
*/
|
||||||
|
public void accrueInterest(final BigDecimal amount, final LocalDate throughDate, final boolean odInterest) {
|
||||||
|
if (odInterest) {
|
||||||
|
this.odInterestCalc = this.odInterestCalc.add(amount);
|
||||||
|
this.odInterestDate = throughDate;
|
||||||
|
} else {
|
||||||
|
this.interestCalc = this.interestCalc.add(amount);
|
||||||
|
this.interestDate = throughDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far the given interest stream has been calculated through - where the next run starts from. */
|
||||||
|
public LocalDate interestCalculatedThrough(final boolean odInterest) {
|
||||||
|
return odInterest ? this.odInterestDate : this.interestDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the given interest stream is holding, waiting to be posted. */
|
||||||
|
public BigDecimal accruedInterest(final boolean odInterest) {
|
||||||
|
return odInterest ? this.odInterestCalc : this.interestCalc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interest Posting: moves the given amount out of accrued interest and into the balance, and
|
||||||
|
* returns the new balance.
|
||||||
|
*
|
||||||
|
* The amount is the one the run previewed rather than whatever is accrued at this moment, so what
|
||||||
|
* gets credited is always what was seen and approved. Since it comes off the accrued figure, a
|
||||||
|
* second posting of the same interest finds nothing left to move.
|
||||||
|
*/
|
||||||
|
public BigDecimal postInterest(final BigDecimal amount, final boolean odInterest) {
|
||||||
|
if (odInterest) {
|
||||||
|
this.odInterestCalc = this.odInterestCalc.subtract(amount);
|
||||||
|
} else {
|
||||||
|
this.interestCalc = this.interestCalc.subtract(amount);
|
||||||
|
}
|
||||||
|
this.currentBalance = this.currentBalance.add(amount);
|
||||||
|
return this.currentBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long clientId() {
|
||||||
|
return this.client.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long agentId() {
|
||||||
|
return this.agent.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> update(final JsonCommand command) {
|
||||||
|
|
||||||
|
final Map<String, Object> actualChanges = new LinkedHashMap<>(40);
|
||||||
|
|
||||||
|
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
|
||||||
|
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
|
||||||
|
}
|
||||||
|
if (command.isChangeInLongParameterNamed("agentId", this.agent.getId())) {
|
||||||
|
actualChanges.put("agentId", command.longValueOfParameterNamed("agentId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "subType", this.subType, v -> this.subType = v);
|
||||||
|
stringChange(command, actualChanges, "lastAccount", this.lastAccount, v -> this.lastAccount = v);
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("nominal", this.nominal)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nominal");
|
||||||
|
actualChanges.put("nominal", newValue);
|
||||||
|
this.nominal = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "aadharNo", this.aadharNo, v -> this.aadharNo = v);
|
||||||
|
stringChange(command, actualChanges, "partyName", this.partyName, v -> this.partyName = v);
|
||||||
|
stringChange(command, actualChanges, "partyName1", this.partyName1, v -> this.partyName1 = v);
|
||||||
|
stringChange(command, actualChanges, "address1", this.address1, v -> this.address1 = v);
|
||||||
|
stringChange(command, actualChanges, "address2", this.address2, v -> this.address2 = v);
|
||||||
|
stringChange(command, actualChanges, "address3", this.address3, v -> this.address3 = v);
|
||||||
|
stringChange(command, actualChanges, "city", this.city, v -> this.city = v);
|
||||||
|
stringChange(command, actualChanges, "pincode", this.pincode, v -> this.pincode = v);
|
||||||
|
stringChange(command, actualChanges, "gender", this.gender, v -> this.gender = v);
|
||||||
|
stringChange(command, actualChanges, "panCardNo", this.panCardNo, v -> this.panCardNo = v);
|
||||||
|
stringChange(command, actualChanges, "telephone", this.telephone, v -> this.telephone = v);
|
||||||
|
stringChange(command, actualChanges, "mobileNoSms", this.mobileNoSms, v -> this.mobileNoSms = v);
|
||||||
|
stringChange(command, actualChanges, "operation", this.operation, v -> this.operation = v);
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("birthDate", this.birthDate)) {
|
||||||
|
actualChanges.put("birthDate", command.stringValueOfParameterNamed("birthDate"));
|
||||||
|
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "occupation", this.occupation, v -> this.occupation = v);
|
||||||
|
stringChange(command, actualChanges, "risk", this.risk, v -> this.risk = v);
|
||||||
|
stringChange(command, actualChanges, "ckycId", this.ckycId, v -> this.ckycId = v);
|
||||||
|
stringChange(command, actualChanges, "adultMinor", this.adultMinor, v -> this.adultMinor = v);
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("minorDate", this.minorDate)) {
|
||||||
|
actualChanges.put("minorDate", command.stringValueOfParameterNamed("minorDate"));
|
||||||
|
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInLongParameterNamed("guardianClientId", this.guardianClientId)) {
|
||||||
|
final Long newValue = command.longValueOfParameterNamed("guardianClientId");
|
||||||
|
actualChanges.put("guardianClientId", newValue);
|
||||||
|
this.guardianClientId = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "minAccountOpt", this.minAccountOpt, v -> this.minAccountOpt = v);
|
||||||
|
stringChange(command, actualChanges, "relation", this.relation, v -> this.relation = v);
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("openingDate", this.openingDate)) {
|
||||||
|
actualChanges.put("openingDate", command.stringValueOfParameterNamed("openingDate"));
|
||||||
|
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInIntegerParameterNamed("month", this.month)) {
|
||||||
|
final Integer newValue = command.integerValueOfParameterNamed("month");
|
||||||
|
actualChanges.put("month", newValue);
|
||||||
|
this.month = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("reopenDate", this.reopenDate)) {
|
||||||
|
actualChanges.put("reopenDate", command.stringValueOfParameterNamed("reopenDate"));
|
||||||
|
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("depositAmount", this.depositAmount)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("depositAmount");
|
||||||
|
actualChanges.put("depositAmount", newValue);
|
||||||
|
this.depositAmount = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("dailyDepositAmount", this.dailyDepositAmount)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
|
||||||
|
actualChanges.put("dailyDepositAmount", newValue);
|
||||||
|
this.dailyDepositAmount = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInIntegerParameterNamed("days", this.days)) {
|
||||||
|
final Integer newValue = command.integerValueOfParameterNamed("days");
|
||||||
|
actualChanges.put("days", newValue);
|
||||||
|
this.days = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rate is not edited here: it is the scheme's, taken when the account is opened or moved
|
||||||
|
// to another scheme - see takeSchemeRate.
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "ledgerNo", this.ledgerNo, v -> this.ledgerNo = v);
|
||||||
|
stringChange(command, actualChanges, "withdrawType", this.withdrawType, v -> this.withdrawType = v);
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("lien", this.lien)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("lien");
|
||||||
|
actualChanges.put("lien", newValue);
|
||||||
|
this.lien = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "lienReason", this.lienReason, v -> this.lienReason = v);
|
||||||
|
|
||||||
|
// A released account explains nothing, so the reason goes with the hold. Leaving it behind
|
||||||
|
// would have the screen read "Lien: No, held for Loan #1234" - a sentence that contradicts
|
||||||
|
// itself, and which somebody will one day read as the account still being held.
|
||||||
|
//
|
||||||
|
// Nothing is lost: what it was held for, who released it and when are all on the command the
|
||||||
|
// release rode in on, which maker-checker keeps.
|
||||||
|
if (!this.lien && this.lienReason != null) {
|
||||||
|
actualChanges.put("lienReason", null);
|
||||||
|
this.lienReason = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("payDate", this.payDate)) {
|
||||||
|
actualChanges.put("payDate", command.stringValueOfParameterNamed("payDate"));
|
||||||
|
this.payDate = command.localDateValueOfParameterNamed("payDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("paymentAmount", this.paymentAmount)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("paymentAmount");
|
||||||
|
actualChanges.put("paymentAmount", newValue);
|
||||||
|
this.paymentAmount = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBigDecimalParameterNamed("openingBalance", this.openingBalance)) {
|
||||||
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("openingBalance");
|
||||||
|
actualChanges.put("openingBalance", newValue);
|
||||||
|
this.openingBalance = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("isSmsActive", this.isSmsActive)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
|
||||||
|
actualChanges.put("isSmsActive", newValue);
|
||||||
|
this.isSmsActive = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("isEmailActive", this.isEmailActive)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
|
||||||
|
actualChanges.put("isEmailActive", newValue);
|
||||||
|
this.isEmailActive = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInDateParameterNamed("interestDate", this.interestDate)) {
|
||||||
|
actualChanges.put("interestDate", command.stringValueOfParameterNamed("interestDate"));
|
||||||
|
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "spDescription", this.spDescription, v -> this.spDescription = v);
|
||||||
|
stringChange(command, actualChanges, "freezeCode", this.freezeCode, v -> this.freezeCode = v);
|
||||||
|
stringChange(command, actualChanges, "advCode", this.advCode, v -> this.advCode = v);
|
||||||
|
stringChange(command, actualChanges, "creditToBranch", this.creditToBranch, v -> this.creditToBranch = v);
|
||||||
|
stringChange(command, actualChanges, "creditToAccountType", this.creditToAccountType, v -> this.creditToAccountType = v);
|
||||||
|
stringChange(command, actualChanges, "creditToAccount", this.creditToAccount, v -> this.creditToAccount = v);
|
||||||
|
|
||||||
|
if (command.isChangeInBooleanParameterNamed("nomineeApplicable", this.nomineeApplicable)) {
|
||||||
|
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
|
||||||
|
actualChanges.put("nomineeApplicable", newValue);
|
||||||
|
this.nomineeApplicable = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stringChange(command, actualChanges, "nomineeSplitMode", this.nomineeSplitMode, v -> this.nomineeSplitMode = v);
|
||||||
|
|
||||||
|
// Correcting a term, an instalment or the rate has to move the maturity figures with it -
|
||||||
|
// otherwise the account keeps quoting what it was worth under the old terms.
|
||||||
|
final LocalDate maturityDateBefore = this.maturityDate;
|
||||||
|
final BigDecimal maturityAmountBefore = this.maturityAmount;
|
||||||
|
recalculateTerm();
|
||||||
|
if (!java.util.Objects.equals(maturityDateBefore, this.maturityDate)) {
|
||||||
|
actualChanges.put("maturityDate", this.maturityDate);
|
||||||
|
}
|
||||||
|
if (maturityAmountBefore == null || this.maturityAmount.compareTo(maturityAmountBefore) != 0) {
|
||||||
|
actualChanges.put("maturityAmount", this.maturityAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return actualChanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
|
||||||
|
final String existingValue, final Consumer<String> setter) {
|
||||||
|
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed(paramName);
|
||||||
|
actualChanges.put(paramName, newValue);
|
||||||
|
setter.accept(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.party.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single nominee recorded against a {@link Party}. Maker-checker on nominee edits is enforced at
|
||||||
|
* the command layer (the whole Party update goes through the standard maker-checker command flow),
|
||||||
|
* not on this child row individually.
|
||||||
|
*
|
||||||
|
* Audited in its own right rather than only through the party: this row decides who receives a
|
||||||
|
* customer's money after them, and "the party changed at 16:19" does not say that the person named
|
||||||
|
* to receive it was replaced, nor by whom.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_dds_party_nominee")
|
||||||
|
public class PartyNominee extends AbstractAuditableCustom {
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "party_id", nullable = false)
|
||||||
|
private Party party;
|
||||||
|
|
||||||
|
@Column(name = "nomination_date", nullable = false)
|
||||||
|
private LocalDate nominationDate;
|
||||||
|
|
||||||
|
@Column(name = "name", length = 100, nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "mobile_no", length = 20)
|
||||||
|
private String mobileNo;
|
||||||
|
|
||||||
|
@Column(name = "birth_date")
|
||||||
|
private LocalDate birthDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this nominee was under age when nominated, worked out from their date of birth rather
|
||||||
|
* than asked for.
|
||||||
|
*
|
||||||
|
* Asked for, it was got wrong: a ten-year-old was recorded as not a minor, and so no guardian
|
||||||
|
* was required for them. The date of birth is captured either way and already holds the answer,
|
||||||
|
* so the clerk is not asked a second time in a form that can disagree with the first.
|
||||||
|
*
|
||||||
|
* Kept as a stored column rather than worked out on reading, because it is the age at the
|
||||||
|
* moment of nomination that the record is of - a nominee who has since come of age was still a
|
||||||
|
* minor on the day they were named, and the guardian named beside them belongs to that day.
|
||||||
|
*/
|
||||||
|
@Column(name = "minor", nullable = false)
|
||||||
|
private boolean minor;
|
||||||
|
|
||||||
|
/** India's age of majority, which is what makes a nominee one who needs a guardian named. */
|
||||||
|
private static final int AGE_OF_MAJORITY = 18;
|
||||||
|
|
||||||
|
@Column(name = "relation", length = 40, nullable = false)
|
||||||
|
private String relation;
|
||||||
|
|
||||||
|
@Column(name = "gender", length = 20, nullable = false)
|
||||||
|
private String gender;
|
||||||
|
|
||||||
|
@Column(name = "guardian_name", length = 100)
|
||||||
|
private String guardianName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this nominee is entitled to, under a Percentage split. Null under a Sequence split, where
|
||||||
|
* the order the nominees are held in is the entitlement and there is no share to state.
|
||||||
|
*/
|
||||||
|
@Column(name = "share_percent", precision = 5, scale = 2)
|
||||||
|
private BigDecimal sharePercent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this nominee stands in the order the bank pays, under a Sequence split. Null under a
|
||||||
|
* Percentage split, where everyone is paid at once and there is no first.
|
||||||
|
*/
|
||||||
|
@Column(name = "sequence_no")
|
||||||
|
private Integer sequenceNo;
|
||||||
|
|
||||||
|
protected PartyNominee() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartyNominee(final LocalDate nominationDate, final String name, final String mobileNo, final LocalDate birthDate,
|
||||||
|
final String relation, final String gender, final String guardianName, final BigDecimal sharePercent,
|
||||||
|
final Integer sequenceNo) {
|
||||||
|
this.nominationDate = nominationDate;
|
||||||
|
// Trimmed on the way in. A relation typed as "friend " is a different value from "friend"
|
||||||
|
// to everything that groups or matches on it, and the trailing space is invisible on screen
|
||||||
|
// - so the two read as the same relation everywhere except where it counts.
|
||||||
|
this.name = StringUtils.trimToNull(name);
|
||||||
|
this.mobileNo = StringUtils.trimToNull(mobileNo);
|
||||||
|
this.birthDate = birthDate;
|
||||||
|
this.minor = isUnderAgeOn(birthDate, nominationDate);
|
||||||
|
this.relation = StringUtils.trimToNull(relation);
|
||||||
|
this.gender = StringUtils.trimToNull(gender);
|
||||||
|
this.guardianName = StringUtils.trimToNull(guardianName);
|
||||||
|
this.sharePercent = sharePercent;
|
||||||
|
this.sequenceNo = sequenceNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setParty(final Party party) {
|
||||||
|
this.party = party;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether someone born on the given day was still under age on the day they were nominated.
|
||||||
|
*
|
||||||
|
* Measured against the nomination date rather than today, so a nomination reads the same in ten
|
||||||
|
* years as it did when it was made - and a nominee who has since come of age does not quietly
|
||||||
|
* lose the guardian who was named for them at the time.
|
||||||
|
*
|
||||||
|
* A missing date of birth is treated as not a minor: the field is required, so a null here can
|
||||||
|
* only be a record made before it was, and guessing "minor" would demand a guardian for someone
|
||||||
|
* who may well be eighty.
|
||||||
|
*/
|
||||||
|
public static boolean isUnderAgeOn(final LocalDate birthDate, final LocalDate nominationDate) {
|
||||||
|
if (birthDate == null || nominationDate == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return birthDate.isAfter(nominationDate.minusYears(AGE_OF_MAJORITY));
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.party.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a Close Party command, or a Party Tran collection, targets a Party that is already
|
||||||
|
* closed.
|
||||||
|
*/
|
||||||
|
public class PartyAlreadyClosedException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public PartyAlreadyClosedException(final String partyCode) {
|
||||||
|
super("error.msg.party.already.closed", "Party " + partyCode + " is already closed", partyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.party.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a Close Party command is attempted while the Party's balance is still non-zero - the
|
||||||
|
* remaining balance must be paid out (a Withdraw Deposite to zero) before the account can be closed.
|
||||||
|
*/
|
||||||
|
public class PartyHasBalanceException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public PartyHasBalanceException(final String partyCode) {
|
||||||
|
super("error.msg.party.has.balance", "Party " + partyCode + " still has a non-zero balance and cannot be closed", partyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.party.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RuntimeException} thrown when a DDS party resource is not found.
|
||||||
|
*/
|
||||||
|
public class PartyNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public PartyNotFoundException(final Long id) {
|
||||||
|
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartyNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||||
|
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartyNotFoundException(final String partyCode) {
|
||||||
|
super("error.msg.party.code.invalid", "Party with code " + partyCode + " does not exist", partyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.party.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the savingsAccountNo given for a Party belongs to a different client than the Party's
|
||||||
|
* own clientId - a Party can only be linked to its own client's savings account.
|
||||||
|
*/
|
||||||
|
public class PartySavingsAccountClientMismatchException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public PartySavingsAccountClientMismatchException(final String savingsAccountNo) {
|
||||||
|
super("error.msg.party.savings.account.client.mismatch",
|
||||||
|
"Savings account " + savingsAccountNo + " does not belong to this party's client", savingsAccountNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user