Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
46f5a1babd | ||
|
|
20fe11e60d | ||
|
|
eb1aa767f7 | ||
|
|
8724c54d9b | ||
|
|
5c0a482f49 | ||
|
|
0e07e60328 | ||
|
|
e227e8ae0f | ||
|
|
231919b62b |
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-6
@@ -181,10 +181,14 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
|||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
|
||||||
.notNull().integerGreaterThanZero();
|
.notNull().integerGreaterThanZero();
|
||||||
|
|
||||||
|
// Transfers In Suspense is not offered by every savings product UI, so it is validated only
|
||||||
|
// when the client actually sends it. Omitting it leaves the mapping unset rather than failing.
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
|
||||||
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
||||||
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
||||||
|
}
|
||||||
|
|
||||||
final Long interestOnSavingsAccountId = this.fromApiJsonHelper
|
final Long interestOnSavingsAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
|
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
|
||||||
@@ -210,20 +214,29 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
|
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
|
||||||
|
// Overdraft Control / Income From Interest / Write Off apply to overdraft-capable products
|
||||||
|
// only, so they are validated when supplied rather than demanded from every savings product.
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(),
|
||||||
|
element)) {
|
||||||
final Long overdraftAccount = this.fromApiJsonHelper
|
final Long overdraftAccount = this.fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
|
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
|
||||||
.value(overdraftAccount).notNull().integerGreaterThanZero();
|
.value(overdraftAccount).notNull().integerGreaterThanZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) {
|
||||||
final Long incomeFromInterest = this.fromApiJsonHelper
|
final Long incomeFromInterest = this.fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
|
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()).value(incomeFromInterest)
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
|
||||||
.notNull().integerGreaterThanZero();
|
.value(incomeFromInterest).notNull().integerGreaterThanZero();
|
||||||
|
}
|
||||||
|
|
||||||
final Long writtenOff = this.fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) {
|
||||||
element);
|
final Long writtenOff = this.fromApiJsonHelper
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull()
|
.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
||||||
.integerGreaterThanZero();
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff)
|
||||||
|
.notNull().integerGreaterThanZero();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+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:
|
||||||
|
|||||||
+43
-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;
|
||||||
|
|
||||||
@@ -437,8 +449,34 @@ public final class AccountingConstants {
|
|||||||
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
||||||
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
||||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||||
|
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
|
||||||
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
|
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME), //
|
||||||
|
/**
|
||||||
|
* The year's closed-out profit a dividend is declared against.
|
||||||
|
*
|
||||||
|
* Kept apart from {@link #RETAINED_PROFIT} on purpose: a declaration is sized on this balance, so anything that
|
||||||
|
* has already been through a distribution decision must not sit here, or the same money would be offered for
|
||||||
|
* distribution a second time.
|
||||||
|
*/
|
||||||
|
DISTRIBUTABLE_PROFIT(302, "distributableProfit", GLAccountType.EQUITY), //
|
||||||
|
/**
|
||||||
|
* Where profit settles once it is no longer distributable - the remainder left after an approved declaration
|
||||||
|
* and dividends forfeited after the three-year claim window. Nothing here ever enters a later dividend
|
||||||
|
* calculation.
|
||||||
|
*/
|
||||||
|
RETAINED_PROFIT(303, "retainedProfit", GLAccountType.EQUITY), //
|
||||||
|
/**
|
||||||
|
* Where the paise a dividend cannot divide evenly are collected.
|
||||||
|
*
|
||||||
|
* A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 that belongs to nobody. It used to be
|
||||||
|
* swept into retained profit along with the undistributed remainder, which buried an arithmetic artefact inside
|
||||||
|
* a figure the society reads as a real business result. Given its own account it stays separately readable -
|
||||||
|
* and an auditor asking why members received less than was declared can be answered from the ledger.
|
||||||
|
*
|
||||||
|
* Optional: a society that has not mapped it has the residue swept to retained profit exactly as before.
|
||||||
|
*/
|
||||||
|
DIVIDEND_ROUND_OFF(304, "dividendRoundOff", GLAccountType.EQUITY); //
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
private final String code;
|
private final String code;
|
||||||
|
|||||||
+20
-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,25 @@ public class CommandSourceService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||||
|
if (!user.isCheckerSuperUser()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+30
-69
@@ -664,75 +664,11 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder createAgent() {
|
public CommandWrapperBuilder deleteOffice(final Long officeId) {
|
||||||
this.actionName = "CREATE";
|
this.actionName = "DELETE";
|
||||||
this.entityName = "AGENT";
|
this.entityName = "OFFICE";
|
||||||
this.entityId = null;
|
this.entityId = officeId;
|
||||||
this.href = "/dds/agents/template";
|
this.href = "/offices/" + officeId;
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder updateAgent(final Long agentId) {
|
|
||||||
this.actionName = "UPDATE";
|
|
||||||
this.entityName = "AGENT";
|
|
||||||
this.entityId = agentId;
|
|
||||||
this.href = "/dds/agents/" + agentId;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder createParty() {
|
|
||||||
this.actionName = "CREATE";
|
|
||||||
this.entityName = "PARTY";
|
|
||||||
this.entityId = null;
|
|
||||||
this.href = "/dds/parties/template";
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder updateParty(final Long partyId) {
|
|
||||||
this.actionName = "UPDATE";
|
|
||||||
this.entityName = "PARTY";
|
|
||||||
this.entityId = partyId;
|
|
||||||
this.href = "/dds/parties/" + partyId;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder closeParty(final Long partyId) {
|
|
||||||
this.actionName = "CLOSE";
|
|
||||||
this.entityName = "PARTY";
|
|
||||||
this.entityId = partyId;
|
|
||||||
this.href = "/dds/parties/" + partyId + "?command=close";
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder createPartyTransaction() {
|
|
||||||
this.actionName = "CREATE";
|
|
||||||
this.entityName = "PARTYTRANSACTION";
|
|
||||||
this.entityId = null;
|
|
||||||
this.href = "/dds/partytransactions/template";
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder createCommissionSlabs() {
|
|
||||||
this.actionName = "CREATE";
|
|
||||||
this.entityName = "COMMISSIONSLAB";
|
|
||||||
this.entityId = null;
|
|
||||||
this.href = "/dds/commissionslabs/template";
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder generateDdsBatchRun() {
|
|
||||||
this.actionName = "GENERATE";
|
|
||||||
this.entityName = "DDSBATCH";
|
|
||||||
this.entityId = null;
|
|
||||||
this.href = "/dds/batchruns/template";
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder postDdsBatchRun(final Long batchRunId) {
|
|
||||||
this.actionName = "POST";
|
|
||||||
this.entityName = "DDSBATCH";
|
|
||||||
this.entityId = batchRunId;
|
|
||||||
this.href = "/dds/batchruns/" + batchRunId;
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2283,6 +2219,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";
|
||||||
@@ -2969,6 +2913,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";
|
||||||
@@ -3175,6 +3128,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";
|
||||||
|
|||||||
+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 + ".";
|
||||||
|
|||||||
+4
@@ -39,7 +39,10 @@ public enum JobName {
|
|||||||
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
||||||
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
||||||
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
||||||
|
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
|
||||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||||
|
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
||||||
|
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
||||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||||
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
||||||
@@ -53,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"), //
|
||||||
|
|||||||
-148
@@ -1,148 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.data;
|
|
||||||
|
|
||||||
import java.io.Serial;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Collection;
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable data object representing a DDS agent, including its collection-limit history and
|
|
||||||
* guarantors. Carries the collection/commission/security-account fields merged in from SubAgent
|
|
||||||
* (2026-08-24) - Agent now does the daily collection work directly.
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
public final class AgentData implements Serializable {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private final Long id;
|
|
||||||
private final Long clientId;
|
|
||||||
private final String clientName;
|
|
||||||
private final String agentCode;
|
|
||||||
private final String agentNameId;
|
|
||||||
private final String agentAddress1;
|
|
||||||
private final String agentAddress2;
|
|
||||||
private final String agentCity;
|
|
||||||
private final String agentPinCode;
|
|
||||||
private final String agentTelephone;
|
|
||||||
private final String agentSavingAccountType;
|
|
||||||
private final String agentSavingAccountNo;
|
|
||||||
private final LocalDate agentOpeningDate;
|
|
||||||
private final LocalDate agentBirthDate;
|
|
||||||
private final BigDecimal agentCurrentBalance;
|
|
||||||
private final BigDecimal agentOpeningBalance;
|
|
||||||
private final String homeBranch;
|
|
||||||
private final String savingBranch;
|
|
||||||
private final Boolean tdsApplicable;
|
|
||||||
private final BigDecimal tdsPercent;
|
|
||||||
private final BigDecimal currentTds;
|
|
||||||
private final BigDecimal tdsAmount;
|
|
||||||
private final String transferBranch;
|
|
||||||
private final String transferAmountAccountType;
|
|
||||||
private final String transferAccountNo;
|
|
||||||
private final Boolean commissionCalculation;
|
|
||||||
private final BigDecimal limit;
|
|
||||||
private final BigDecimal machineRent;
|
|
||||||
private final String agentSecurityBranch;
|
|
||||||
private final String agentSecurityAccountType;
|
|
||||||
private final String agentSecurityAccountNo;
|
|
||||||
private final BigDecimal commission;
|
|
||||||
private final BigDecimal otherCommission;
|
|
||||||
private final BigDecimal agentDeposit;
|
|
||||||
private final BigDecimal festivalAdvance;
|
|
||||||
private final BigDecimal otherDeduction;
|
|
||||||
private final Collection<AgentLimitData> limits;
|
|
||||||
private final Collection<AgentGuarantorData> guarantors;
|
|
||||||
|
|
||||||
public static AgentData instance(final Long id, final Long clientId, final String clientName, final String agentCode,
|
|
||||||
final String agentNameId, final String agentAddress1, final String agentAddress2, final String agentCity,
|
|
||||||
final String agentPinCode, final String agentTelephone, final String agentSavingAccountType,
|
|
||||||
final String agentSavingAccountNo, final LocalDate agentOpeningDate, final LocalDate agentBirthDate,
|
|
||||||
final BigDecimal agentCurrentBalance, final BigDecimal agentOpeningBalance, final String homeBranch,
|
|
||||||
final String savingBranch, final Boolean tdsApplicable, final BigDecimal tdsPercent, final BigDecimal currentTds,
|
|
||||||
final BigDecimal tdsAmount, final String transferBranch, final String transferAmountAccountType,
|
|
||||||
final String transferAccountNo, final Boolean commissionCalculation, final BigDecimal limit, final BigDecimal machineRent,
|
|
||||||
final String agentSecurityBranch, final String agentSecurityAccountType, final String agentSecurityAccountNo,
|
|
||||||
final BigDecimal commission, final BigDecimal otherCommission, final BigDecimal agentDeposit,
|
|
||||||
final BigDecimal festivalAdvance, final BigDecimal otherDeduction, final Collection<AgentLimitData> limits,
|
|
||||||
final Collection<AgentGuarantorData> guarantors) {
|
|
||||||
return new AgentData(id, clientId, clientName, agentCode, agentNameId, agentAddress1, agentAddress2, agentCity, agentPinCode,
|
|
||||||
agentTelephone, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentCurrentBalance,
|
|
||||||
agentOpeningBalance, homeBranch, savingBranch, tdsApplicable, tdsPercent, currentTds, tdsAmount, transferBranch,
|
|
||||||
transferAmountAccountType, transferAccountNo, commissionCalculation, limit, machineRent, agentSecurityBranch,
|
|
||||||
agentSecurityAccountType, agentSecurityAccountNo, commission, otherCommission, agentDeposit, festivalAdvance,
|
|
||||||
otherDeduction, limits, guarantors);
|
|
||||||
}
|
|
||||||
|
|
||||||
private AgentData(final Long id, final Long clientId, final String clientName, final String agentCode, final String agentNameId,
|
|
||||||
final String agentAddress1, final String agentAddress2, final String agentCity, final String agentPinCode,
|
|
||||||
final String agentTelephone, final String agentSavingAccountType, final String agentSavingAccountNo,
|
|
||||||
final LocalDate agentOpeningDate, final LocalDate agentBirthDate, final BigDecimal agentCurrentBalance,
|
|
||||||
final BigDecimal agentOpeningBalance, final String homeBranch, final String savingBranch, final Boolean tdsApplicable,
|
|
||||||
final BigDecimal tdsPercent, final BigDecimal currentTds, final BigDecimal tdsAmount, final String transferBranch,
|
|
||||||
final String transferAmountAccountType, final String transferAccountNo, final Boolean commissionCalculation,
|
|
||||||
final BigDecimal limit, final BigDecimal machineRent, final String agentSecurityBranch,
|
|
||||||
final String agentSecurityAccountType, final String agentSecurityAccountNo, final BigDecimal commission,
|
|
||||||
final BigDecimal otherCommission, final BigDecimal agentDeposit, final BigDecimal festivalAdvance,
|
|
||||||
final BigDecimal otherDeduction, final Collection<AgentLimitData> limits, final Collection<AgentGuarantorData> guarantors) {
|
|
||||||
this.id = id;
|
|
||||||
this.clientId = clientId;
|
|
||||||
this.clientName = clientName;
|
|
||||||
this.agentCode = agentCode;
|
|
||||||
this.agentNameId = agentNameId;
|
|
||||||
this.agentAddress1 = agentAddress1;
|
|
||||||
this.agentAddress2 = agentAddress2;
|
|
||||||
this.agentCity = agentCity;
|
|
||||||
this.agentPinCode = agentPinCode;
|
|
||||||
this.agentTelephone = agentTelephone;
|
|
||||||
this.agentSavingAccountType = agentSavingAccountType;
|
|
||||||
this.agentSavingAccountNo = agentSavingAccountNo;
|
|
||||||
this.agentOpeningDate = agentOpeningDate;
|
|
||||||
this.agentBirthDate = agentBirthDate;
|
|
||||||
this.agentCurrentBalance = agentCurrentBalance;
|
|
||||||
this.agentOpeningBalance = agentOpeningBalance;
|
|
||||||
this.homeBranch = homeBranch;
|
|
||||||
this.savingBranch = savingBranch;
|
|
||||||
this.tdsApplicable = tdsApplicable;
|
|
||||||
this.tdsPercent = tdsPercent;
|
|
||||||
this.currentTds = currentTds;
|
|
||||||
this.tdsAmount = tdsAmount;
|
|
||||||
this.transferBranch = transferBranch;
|
|
||||||
this.transferAmountAccountType = transferAmountAccountType;
|
|
||||||
this.transferAccountNo = transferAccountNo;
|
|
||||||
this.commissionCalculation = commissionCalculation;
|
|
||||||
this.limit = limit;
|
|
||||||
this.machineRent = machineRent;
|
|
||||||
this.agentSecurityBranch = agentSecurityBranch;
|
|
||||||
this.agentSecurityAccountType = agentSecurityAccountType;
|
|
||||||
this.agentSecurityAccountNo = agentSecurityAccountNo;
|
|
||||||
this.commission = commission;
|
|
||||||
this.otherCommission = otherCommission;
|
|
||||||
this.agentDeposit = agentDeposit;
|
|
||||||
this.festivalAdvance = festivalAdvance;
|
|
||||||
this.otherDeduction = otherDeduction;
|
|
||||||
this.limits = limits;
|
|
||||||
this.guarantors = guarantors;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-59
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-48
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.data;
|
|
||||||
|
|
||||||
import java.io.Serial;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
public final class AgentLimitData implements Serializable {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private final Long id;
|
|
||||||
private final LocalDate fromDate;
|
|
||||||
private final LocalDate toDate;
|
|
||||||
private final BigDecimal limit;
|
|
||||||
|
|
||||||
public static AgentLimitData instance(final Long id, final LocalDate fromDate, final LocalDate toDate, final BigDecimal limit) {
|
|
||||||
return new AgentLimitData(id, fromDate, toDate, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private AgentLimitData(final Long id, final LocalDate fromDate, final LocalDate toDate, final BigDecimal limit) {
|
|
||||||
this.id = id;
|
|
||||||
this.fromDate = fromDate;
|
|
||||||
this.toDate = toDate;
|
|
||||||
this.limit = limit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.domain;
|
|
||||||
|
|
||||||
import jakarta.persistence.CascadeType;
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.FetchType;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.OneToMany;
|
|
||||||
import jakarta.persistence.OrderBy;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import jakarta.persistence.UniqueConstraint;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.Getter;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A DDS (Daily Deposit Scheme) collection agent. Can represent either a person or, at the top of the
|
|
||||||
* hierarchy, the bank itself acting as its own agent. Holds its own settlement savings account and one
|
|
||||||
* or more guarantors.
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Entity
|
|
||||||
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
|
|
||||||
public class Agent extends AbstractPersistableCustom<Long> {
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "client_id", nullable = false)
|
|
||||||
private Client client;
|
|
||||||
|
|
||||||
@Column(name = "agent_code", length = 20, nullable = false)
|
|
||||||
private String agentCode;
|
|
||||||
|
|
||||||
@Column(name = "agent_name", length = 100, nullable = false)
|
|
||||||
private String agentName;
|
|
||||||
|
|
||||||
@Column(name = "agent_address1", length = 200, nullable = false)
|
|
||||||
private String agentAddress1;
|
|
||||||
|
|
||||||
@Column(name = "agent_address2", length = 200)
|
|
||||||
private String agentAddress2;
|
|
||||||
|
|
||||||
@Column(name = "agent_city", length = 100, nullable = false)
|
|
||||||
private String agentCity;
|
|
||||||
|
|
||||||
@Column(name = "agent_pincode", length = 20, nullable = false)
|
|
||||||
private String agentPinCode;
|
|
||||||
|
|
||||||
@Column(name = "agent_telephone", length = 20)
|
|
||||||
private String agentTelephone;
|
|
||||||
|
|
||||||
@Column(name = "agent_saving_account_type", length = 100, nullable = false)
|
|
||||||
private String agentSavingAccountType;
|
|
||||||
|
|
||||||
@Column(name = "agent_saving_account_no", length = 50, nullable = false)
|
|
||||||
private String agentSavingAccountNo;
|
|
||||||
|
|
||||||
@Column(name = "agent_opening_date", nullable = false)
|
|
||||||
private LocalDate agentOpeningDate;
|
|
||||||
|
|
||||||
@Column(name = "agent_birth_date", nullable = false)
|
|
||||||
private LocalDate agentBirthDate;
|
|
||||||
|
|
||||||
@Column(name = "agent_current_balance", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal agentCurrentBalance;
|
|
||||||
|
|
||||||
@Column(name = "agent_opening_balance", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal agentOpeningBalance;
|
|
||||||
|
|
||||||
// Fields below were merged in from SubAgent (2026-08-24) - the Agent itself now does the daily
|
|
||||||
// collection work directly; SubAgent no longer exists as a separate tier. Ag.Level / Upper Ag.Level
|
|
||||||
// / Collector (MLM) / CRPB Points / Level Update Date were dropped - they only ever meant anything
|
|
||||||
// for a multi-level hierarchy, which no longer exists here.
|
|
||||||
@Column(name = "home_branch", length = 100, nullable = false)
|
|
||||||
private String homeBranch;
|
|
||||||
|
|
||||||
@Column(name = "saving_branch", length = 100, nullable = false)
|
|
||||||
private String savingBranch;
|
|
||||||
|
|
||||||
@Column(name = "tds_applicable", nullable = false)
|
|
||||||
private boolean tdsApplicable;
|
|
||||||
|
|
||||||
@Column(name = "tds_percent", scale = 6, precision = 19)
|
|
||||||
private BigDecimal tdsPercent;
|
|
||||||
|
|
||||||
@Column(name = "current_tds", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal currentTds;
|
|
||||||
|
|
||||||
@Column(name = "tds_amount", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal tdsAmount;
|
|
||||||
|
|
||||||
@Column(name = "transfer_branch", length = 100)
|
|
||||||
private String transferBranch;
|
|
||||||
|
|
||||||
@Column(name = "transfer_amount_account_type", length = 100)
|
|
||||||
private String transferAmountAccountType;
|
|
||||||
|
|
||||||
@Column(name = "transfer_account_no", length = 50)
|
|
||||||
private String transferAccountNo;
|
|
||||||
|
|
||||||
@Column(name = "collector_commission_calculation", nullable = false)
|
|
||||||
private boolean commissionCalculation;
|
|
||||||
|
|
||||||
@Column(name = "collector_limit_amount", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal limitAmount;
|
|
||||||
|
|
||||||
@Column(name = "machine_rent", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal machineRent;
|
|
||||||
|
|
||||||
@Column(name = "agent_security_branch", length = 100)
|
|
||||||
private String agentSecurityBranch;
|
|
||||||
|
|
||||||
@Column(name = "agent_security_account_type", length = 100)
|
|
||||||
private String agentSecurityAccountType;
|
|
||||||
|
|
||||||
@Column(name = "agent_security_account_no", length = 50)
|
|
||||||
private String agentSecurityAccountNo;
|
|
||||||
|
|
||||||
@Column(name = "commission", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal commission;
|
|
||||||
|
|
||||||
@Column(name = "other_commission", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal otherCommission;
|
|
||||||
|
|
||||||
@Column(name = "agent_deposit", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal agentDeposit;
|
|
||||||
|
|
||||||
@Column(name = "festival_advance", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal festivalAdvance;
|
|
||||||
|
|
||||||
@Column(name = "other_deduction", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal otherDeduction;
|
|
||||||
|
|
||||||
@Column(name = "last_commission_date")
|
|
||||||
private LocalDate lastCommissionDate;
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
|
||||||
@OrderBy("id")
|
|
||||||
private List<AgentGuarantor> guarantors = new ArrayList<>();
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
|
||||||
@OrderBy("id")
|
|
||||||
private List<AgentLimit> limits = new ArrayList<>();
|
|
||||||
|
|
||||||
protected Agent() {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
private Agent(final Client client, final String agentCode, final String agentName, final String agentAddress1,
|
|
||||||
final String agentAddress2, final String agentCity, final String agentPinCode, final String agentTelephone,
|
|
||||||
final String agentSavingAccountType, final String agentSavingAccountNo, final LocalDate agentOpeningDate,
|
|
||||||
final LocalDate agentBirthDate, final BigDecimal agentOpeningBalance, final JsonCommand command) {
|
|
||||||
this.client = client;
|
|
||||||
this.agentCode = agentCode;
|
|
||||||
this.agentName = agentName;
|
|
||||||
this.agentAddress1 = agentAddress1;
|
|
||||||
this.agentAddress2 = StringUtils.defaultIfEmpty(agentAddress2, null);
|
|
||||||
this.agentCity = agentCity;
|
|
||||||
this.agentPinCode = agentPinCode;
|
|
||||||
this.agentTelephone = StringUtils.defaultIfEmpty(agentTelephone, null);
|
|
||||||
this.agentSavingAccountType = agentSavingAccountType;
|
|
||||||
this.agentSavingAccountNo = agentSavingAccountNo;
|
|
||||||
this.agentOpeningDate = agentOpeningDate;
|
|
||||||
this.agentBirthDate = agentBirthDate;
|
|
||||||
this.agentOpeningBalance = agentOpeningBalance == null ? BigDecimal.ZERO : agentOpeningBalance;
|
|
||||||
this.agentCurrentBalance = this.agentOpeningBalance;
|
|
||||||
|
|
||||||
this.homeBranch = command.stringValueOfParameterNamed("homeBranch");
|
|
||||||
this.savingBranch = command.stringValueOfParameterNamed("savingBranch");
|
|
||||||
this.tdsApplicable = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
|
|
||||||
this.tdsPercent = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("tdsPercent");
|
|
||||||
this.currentTds = BigDecimal.ZERO;
|
|
||||||
this.tdsAmount = BigDecimal.ZERO;
|
|
||||||
this.transferBranch = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferBranch"), null);
|
|
||||||
this.transferAmountAccountType = StringUtils
|
|
||||||
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferAmountAccountType"), null);
|
|
||||||
this.transferAccountNo = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferAccountNo"), null);
|
|
||||||
this.commissionCalculation = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
|
|
||||||
this.limitAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("limit"));
|
|
||||||
this.machineRent = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("machineRent"));
|
|
||||||
this.agentSecurityBranch = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityBranch"),
|
|
||||||
null);
|
|
||||||
this.agentSecurityAccountType = StringUtils
|
|
||||||
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityAccountType"), null);
|
|
||||||
this.agentSecurityAccountNo = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityAccountNo"),
|
|
||||||
null);
|
|
||||||
this.commission = BigDecimal.ZERO;
|
|
||||||
this.otherCommission = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("otherCommission"));
|
|
||||||
this.agentDeposit = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentDeposit"));
|
|
||||||
this.festivalAdvance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("festivalAdvance"));
|
|
||||||
this.otherDeduction = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("otherDeduction"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Agent fromJson(final Client client, final String agentCode, final JsonCommand command) {
|
|
||||||
final String agentName = command.stringValueOfParameterNamed("agentNameId");
|
|
||||||
final String agentAddress1 = command.stringValueOfParameterNamed("agentAddress1");
|
|
||||||
final String agentAddress2 = command.stringValueOfParameterNamedAllowingNull("agentAddress2");
|
|
||||||
final String agentCity = command.stringValueOfParameterNamed("agentCity");
|
|
||||||
final String agentPinCode = command.stringValueOfParameterNamed("agentPinCode");
|
|
||||||
final String agentTelephone = command.stringValueOfParameterNamedAllowingNull("agentTelephone");
|
|
||||||
final String agentSavingAccountType = command.stringValueOfParameterNamed("agentSavingAccountType");
|
|
||||||
final String agentSavingAccountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
|
||||||
final LocalDate agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
|
|
||||||
final LocalDate agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
|
|
||||||
final BigDecimal agentOpeningBalance = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentOpeningBalance");
|
|
||||||
|
|
||||||
return new Agent(client, agentCode, agentName, agentAddress1, agentAddress2, agentCity, agentPinCode, agentTelephone,
|
|
||||||
agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentOpeningBalance, command);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BigDecimal orZero(final BigDecimal value) {
|
|
||||||
return value == null ? BigDecimal.ZERO : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void addGuarantor(final AgentGuarantor guarantor) {
|
|
||||||
guarantor.setAgent(this);
|
|
||||||
this.guarantors.add(guarantor);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void resetGuarantors() {
|
|
||||||
this.guarantors.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void addLimit(final AgentLimit limit) {
|
|
||||||
limit.setAgent(this);
|
|
||||||
this.limits.add(limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void resetLimits() {
|
|
||||||
this.limits.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a Commission Posting's amount to this agent's running commission total and stamps the date
|
|
||||||
* commission was last calculated through, so the next Commission Calculation batch only covers
|
|
||||||
* collections since then.
|
|
||||||
*/
|
|
||||||
public void postCommission(final BigDecimal amount, final LocalDate throughDate) {
|
|
||||||
this.commission = this.commission.add(amount);
|
|
||||||
this.lastCommissionDate = throughDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cash this agent has collected from parties but not yet handed over to a branch (an Agent Tran
|
|
||||||
* settlement). Raised by a Party Tran collection, lowered by an Agent Tran settlement - this is the
|
|
||||||
* figure an Agent's collection limit is enforced against.
|
|
||||||
*/
|
|
||||||
public BigDecimal credit(final BigDecimal amount) {
|
|
||||||
this.agentCurrentBalance = this.agentCurrentBalance.add(amount);
|
|
||||||
return this.agentCurrentBalance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal settle(final BigDecimal amount) {
|
|
||||||
this.agentCurrentBalance = this.agentCurrentBalance.subtract(amount);
|
|
||||||
return this.agentCurrentBalance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void changeClient(final Client newClient) {
|
|
||||||
this.client = newClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long clientId() {
|
|
||||||
return this.client.getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, Object> update(final JsonCommand command) {
|
|
||||||
|
|
||||||
final Map<String, Object> actualChanges = new LinkedHashMap<>(10);
|
|
||||||
|
|
||||||
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
|
|
||||||
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed("agentNameId", this.agentName)) {
|
|
||||||
final String newValue = command.stringValueOfParameterNamed("agentNameId");
|
|
||||||
actualChanges.put("agentNameId", newValue);
|
|
||||||
this.agentName = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed("agentAddress1", this.agentAddress1)) {
|
|
||||||
final String newValue = command.stringValueOfParameterNamed("agentAddress1");
|
|
||||||
actualChanges.put("agentAddress1", newValue);
|
|
||||||
this.agentAddress1 = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed("agentAddress2", this.agentAddress2)) {
|
|
||||||
final String newValue = command.stringValueOfParameterNamed("agentAddress2");
|
|
||||||
actualChanges.put("agentAddress2", newValue);
|
|
||||||
this.agentAddress2 = StringUtils.defaultIfEmpty(newValue, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed("agentCity", this.agentCity)) {
|
|
||||||
final String newValue = command.stringValueOfParameterNamed("agentCity");
|
|
||||||
actualChanges.put("agentCity", newValue);
|
|
||||||
this.agentCity = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed("agentPinCode", this.agentPinCode)) {
|
|
||||||
final String newValue = command.stringValueOfParameterNamed("agentPinCode");
|
|
||||||
actualChanges.put("agentPinCode", newValue);
|
|
||||||
this.agentPinCode = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed("agentTelephone", this.agentTelephone)) {
|
|
||||||
final String newValue = command.stringValueOfParameterNamed("agentTelephone");
|
|
||||||
actualChanges.put("agentTelephone", newValue);
|
|
||||||
this.agentTelephone = StringUtils.defaultIfEmpty(newValue, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed("agentSavingAccountType", this.agentSavingAccountType)) {
|
|
||||||
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountType");
|
|
||||||
actualChanges.put("agentSavingAccountType", newValue);
|
|
||||||
this.agentSavingAccountType = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed("agentSavingAccountNo", this.agentSavingAccountNo)) {
|
|
||||||
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountNo");
|
|
||||||
actualChanges.put("agentSavingAccountNo", newValue);
|
|
||||||
this.agentSavingAccountNo = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInDateParameterNamed("agentOpeningDate", this.agentOpeningDate)) {
|
|
||||||
actualChanges.put("agentOpeningDate", command.stringValueOfParameterNamed("agentOpeningDate"));
|
|
||||||
this.agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInDateParameterNamed("agentBirthDate", this.agentBirthDate)) {
|
|
||||||
actualChanges.put("agentBirthDate", command.stringValueOfParameterNamed("agentBirthDate"));
|
|
||||||
this.agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
|
|
||||||
}
|
|
||||||
|
|
||||||
stringChange(command, actualChanges, "homeBranch", this.homeBranch, v -> this.homeBranch = v);
|
|
||||||
stringChange(command, actualChanges, "savingBranch", this.savingBranch, v -> this.savingBranch = v);
|
|
||||||
|
|
||||||
if (command.isChangeInBooleanParameterNamed("tdsApplicable", this.tdsApplicable)) {
|
|
||||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
|
|
||||||
actualChanges.put("tdsApplicable", newValue);
|
|
||||||
this.tdsApplicable = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("tdsPercent", this.tdsPercent)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("tdsPercent");
|
|
||||||
actualChanges.put("tdsPercent", newValue);
|
|
||||||
this.tdsPercent = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
stringChange(command, actualChanges, "transferBranch", this.transferBranch,
|
|
||||||
v -> this.transferBranch = StringUtils.defaultIfEmpty(v, null));
|
|
||||||
stringChange(command, actualChanges, "transferAmountAccountType", this.transferAmountAccountType,
|
|
||||||
v -> this.transferAmountAccountType = StringUtils.defaultIfEmpty(v, null));
|
|
||||||
stringChange(command, actualChanges, "transferAccountNo", this.transferAccountNo,
|
|
||||||
v -> this.transferAccountNo = StringUtils.defaultIfEmpty(v, null));
|
|
||||||
|
|
||||||
if (command.isChangeInBooleanParameterNamed("commissionCalculation", this.commissionCalculation)) {
|
|
||||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
|
|
||||||
actualChanges.put("commissionCalculation", newValue);
|
|
||||||
this.commissionCalculation = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("limit", this.limitAmount)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("limit");
|
|
||||||
actualChanges.put("limit", newValue);
|
|
||||||
this.limitAmount = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("machineRent", this.machineRent)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("machineRent");
|
|
||||||
actualChanges.put("machineRent", newValue);
|
|
||||||
this.machineRent = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
stringChange(command, actualChanges, "agentSecurityBranch", this.agentSecurityBranch,
|
|
||||||
v -> this.agentSecurityBranch = StringUtils.defaultIfEmpty(v, null));
|
|
||||||
stringChange(command, actualChanges, "agentSecurityAccountType", this.agentSecurityAccountType,
|
|
||||||
v -> this.agentSecurityAccountType = StringUtils.defaultIfEmpty(v, null));
|
|
||||||
stringChange(command, actualChanges, "agentSecurityAccountNo", this.agentSecurityAccountNo,
|
|
||||||
v -> this.agentSecurityAccountNo = StringUtils.defaultIfEmpty(v, null));
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("otherCommission", this.otherCommission)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("otherCommission");
|
|
||||||
actualChanges.put("otherCommission", newValue);
|
|
||||||
this.otherCommission = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("agentDeposit", this.agentDeposit)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("agentDeposit");
|
|
||||||
actualChanges.put("agentDeposit", newValue);
|
|
||||||
this.agentDeposit = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("festivalAdvance", this.festivalAdvance)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("festivalAdvance");
|
|
||||||
actualChanges.put("festivalAdvance", newValue);
|
|
||||||
this.festivalAdvance = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("otherDeduction", this.otherDeduction)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("otherDeduction");
|
|
||||||
actualChanges.put("otherDeduction", newValue);
|
|
||||||
this.otherDeduction = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return actualChanges;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
|
|
||||||
final String existingValue, final java.util.function.Consumer<String> setter) {
|
|
||||||
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
|
|
||||||
final String newValue = command.stringValueOfParameterNamed(paramName);
|
|
||||||
actualChanges.put(paramName, newValue);
|
|
||||||
setter.accept(newValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-83
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.domain;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import lombok.Getter;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single guarantor recorded against an {@link Agent} (or, reused with the same shape, against a
|
|
||||||
* SubAgent). Always created/replaced as part of its parent agent's own create/update command - it has
|
|
||||||
* no independent API of its own.
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Entity
|
|
||||||
@Table(name = "m_dds_agent_guarantor")
|
|
||||||
public class AgentGuarantor extends AbstractPersistableCustom<Long> {
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "agent_id", nullable = false)
|
|
||||||
private Agent agent;
|
|
||||||
|
|
||||||
@Column(name = "client_id", nullable = false)
|
|
||||||
private Long clientId;
|
|
||||||
|
|
||||||
@Column(name = "guarantor_name", length = 100, nullable = false)
|
|
||||||
private String guarantorName;
|
|
||||||
|
|
||||||
@Column(name = "address1", length = 200, nullable = false)
|
|
||||||
private String address1;
|
|
||||||
|
|
||||||
@Column(name = "address2", length = 200)
|
|
||||||
private String address2;
|
|
||||||
|
|
||||||
@Column(name = "city", length = 100, nullable = false)
|
|
||||||
private String city;
|
|
||||||
|
|
||||||
@Column(name = "pin_code", length = 20, nullable = false)
|
|
||||||
private String pinCode;
|
|
||||||
|
|
||||||
@Column(name = "telephone", length = 20)
|
|
||||||
private String telephone;
|
|
||||||
|
|
||||||
protected AgentGuarantor() {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
public AgentGuarantor(final Long clientId, final String guarantorName, final String address1, final String address2,
|
|
||||||
final String city, final String pinCode, final String telephone) {
|
|
||||||
this.clientId = clientId;
|
|
||||||
this.guarantorName = guarantorName;
|
|
||||||
this.address1 = address1;
|
|
||||||
this.address2 = StringUtils.defaultIfEmpty(address2, null);
|
|
||||||
this.city = city;
|
|
||||||
this.pinCode = pinCode;
|
|
||||||
this.telephone = StringUtils.defaultIfEmpty(telephone, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
void setAgent(final Agent agent) {
|
|
||||||
this.agent = agent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-66
@@ -1,66 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.domain;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import lombok.Getter;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row of an Agent's time-bound collection-limit history (From Date / To Date / Limit) - an
|
|
||||||
* Agent's authorized collection limit can change over time and every change is kept, not overwritten.
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Entity
|
|
||||||
@Table(name = "m_dds_agent_limit")
|
|
||||||
public class AgentLimit extends AbstractPersistableCustom<Long> {
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "agent_id", nullable = false)
|
|
||||||
private Agent agent;
|
|
||||||
|
|
||||||
@Column(name = "from_date", nullable = false)
|
|
||||||
private LocalDate fromDate;
|
|
||||||
|
|
||||||
@Column(name = "to_date", nullable = false)
|
|
||||||
private LocalDate toDate;
|
|
||||||
|
|
||||||
@Column(name = "limit_amount", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal limitAmount;
|
|
||||||
|
|
||||||
protected AgentLimit() {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
public AgentLimit(final LocalDate fromDate, final LocalDate toDate, final BigDecimal limitAmount) {
|
|
||||||
this.fromDate = fromDate;
|
|
||||||
this.toDate = toDate;
|
|
||||||
this.limitAmount = limitAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setAgent(final Agent agent) {
|
|
||||||
this.agent = agent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-40
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.commissionslab.domain;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import lombok.Getter;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row of the agent commission chart: an ACCOUNT slab (commission percentage on collection
|
|
||||||
* volume) or a COMMISSION slab (the override a senior agent earns on a sub-agent's commission).
|
|
||||||
* Append-only reference data, like the legacy screen it replaces - there is deliberately no update or
|
|
||||||
* delete API; a superseded slab range is retired by adding a new row, not editing history.
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Entity
|
|
||||||
@Table(name = "m_dds_commission_slab")
|
|
||||||
public class CommissionSlab extends AbstractPersistableCustom<Long> {
|
|
||||||
|
|
||||||
@Column(name = "slab_type", length = 20, nullable = false)
|
|
||||||
private String slabType;
|
|
||||||
|
|
||||||
@Column(name = "slab_from", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal slabFrom;
|
|
||||||
|
|
||||||
@Column(name = "slab_to", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal slabTo;
|
|
||||||
|
|
||||||
@Column(name = "commission_percent", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal commissionPercent;
|
|
||||||
|
|
||||||
protected CommissionSlab() {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommissionSlab(final String slabType, final BigDecimal slabFrom, final BigDecimal slabTo, final BigDecimal commissionPercent) {
|
|
||||||
this.slabType = slabType;
|
|
||||||
this.slabFrom = slabFrom;
|
|
||||||
this.slabTo = slabTo;
|
|
||||||
this.commissionPercent = commissionPercent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+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;
|
||||||
|
|||||||
+43
@@ -84,6 +84,49 @@ public class AmlTransactionRule {
|
|||||||
@Column(name = "is_active", nullable = false)
|
@Column(name = "is_active", nullable = false)
|
||||||
private boolean active;
|
private boolean active;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CREDIT or DEBIT for a rule written about one direction only; null where either way qualifies.
|
||||||
|
* A rule named for withdrawals must not fire on a deposit.
|
||||||
|
*/
|
||||||
|
@Column(name = "direction", length = 10)
|
||||||
|
private String direction;
|
||||||
|
|
||||||
|
/** True where only physical cash qualifies — a cash-reporting rule is not about transfers. */
|
||||||
|
@Column(name = "cash_only", nullable = false)
|
||||||
|
private boolean cashOnly;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many transactions must make up the window before a pattern rule fires. Structuring is
|
||||||
|
* many small transactions; without this floor a single large one satisfies the window total and
|
||||||
|
* reads as a pattern it is not.
|
||||||
|
*/
|
||||||
|
@Column(name = "min_transaction_count")
|
||||||
|
private Integer minTransactionCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which customers a rule is about, where the rule is about the customer rather than the
|
||||||
|
* transaction: PEP, CHARITABLE_ORG, HIGH_RISK_BUSINESS, NEW_ACCOUNT. Null means the rule is
|
||||||
|
* about the transaction alone and so applies to everyone.
|
||||||
|
*/
|
||||||
|
@Column(name = "subject_scope", length = 30)
|
||||||
|
private String subjectScope;
|
||||||
|
|
||||||
|
public String getSubjectScope() {
|
||||||
|
return subjectScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDirection() {
|
||||||
|
return direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isCashOnly() {
|
||||||
|
return cashOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getMinTransactionCount() {
|
||||||
|
return minTransactionCount;
|
||||||
|
}
|
||||||
|
|
||||||
protected AmlTransactionRule() {}
|
protected AmlTransactionRule() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+9
@@ -162,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";
|
||||||
|
|||||||
+113
-15
@@ -355,7 +355,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
this.dinVerified = false;
|
this.dinVerified = false;
|
||||||
this.isException = false;
|
this.isException = false;
|
||||||
this.exceptionResolved = false;
|
this.exceptionResolved = false;
|
||||||
this.initiatedOn = DateUtils.getLocalDateOfTenant();
|
this.initiatedOn = DateUtils.getBusinessLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
|
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
|
||||||
@@ -484,6 +484,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
actualChanges.put(riskParam, newValue);
|
actualChanges.put(riskParam, newValue);
|
||||||
recordOverwrite(riskParam, this.riskCategory);
|
recordOverwrite(riskParam, this.riskCategory);
|
||||||
this.riskCategory = newValue;
|
this.riskCategory = newValue;
|
||||||
|
|
||||||
|
// The re-KYC cycle is a property of the risk category, so moving the category moves the
|
||||||
|
// due date with it — otherwise a customer raised to High keeps a Low customer's ten-year
|
||||||
|
// renewal and is never called back in for the two-yearly review the rating requires.
|
||||||
|
final LocalDate newExpiry = recalculateExpiryForRiskCategory();
|
||||||
|
if (newExpiry != null) {
|
||||||
|
actualChanges.put("expiryDate", newExpiry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remarks is the audit justification itself, so a change to it is deliberately not recorded
|
// remarks is the audit justification itself, so a change to it is deliberately not recorded
|
||||||
@@ -554,10 +562,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
v -> this.form15Applicable = v);
|
v -> this.form15Applicable = v);
|
||||||
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
||||||
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
||||||
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
|
// The four exception columns are derived from whether the identity checks have passed, not
|
||||||
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
|
// data an officer types, so they are updated without recording an overwrite. A verified
|
||||||
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
|
// record demands remarks for any field it already held (see
|
||||||
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
|
// 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, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
||||||
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
||||||
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
||||||
@@ -612,6 +631,44 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
* populated is the record being completed rather than altered, so it is deliberately not
|
* populated is the record being completed rather than altered, so it is deliberately not
|
||||||
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* Derived-field variants of the update helpers above.
|
||||||
|
*
|
||||||
|
* Identical, minus the recordOverwrite call: these columns are computed from whether the
|
||||||
|
* identity checks have passed rather than entered by an officer, so replacing one is not the
|
||||||
|
* kind of change a verified record demands a justification for. They still report into
|
||||||
|
* `changes`, so the update is persisted and audited like any other.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private void updateDerivedBooleanField(final JsonCommand command, final String param, final Boolean current,
|
||||||
|
final Map<String, Object> changes, final java.util.function.Consumer<Boolean> setter) {
|
||||||
|
if (command.parameterExists(param)) {
|
||||||
|
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
||||||
|
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
||||||
|
changes.put(param, newValue);
|
||||||
|
setter.accept(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDerivedStringField(final JsonCommand command, final String param, final String current,
|
||||||
|
final Map<String, Object> changes, final java.util.function.Consumer<String> setter) {
|
||||||
|
if (command.isChangeInStringParameterNamed(param, current)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed(param);
|
||||||
|
changes.put(param, newValue);
|
||||||
|
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDerivedDateField(final JsonCommand command, final String param, final LocalDate current,
|
||||||
|
final Map<String, Object> changes, final java.util.function.Consumer<LocalDate> setter) {
|
||||||
|
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
||||||
|
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
||||||
|
changes.put(param, newValue);
|
||||||
|
setter.accept(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void recordOverwrite(final String param, final Object currentValue) {
|
private void recordOverwrite(final String param, final Object currentValue) {
|
||||||
final boolean wasPopulated = currentValue instanceof String text
|
final boolean wasPopulated = currentValue instanceof String text
|
||||||
? StringUtils.isNotBlank(text)
|
? StringUtils.isNotBlank(text)
|
||||||
@@ -662,13 +719,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);
|
||||||
@@ -677,17 +734,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);
|
||||||
@@ -713,28 +798,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;
|
||||||
}
|
}
|
||||||
@@ -755,7 +840,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;
|
||||||
@@ -798,6 +893,9 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
*/
|
*/
|
||||||
public void escalateRiskCategory(Integer newCategory) {
|
public void escalateRiskCategory(Integer newCategory) {
|
||||||
this.riskCategory = newCategory;
|
this.riskCategory = newCategory;
|
||||||
|
// An auto-escalation moves the rating for the same reason an officer would, so it moves the
|
||||||
|
// re-KYC due date on the same terms — see recalculateExpiryForRiskCategory().
|
||||||
|
recalculateExpiryForRiskCategory();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getPanNumber() {
|
public String getPanNumber() {
|
||||||
|
|||||||
+13
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.client.domain;
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@@ -48,6 +49,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
|
|||||||
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
||||||
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
|
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KYC records whose periodic re-KYC due date has passed, oldest first.
|
||||||
|
*
|
||||||
|
* <p>Only records that carry an expiry are considered: an expiry is set when the KYC is first
|
||||||
|
* verified, so one without a date has never been verified and has no renewal cycle to lapse.
|
||||||
|
* A rejected record is excluded for the same reason — it is not a lapsed KYC, it is one that
|
||||||
|
* never took effect, and restricting an account over it would punish the wrong thing.
|
||||||
|
*/
|
||||||
|
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate <= :asOn "
|
||||||
|
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
|
||||||
|
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
|
||||||
|
|
||||||
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
||||||
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
||||||
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
||||||
|
|||||||
+35
-1
@@ -93,6 +93,26 @@ 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() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,8 +124,9 @@ public class ClientRiskRule {
|
|||||||
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
|
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
|
||||||
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
|
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
|
||||||
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
|
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
|
||||||
final String dormantFlag) {
|
final String dormantFlag, final String ruleKind) {
|
||||||
final ClientRiskRule rule = new ClientRiskRule();
|
final ClientRiskRule rule = new ClientRiskRule();
|
||||||
|
rule.ruleKind = ruleKind;
|
||||||
rule.ruleName = ruleName;
|
rule.ruleName = ruleName;
|
||||||
rule.reason = reason;
|
rule.reason = reason;
|
||||||
rule.riskCategory = riskCategory;
|
rule.riskCategory = riskCategory;
|
||||||
@@ -137,6 +158,19 @@ public class ClientRiskRule {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getRuleKind() {
|
||||||
|
return ruleKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this rule may take part in onboarding risk categorisation. Rows predating the
|
||||||
|
* rule_kind column are treated as KYC, matching how they behaved before AML rules were seeded
|
||||||
|
* into the same table.
|
||||||
|
*/
|
||||||
|
public boolean isKycRule() {
|
||||||
|
return !KIND_AML.equalsIgnoreCase(this.ruleKind);
|
||||||
|
}
|
||||||
|
|
||||||
public String getRuleName() {
|
public String getRuleName() {
|
||||||
return ruleName;
|
return ruleName;
|
||||||
}
|
}
|
||||||
|
|||||||
+106
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
-50
@@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.batch.data;
|
|
||||||
|
|
||||||
import java.io.Serial;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Collection;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Builder(toBuilder = true)
|
|
||||||
public final class BatchRunData implements Serializable {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private final Long id;
|
|
||||||
private final String jobType;
|
|
||||||
private final String branchScope;
|
|
||||||
private final LocalDate jobDate;
|
|
||||||
private final Long agentFrom;
|
|
||||||
private final Long agentTo;
|
|
||||||
private final Long partyFrom;
|
|
||||||
private final Long partyTo;
|
|
||||||
private final String status;
|
|
||||||
private final Integer totalRows;
|
|
||||||
private final BigDecimal totalAmount;
|
|
||||||
private final LocalDate generatedOnDate;
|
|
||||||
private final LocalDate postedOnDate;
|
|
||||||
private final Collection<BatchRunRowData> rows;
|
|
||||||
}
|
|
||||||
-122
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.batch.domain;
|
|
||||||
|
|
||||||
import jakarta.persistence.CascadeType;
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.FetchType;
|
|
||||||
import jakarta.persistence.OneToMany;
|
|
||||||
import jakarta.persistence.OrderBy;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import lombok.Getter;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One Calculate-then-Post batch run - the deliberate two-step control from the legacy Interest Part
|
|
||||||
* menu (Interest Calculation -> Interest Posting, OD Interest Calculation -> OD Interest Posting,
|
|
||||||
* Commission Calculation -> Commission Posting): a run is generated (rows computed and saved as a
|
|
||||||
* preview, nothing else changes), then posted (a separate, explicit step that applies exactly the
|
|
||||||
* rows that were previewed - never a fresh recalculation, so what gets applied is always what was
|
|
||||||
* seen).
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Entity
|
|
||||||
@Table(name = "m_dds_batch_run")
|
|
||||||
public class BatchRun extends AbstractPersistableCustom<Long> {
|
|
||||||
|
|
||||||
@Column(name = "job_type", length = 40, nullable = false)
|
|
||||||
private String jobType;
|
|
||||||
|
|
||||||
@Column(name = "branch_scope", length = 20, nullable = false)
|
|
||||||
private String branchScope;
|
|
||||||
|
|
||||||
@Column(name = "job_date", nullable = false)
|
|
||||||
private LocalDate jobDate;
|
|
||||||
|
|
||||||
@Column(name = "agent_from", nullable = false)
|
|
||||||
private Long agentFrom;
|
|
||||||
|
|
||||||
@Column(name = "agent_to", nullable = false)
|
|
||||||
private Long agentTo;
|
|
||||||
|
|
||||||
@Column(name = "party_from")
|
|
||||||
private Long partyFrom;
|
|
||||||
|
|
||||||
@Column(name = "party_to")
|
|
||||||
private Long partyTo;
|
|
||||||
|
|
||||||
@Column(name = "status", length = 20, nullable = false)
|
|
||||||
private String status;
|
|
||||||
|
|
||||||
@Column(name = "total_rows", nullable = false)
|
|
||||||
private Integer totalRows;
|
|
||||||
|
|
||||||
@Column(name = "total_amount", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal totalAmount;
|
|
||||||
|
|
||||||
@Column(name = "generated_on_date", nullable = false)
|
|
||||||
private LocalDate generatedOnDate;
|
|
||||||
|
|
||||||
@Column(name = "posted_on_date")
|
|
||||||
private LocalDate postedOnDate;
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "batchRun", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
|
||||||
@OrderBy("id")
|
|
||||||
private List<BatchRunRow> rows = new ArrayList<>();
|
|
||||||
|
|
||||||
protected BatchRun() {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
public BatchRun(final String jobType, final String branchScope, final LocalDate jobDate, final Long agentFrom,
|
|
||||||
final Long agentTo, final Long partyFrom, final Long partyTo, final LocalDate generatedOnDate) {
|
|
||||||
this.jobType = jobType;
|
|
||||||
this.branchScope = branchScope;
|
|
||||||
this.jobDate = jobDate;
|
|
||||||
this.agentFrom = agentFrom;
|
|
||||||
this.agentTo = agentTo;
|
|
||||||
this.partyFrom = partyFrom;
|
|
||||||
this.partyTo = partyTo;
|
|
||||||
this.status = "CALCULATED";
|
|
||||||
this.totalRows = 0;
|
|
||||||
this.totalAmount = BigDecimal.ZERO;
|
|
||||||
this.generatedOnDate = generatedOnDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void addRow(final BatchRunRow row) {
|
|
||||||
row.setBatchRun(this);
|
|
||||||
this.rows.add(row);
|
|
||||||
this.totalRows = this.rows.size();
|
|
||||||
this.totalAmount = this.totalAmount.add(row.getAmount());
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isPosted() {
|
|
||||||
return "POSTED".equals(this.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void markPosted(final LocalDate postedOnDate) {
|
|
||||||
this.status = "POSTED";
|
|
||||||
this.postedOnDate = postedOnDate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.batch.domain;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import lombok.Getter;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One previewed/posted line of a {@link BatchRun}: for an interest job, one Party's accrued interest;
|
|
||||||
* for a commission job, one Agent's commission on their collected volume (partyId is null there).
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Entity
|
|
||||||
@Table(name = "m_dds_batch_run_row")
|
|
||||||
public class BatchRunRow extends AbstractPersistableCustom<Long> {
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "batch_run_id", nullable = false)
|
|
||||||
private BatchRun batchRun;
|
|
||||||
|
|
||||||
@Column(name = "party_id")
|
|
||||||
private Long partyId;
|
|
||||||
|
|
||||||
@Column(name = "agent_id", nullable = false)
|
|
||||||
private Long agentId;
|
|
||||||
|
|
||||||
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal amount;
|
|
||||||
|
|
||||||
protected BatchRunRow() {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
public BatchRunRow(final Long partyId, final Long agentId, final BigDecimal amount) {
|
|
||||||
this.partyId = partyId;
|
|
||||||
this.agentId = agentId;
|
|
||||||
this.amount = amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setBatchRun(final BatchRun batchRun) {
|
|
||||||
this.batchRun = batchRun;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-107
@@ -1,107 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.party.data;
|
|
||||||
|
|
||||||
import java.io.Serial;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Collection;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable data object representing a DDS Party (customer scheme account), including its nominees.
|
|
||||||
* Built via the Lombok builder rather than a long positional constructor - the field count here makes
|
|
||||||
* positional args unreadable and error-prone to keep in sync.
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Builder(toBuilder = true)
|
|
||||||
public final class PartyData implements Serializable {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private final Long id;
|
|
||||||
private final Long clientId;
|
|
||||||
private final String clientName;
|
|
||||||
private final Long agentId;
|
|
||||||
private final String agentCode;
|
|
||||||
private final String agentName;
|
|
||||||
private final Long savingsAccountId;
|
|
||||||
private final String subType;
|
|
||||||
private final String partyCode;
|
|
||||||
private final String lastAccount;
|
|
||||||
private final Boolean nominal;
|
|
||||||
private final String aadharNo;
|
|
||||||
private final String partyName;
|
|
||||||
private final String partyName1;
|
|
||||||
private final String address1;
|
|
||||||
private final String address2;
|
|
||||||
private final String address3;
|
|
||||||
private final String city;
|
|
||||||
private final String pincode;
|
|
||||||
private final String gender;
|
|
||||||
private final String panCardNo;
|
|
||||||
private final String telephone;
|
|
||||||
private final String mobileNoSms;
|
|
||||||
private final String operation;
|
|
||||||
private final LocalDate birthDate;
|
|
||||||
private final String occupation;
|
|
||||||
private final String risk;
|
|
||||||
private final String ckycId;
|
|
||||||
private final String adultMinor;
|
|
||||||
private final LocalDate minorDate;
|
|
||||||
private final Long guardianClientId;
|
|
||||||
private final String minAccountOpt;
|
|
||||||
private final String relation;
|
|
||||||
private final LocalDate openingDate;
|
|
||||||
private final Integer month;
|
|
||||||
private final LocalDate maturityDate;
|
|
||||||
private final LocalDate reopenDate;
|
|
||||||
private final BigDecimal depositAmount;
|
|
||||||
private final BigDecimal dailyDepositAmount;
|
|
||||||
private final Integer days;
|
|
||||||
private final String ledgerNo;
|
|
||||||
private final BigDecimal maturityAmount;
|
|
||||||
private final BigDecimal interestRate;
|
|
||||||
private final String withdrawType;
|
|
||||||
private final Boolean od1Flag;
|
|
||||||
private final Boolean od2Flag;
|
|
||||||
private final Boolean lien;
|
|
||||||
private final LocalDate payDate;
|
|
||||||
private final BigDecimal paymentAmount;
|
|
||||||
private final BigDecimal currentBalance;
|
|
||||||
private final BigDecimal openingBalance;
|
|
||||||
private final Boolean isSmsActive;
|
|
||||||
private final Boolean isEmailActive;
|
|
||||||
private final BigDecimal interestCalc;
|
|
||||||
private final LocalDate interestDate;
|
|
||||||
private final String spDescription;
|
|
||||||
private final String freezeCode;
|
|
||||||
private final String advCode;
|
|
||||||
private final String creditToBranch;
|
|
||||||
private final String creditToAccountType;
|
|
||||||
private final String creditToAccount;
|
|
||||||
private final Boolean nomineeApplicable;
|
|
||||||
private final String nomineeSplitMode;
|
|
||||||
private final String status;
|
|
||||||
private final LocalDate closeDate;
|
|
||||||
private final Collection<PartyNomineeData> nominees;
|
|
||||||
}
|
|
||||||
-59
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.party.data;
|
|
||||||
|
|
||||||
import java.io.Serial;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
public final class PartyNomineeData implements Serializable {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private final Long id;
|
|
||||||
private final LocalDate nominationDate;
|
|
||||||
private final String name;
|
|
||||||
private final String mobileNo;
|
|
||||||
private final LocalDate birthDate;
|
|
||||||
private final Boolean minor;
|
|
||||||
private final String relation;
|
|
||||||
private final String gender;
|
|
||||||
private final String guardianName;
|
|
||||||
|
|
||||||
public static PartyNomineeData instance(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
|
|
||||||
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName) {
|
|
||||||
return new PartyNomineeData(id, nominationDate, name, mobileNo, birthDate, minor, relation, gender, guardianName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private PartyNomineeData(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
|
|
||||||
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName) {
|
|
||||||
this.id = id;
|
|
||||||
this.nominationDate = nominationDate;
|
|
||||||
this.name = name;
|
|
||||||
this.mobileNo = mobileNo;
|
|
||||||
this.birthDate = birthDate;
|
|
||||||
this.minor = minor;
|
|
||||||
this.relation = relation;
|
|
||||||
this.gender = gender;
|
|
||||||
this.guardianName = guardianName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-569
@@ -1,569 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.party.domain;
|
|
||||||
|
|
||||||
import jakarta.persistence.CascadeType;
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.FetchType;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.OneToMany;
|
|
||||||
import jakarta.persistence.OrderBy;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import jakarta.persistence.UniqueConstraint;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
import lombok.Getter;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
|
||||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A DDS Party - the customer's daily-deposit scheme account, linked to the Agent who collects for it.
|
|
||||||
* Carries the scheme's KYC, terms and two-stage overdue flags, plus a nominee list.
|
|
||||||
*
|
|
||||||
* The underlying RD (recurring deposit) account this scheme conceptually reuses is not wired up yet -
|
|
||||||
* {@code savingsAccountId} is a nullable placeholder for that future linkage (see the DDS architecture
|
|
||||||
* decision to reuse the RD engine for the deposit half). Until then the scheme/balance fields below
|
|
||||||
* are this record's own, not mirrored from any m_savings_account row.
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Entity
|
|
||||||
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
|
|
||||||
public class Party extends AbstractPersistableCustom<Long> {
|
|
||||||
|
|
||||||
/** DDS accounts carry a flat interest rate unless the officer explicitly overrides it at creation. */
|
|
||||||
private static final BigDecimal DEFAULT_INTEREST_RATE = new BigDecimal("2");
|
|
||||||
|
|
||||||
public static final String STATUS_ACTIVE = "ACTIVE";
|
|
||||||
public static final String STATUS_CLOSED = "CLOSED";
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "client_id", nullable = false)
|
|
||||||
private Client client;
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "agent_id", nullable = false)
|
|
||||||
private Agent agent;
|
|
||||||
|
|
||||||
@Column(name = "savings_account_id")
|
|
||||||
private Long savingsAccountId;
|
|
||||||
|
|
||||||
@Column(name = "sub_type", length = 60, nullable = false)
|
|
||||||
private String subType;
|
|
||||||
|
|
||||||
@Column(name = "party_code", length = 20, nullable = false)
|
|
||||||
private String partyCode;
|
|
||||||
|
|
||||||
@Column(name = "last_account", length = 50)
|
|
||||||
private String lastAccount;
|
|
||||||
|
|
||||||
@Column(name = "nominal", nullable = false)
|
|
||||||
private boolean nominal;
|
|
||||||
|
|
||||||
@Column(name = "aadhar_no", length = 20)
|
|
||||||
private String aadharNo;
|
|
||||||
|
|
||||||
@Column(name = "party_name", length = 100, nullable = false)
|
|
||||||
private String partyName;
|
|
||||||
|
|
||||||
@Column(name = "party_name1", length = 100)
|
|
||||||
private String partyName1;
|
|
||||||
|
|
||||||
@Column(name = "address1", length = 200, nullable = false)
|
|
||||||
private String address1;
|
|
||||||
|
|
||||||
@Column(name = "address2", length = 200)
|
|
||||||
private String address2;
|
|
||||||
|
|
||||||
@Column(name = "address3", length = 200)
|
|
||||||
private String address3;
|
|
||||||
|
|
||||||
@Column(name = "city", length = 100, nullable = false)
|
|
||||||
private String city;
|
|
||||||
|
|
||||||
@Column(name = "pincode", length = 20, nullable = false)
|
|
||||||
private String pincode;
|
|
||||||
|
|
||||||
@Column(name = "gender", length = 20)
|
|
||||||
private String gender;
|
|
||||||
|
|
||||||
@Column(name = "pan_card_no", length = 20)
|
|
||||||
private String panCardNo;
|
|
||||||
|
|
||||||
@Column(name = "telephone", length = 20)
|
|
||||||
private String telephone;
|
|
||||||
|
|
||||||
@Column(name = "mobile_no_sms", length = 20, nullable = false)
|
|
||||||
private String mobileNoSms;
|
|
||||||
|
|
||||||
@Column(name = "operation", length = 20, nullable = false)
|
|
||||||
private String operation;
|
|
||||||
|
|
||||||
@Column(name = "birth_date")
|
|
||||||
private LocalDate birthDate;
|
|
||||||
|
|
||||||
@Column(name = "occupation", length = 40)
|
|
||||||
private String occupation;
|
|
||||||
|
|
||||||
@Column(name = "risk", length = 20)
|
|
||||||
private String risk;
|
|
||||||
|
|
||||||
@Column(name = "ckyc_id", length = 40)
|
|
||||||
private String ckycId;
|
|
||||||
|
|
||||||
@Column(name = "adult_minor", length = 20, nullable = false)
|
|
||||||
private String adultMinor;
|
|
||||||
|
|
||||||
@Column(name = "minor_date")
|
|
||||||
private LocalDate minorDate;
|
|
||||||
|
|
||||||
@Column(name = "guardian_client_id")
|
|
||||||
private Long guardianClientId;
|
|
||||||
|
|
||||||
@Column(name = "min_account_opt", length = 40)
|
|
||||||
private String minAccountOpt;
|
|
||||||
|
|
||||||
@Column(name = "relation", length = 40)
|
|
||||||
private String relation;
|
|
||||||
|
|
||||||
@Column(name = "opening_date", nullable = false)
|
|
||||||
private LocalDate openingDate;
|
|
||||||
|
|
||||||
@Column(name = "months")
|
|
||||||
private Integer month;
|
|
||||||
|
|
||||||
@Column(name = "maturity_date")
|
|
||||||
private LocalDate maturityDate;
|
|
||||||
|
|
||||||
@Column(name = "reopen_date")
|
|
||||||
private LocalDate reopenDate;
|
|
||||||
|
|
||||||
@Column(name = "deposit_amount", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal depositAmount;
|
|
||||||
|
|
||||||
@Column(name = "daily_deposit_amount", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal dailyDepositAmount;
|
|
||||||
|
|
||||||
@Column(name = "days", nullable = false)
|
|
||||||
private Integer days;
|
|
||||||
|
|
||||||
@Column(name = "ledger_no", length = 40)
|
|
||||||
private String ledgerNo;
|
|
||||||
|
|
||||||
@Column(name = "maturity_amount", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal maturityAmount;
|
|
||||||
|
|
||||||
@Column(name = "interest_rate", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal interestRate;
|
|
||||||
|
|
||||||
@Column(name = "withdraw_type", length = 20)
|
|
||||||
private String withdrawType;
|
|
||||||
|
|
||||||
@Column(name = "od1_flag", nullable = false)
|
|
||||||
private boolean od1Flag;
|
|
||||||
|
|
||||||
@Column(name = "od2_flag", nullable = false)
|
|
||||||
private boolean od2Flag;
|
|
||||||
|
|
||||||
@Column(name = "lien", nullable = false)
|
|
||||||
private boolean lien;
|
|
||||||
|
|
||||||
@Column(name = "pay_date")
|
|
||||||
private LocalDate payDate;
|
|
||||||
|
|
||||||
@Column(name = "payment_amount", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal paymentAmount;
|
|
||||||
|
|
||||||
@Column(name = "current_balance", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal currentBalance;
|
|
||||||
|
|
||||||
@Column(name = "opening_balance", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal openingBalance;
|
|
||||||
|
|
||||||
@Column(name = "is_sms_active", nullable = false)
|
|
||||||
private boolean isSmsActive;
|
|
||||||
|
|
||||||
@Column(name = "is_email_active", nullable = false)
|
|
||||||
private boolean isEmailActive;
|
|
||||||
|
|
||||||
@Column(name = "interest_calc", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal interestCalc;
|
|
||||||
|
|
||||||
@Column(name = "interest_date")
|
|
||||||
private LocalDate interestDate;
|
|
||||||
|
|
||||||
@Column(name = "sp_description", length = 500)
|
|
||||||
private String spDescription;
|
|
||||||
|
|
||||||
@Column(name = "freeze_code", length = 20, nullable = false)
|
|
||||||
private String freezeCode;
|
|
||||||
|
|
||||||
@Column(name = "adv_code", length = 40, nullable = false)
|
|
||||||
private String advCode;
|
|
||||||
|
|
||||||
@Column(name = "credit_to_branch", length = 100)
|
|
||||||
private String creditToBranch;
|
|
||||||
|
|
||||||
@Column(name = "credit_to_account_type", length = 100)
|
|
||||||
private String creditToAccountType;
|
|
||||||
|
|
||||||
@Column(name = "credit_to_account", length = 50)
|
|
||||||
private String creditToAccount;
|
|
||||||
|
|
||||||
@Column(name = "nominee_applicable", nullable = false)
|
|
||||||
private boolean nomineeApplicable;
|
|
||||||
|
|
||||||
@Column(name = "nominee_split_mode", length = 20)
|
|
||||||
private String nomineeSplitMode;
|
|
||||||
|
|
||||||
@Column(name = "status", length = 20, nullable = false)
|
|
||||||
private String status;
|
|
||||||
|
|
||||||
@Column(name = "close_date")
|
|
||||||
private LocalDate closeDate;
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
|
||||||
@OrderBy("id")
|
|
||||||
private List<PartyNominee> nominees = new ArrayList<>();
|
|
||||||
|
|
||||||
protected Party() {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
private Party(final Client client, final Agent agent, final String partyCode, final JsonCommand command) {
|
|
||||||
this.client = client;
|
|
||||||
this.agent = agent;
|
|
||||||
this.partyCode = partyCode;
|
|
||||||
this.subType = command.stringValueOfParameterNamed("subType");
|
|
||||||
this.lastAccount = command.stringValueOfParameterNamedAllowingNull("lastAccount");
|
|
||||||
this.nominal = command.booleanPrimitiveValueOfParameterNamed("nominal");
|
|
||||||
this.aadharNo = command.stringValueOfParameterNamedAllowingNull("aadharNo");
|
|
||||||
this.partyName = command.stringValueOfParameterNamed("partyName");
|
|
||||||
this.partyName1 = command.stringValueOfParameterNamedAllowingNull("partyName1");
|
|
||||||
this.address1 = command.stringValueOfParameterNamed("address1");
|
|
||||||
this.address2 = command.stringValueOfParameterNamedAllowingNull("address2");
|
|
||||||
this.address3 = command.stringValueOfParameterNamedAllowingNull("address3");
|
|
||||||
this.city = command.stringValueOfParameterNamed("city");
|
|
||||||
this.pincode = command.stringValueOfParameterNamed("pincode");
|
|
||||||
this.gender = command.stringValueOfParameterNamedAllowingNull("gender");
|
|
||||||
this.panCardNo = command.stringValueOfParameterNamedAllowingNull("panCardNo");
|
|
||||||
this.telephone = command.stringValueOfParameterNamedAllowingNull("telephone");
|
|
||||||
this.mobileNoSms = command.stringValueOfParameterNamed("mobileNoSms");
|
|
||||||
this.operation = command.stringValueOfParameterNamed("operation");
|
|
||||||
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
|
|
||||||
this.occupation = command.stringValueOfParameterNamedAllowingNull("occupation");
|
|
||||||
this.risk = command.stringValueOfParameterNamedAllowingNull("risk");
|
|
||||||
this.ckycId = command.stringValueOfParameterNamedAllowingNull("ckycId");
|
|
||||||
this.adultMinor = command.stringValueOfParameterNamed("adultMinor");
|
|
||||||
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
|
|
||||||
this.guardianClientId = command.longValueOfParameterNamed("guardianClientId");
|
|
||||||
this.minAccountOpt = command.stringValueOfParameterNamedAllowingNull("minAccountOpt");
|
|
||||||
this.relation = command.stringValueOfParameterNamedAllowingNull("relation");
|
|
||||||
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
|
|
||||||
this.month = command.integerValueOfParameterNamedDefaultToNullIfZero("month");
|
|
||||||
this.maturityDate = command.localDateValueOfParameterNamed("maturityDate");
|
|
||||||
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
|
|
||||||
this.depositAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("depositAmount"));
|
|
||||||
this.dailyDepositAmount = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
|
|
||||||
this.days = command.integerValueOfParameterNamed("days");
|
|
||||||
this.ledgerNo = command.stringValueOfParameterNamedAllowingNull("ledgerNo");
|
|
||||||
this.maturityAmount = BigDecimal.ZERO;
|
|
||||||
this.interestRate = command.parameterExists("interestRate")
|
|
||||||
? command.bigDecimalValueOfParameterNamed("interestRate")
|
|
||||||
: DEFAULT_INTEREST_RATE;
|
|
||||||
this.withdrawType = command.stringValueOfParameterNamedAllowingNull("withdrawType");
|
|
||||||
this.od1Flag = false;
|
|
||||||
this.od2Flag = false;
|
|
||||||
this.lien = command.booleanPrimitiveValueOfParameterNamed("lien");
|
|
||||||
this.payDate = command.localDateValueOfParameterNamed("payDate");
|
|
||||||
this.paymentAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("paymentAmount"));
|
|
||||||
this.openingBalance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("openingBalance"));
|
|
||||||
this.currentBalance = this.openingBalance;
|
|
||||||
this.isSmsActive = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
|
|
||||||
this.isEmailActive = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
|
|
||||||
this.interestCalc = BigDecimal.ZERO;
|
|
||||||
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
|
|
||||||
this.spDescription = command.stringValueOfParameterNamedAllowingNull("spDescription");
|
|
||||||
this.freezeCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("freezeCode"), "Normal");
|
|
||||||
this.advCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("advCode"), "None");
|
|
||||||
this.creditToBranch = command.stringValueOfParameterNamedAllowingNull("creditToBranch");
|
|
||||||
this.creditToAccountType = command.stringValueOfParameterNamedAllowingNull("creditToAccountType");
|
|
||||||
this.creditToAccount = command.stringValueOfParameterNamedAllowingNull("creditToAccount");
|
|
||||||
this.nomineeApplicable = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
|
|
||||||
this.nomineeSplitMode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("nomineeSplitMode"),
|
|
||||||
"Percentage");
|
|
||||||
this.status = STATUS_ACTIVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Party fromJson(final Client client, final Agent agent, final String partyCode, final JsonCommand command) {
|
|
||||||
return new Party(client, agent, partyCode, command);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BigDecimal orZero(final BigDecimal value) {
|
|
||||||
return value == null ? BigDecimal.ZERO : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void addNominee(final PartyNominee nominee) {
|
|
||||||
nominee.setParty(this);
|
|
||||||
this.nominees.add(nominee);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void resetNominees() {
|
|
||||||
this.nominees.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void changeClient(final Client newClient) {
|
|
||||||
this.client = newClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void changeAgent(final Agent newAgent) {
|
|
||||||
this.agent = newAgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Links this Party to an existing real Savings Account, resolved by account number in the write
|
|
||||||
* service. There is no unlink - once a Party's DDS scheme is tied to a real account, it stays
|
|
||||||
* tied.
|
|
||||||
*/
|
|
||||||
public void linkSavingsAccount(final Long savingsAccountId) {
|
|
||||||
this.savingsAccountId = savingsAccountId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal credit(final BigDecimal amount) {
|
|
||||||
this.currentBalance = this.currentBalance.add(amount);
|
|
||||||
return this.currentBalance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal debit(final BigDecimal amount) {
|
|
||||||
this.currentBalance = this.currentBalance.subtract(amount);
|
|
||||||
return this.currentBalance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isClosed() {
|
|
||||||
return STATUS_CLOSED.equals(this.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closes this Party's account as of the given date. The balance must already be zero - a Party
|
|
||||||
* is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money
|
|
||||||
* still owed to it.
|
|
||||||
*/
|
|
||||||
public void close(final LocalDate closeDate) {
|
|
||||||
this.status = STATUS_CLOSED;
|
|
||||||
this.closeDate = closeDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamps the date interest (regular or OD) was last calculated up to, so the next batch run
|
|
||||||
* accrues only the days since. Set on POST, not on calculate/preview.
|
|
||||||
*/
|
|
||||||
public void markInterestCalculatedThrough(final LocalDate date) {
|
|
||||||
this.interestDate = date;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long clientId() {
|
|
||||||
return this.client.getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long agentId() {
|
|
||||||
return this.agent.getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, Object> update(final JsonCommand command) {
|
|
||||||
|
|
||||||
final Map<String, Object> actualChanges = new LinkedHashMap<>(40);
|
|
||||||
|
|
||||||
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
|
|
||||||
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
|
|
||||||
}
|
|
||||||
if (command.isChangeInLongParameterNamed("agentId", this.agent.getId())) {
|
|
||||||
actualChanges.put("agentId", command.longValueOfParameterNamed("agentId"));
|
|
||||||
}
|
|
||||||
|
|
||||||
stringChange(command, actualChanges, "subType", this.subType, v -> this.subType = v);
|
|
||||||
stringChange(command, actualChanges, "lastAccount", this.lastAccount, v -> this.lastAccount = v);
|
|
||||||
|
|
||||||
if (command.isChangeInBooleanParameterNamed("nominal", this.nominal)) {
|
|
||||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nominal");
|
|
||||||
actualChanges.put("nominal", newValue);
|
|
||||||
this.nominal = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
stringChange(command, actualChanges, "aadharNo", this.aadharNo, v -> this.aadharNo = v);
|
|
||||||
stringChange(command, actualChanges, "partyName", this.partyName, v -> this.partyName = v);
|
|
||||||
stringChange(command, actualChanges, "partyName1", this.partyName1, v -> this.partyName1 = v);
|
|
||||||
stringChange(command, actualChanges, "address1", this.address1, v -> this.address1 = v);
|
|
||||||
stringChange(command, actualChanges, "address2", this.address2, v -> this.address2 = v);
|
|
||||||
stringChange(command, actualChanges, "address3", this.address3, v -> this.address3 = v);
|
|
||||||
stringChange(command, actualChanges, "city", this.city, v -> this.city = v);
|
|
||||||
stringChange(command, actualChanges, "pincode", this.pincode, v -> this.pincode = v);
|
|
||||||
stringChange(command, actualChanges, "gender", this.gender, v -> this.gender = v);
|
|
||||||
stringChange(command, actualChanges, "panCardNo", this.panCardNo, v -> this.panCardNo = v);
|
|
||||||
stringChange(command, actualChanges, "telephone", this.telephone, v -> this.telephone = v);
|
|
||||||
stringChange(command, actualChanges, "mobileNoSms", this.mobileNoSms, v -> this.mobileNoSms = v);
|
|
||||||
stringChange(command, actualChanges, "operation", this.operation, v -> this.operation = v);
|
|
||||||
|
|
||||||
if (command.isChangeInDateParameterNamed("birthDate", this.birthDate)) {
|
|
||||||
actualChanges.put("birthDate", command.stringValueOfParameterNamed("birthDate"));
|
|
||||||
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
|
|
||||||
}
|
|
||||||
|
|
||||||
stringChange(command, actualChanges, "occupation", this.occupation, v -> this.occupation = v);
|
|
||||||
stringChange(command, actualChanges, "risk", this.risk, v -> this.risk = v);
|
|
||||||
stringChange(command, actualChanges, "ckycId", this.ckycId, v -> this.ckycId = v);
|
|
||||||
stringChange(command, actualChanges, "adultMinor", this.adultMinor, v -> this.adultMinor = v);
|
|
||||||
|
|
||||||
if (command.isChangeInDateParameterNamed("minorDate", this.minorDate)) {
|
|
||||||
actualChanges.put("minorDate", command.stringValueOfParameterNamed("minorDate"));
|
|
||||||
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInLongParameterNamed("guardianClientId", this.guardianClientId)) {
|
|
||||||
final Long newValue = command.longValueOfParameterNamed("guardianClientId");
|
|
||||||
actualChanges.put("guardianClientId", newValue);
|
|
||||||
this.guardianClientId = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
stringChange(command, actualChanges, "minAccountOpt", this.minAccountOpt, v -> this.minAccountOpt = v);
|
|
||||||
stringChange(command, actualChanges, "relation", this.relation, v -> this.relation = v);
|
|
||||||
|
|
||||||
if (command.isChangeInDateParameterNamed("openingDate", this.openingDate)) {
|
|
||||||
actualChanges.put("openingDate", command.stringValueOfParameterNamed("openingDate"));
|
|
||||||
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInIntegerParameterNamed("month", this.month)) {
|
|
||||||
final Integer newValue = command.integerValueOfParameterNamed("month");
|
|
||||||
actualChanges.put("month", newValue);
|
|
||||||
this.month = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInDateParameterNamed("maturityDate", this.maturityDate)) {
|
|
||||||
actualChanges.put("maturityDate", command.stringValueOfParameterNamed("maturityDate"));
|
|
||||||
this.maturityDate = command.localDateValueOfParameterNamed("maturityDate");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInDateParameterNamed("reopenDate", this.reopenDate)) {
|
|
||||||
actualChanges.put("reopenDate", command.stringValueOfParameterNamed("reopenDate"));
|
|
||||||
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("depositAmount", this.depositAmount)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("depositAmount");
|
|
||||||
actualChanges.put("depositAmount", newValue);
|
|
||||||
this.depositAmount = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("dailyDepositAmount", this.dailyDepositAmount)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
|
|
||||||
actualChanges.put("dailyDepositAmount", newValue);
|
|
||||||
this.dailyDepositAmount = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInIntegerParameterNamed("days", this.days)) {
|
|
||||||
final Integer newValue = command.integerValueOfParameterNamed("days");
|
|
||||||
actualChanges.put("days", newValue);
|
|
||||||
this.days = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("interestRate", this.interestRate)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("interestRate");
|
|
||||||
actualChanges.put("interestRate", newValue);
|
|
||||||
this.interestRate = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
stringChange(command, actualChanges, "ledgerNo", this.ledgerNo, v -> this.ledgerNo = v);
|
|
||||||
stringChange(command, actualChanges, "withdrawType", this.withdrawType, v -> this.withdrawType = v);
|
|
||||||
|
|
||||||
if (command.isChangeInBooleanParameterNamed("lien", this.lien)) {
|
|
||||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("lien");
|
|
||||||
actualChanges.put("lien", newValue);
|
|
||||||
this.lien = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInDateParameterNamed("payDate", this.payDate)) {
|
|
||||||
actualChanges.put("payDate", command.stringValueOfParameterNamed("payDate"));
|
|
||||||
this.payDate = command.localDateValueOfParameterNamed("payDate");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("paymentAmount", this.paymentAmount)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("paymentAmount");
|
|
||||||
actualChanges.put("paymentAmount", newValue);
|
|
||||||
this.paymentAmount = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBigDecimalParameterNamed("openingBalance", this.openingBalance)) {
|
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("openingBalance");
|
|
||||||
actualChanges.put("openingBalance", newValue);
|
|
||||||
this.openingBalance = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBooleanParameterNamed("isSmsActive", this.isSmsActive)) {
|
|
||||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
|
|
||||||
actualChanges.put("isSmsActive", newValue);
|
|
||||||
this.isSmsActive = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInBooleanParameterNamed("isEmailActive", this.isEmailActive)) {
|
|
||||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
|
|
||||||
actualChanges.put("isEmailActive", newValue);
|
|
||||||
this.isEmailActive = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.isChangeInDateParameterNamed("interestDate", this.interestDate)) {
|
|
||||||
actualChanges.put("interestDate", command.stringValueOfParameterNamed("interestDate"));
|
|
||||||
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
|
|
||||||
}
|
|
||||||
|
|
||||||
stringChange(command, actualChanges, "spDescription", this.spDescription, v -> this.spDescription = v);
|
|
||||||
stringChange(command, actualChanges, "freezeCode", this.freezeCode, v -> this.freezeCode = v);
|
|
||||||
stringChange(command, actualChanges, "advCode", this.advCode, v -> this.advCode = v);
|
|
||||||
stringChange(command, actualChanges, "creditToBranch", this.creditToBranch, v -> this.creditToBranch = v);
|
|
||||||
stringChange(command, actualChanges, "creditToAccountType", this.creditToAccountType, v -> this.creditToAccountType = v);
|
|
||||||
stringChange(command, actualChanges, "creditToAccount", this.creditToAccount, v -> this.creditToAccount = v);
|
|
||||||
|
|
||||||
if (command.isChangeInBooleanParameterNamed("nomineeApplicable", this.nomineeApplicable)) {
|
|
||||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
|
|
||||||
actualChanges.put("nomineeApplicable", newValue);
|
|
||||||
this.nomineeApplicable = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
stringChange(command, actualChanges, "nomineeSplitMode", this.nomineeSplitMode, v -> this.nomineeSplitMode = v);
|
|
||||||
|
|
||||||
return actualChanges;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
|
|
||||||
final String existingValue, final Consumer<String> setter) {
|
|
||||||
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
|
|
||||||
final String newValue = command.stringValueOfParameterNamed(paramName);
|
|
||||||
actualChanges.put(paramName, newValue);
|
|
||||||
setter.accept(newValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-88
@@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.party.domain;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import lombok.Getter;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single nominee recorded against a {@link Party}. Maker-checker on nominee edits is enforced at
|
|
||||||
* the command layer (the whole Party update goes through the standard maker-checker command flow),
|
|
||||||
* not on this child row individually.
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Entity
|
|
||||||
@Table(name = "m_dds_party_nominee")
|
|
||||||
public class PartyNominee extends AbstractPersistableCustom<Long> {
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "party_id", nullable = false)
|
|
||||||
private Party party;
|
|
||||||
|
|
||||||
@Column(name = "nomination_date", nullable = false)
|
|
||||||
private LocalDate nominationDate;
|
|
||||||
|
|
||||||
@Column(name = "name", length = 100, nullable = false)
|
|
||||||
private String name;
|
|
||||||
|
|
||||||
@Column(name = "mobile_no", length = 20)
|
|
||||||
private String mobileNo;
|
|
||||||
|
|
||||||
@Column(name = "birth_date")
|
|
||||||
private LocalDate birthDate;
|
|
||||||
|
|
||||||
@Column(name = "minor", nullable = false)
|
|
||||||
private boolean minor;
|
|
||||||
|
|
||||||
@Column(name = "relation", length = 40, nullable = false)
|
|
||||||
private String relation;
|
|
||||||
|
|
||||||
@Column(name = "gender", length = 20, nullable = false)
|
|
||||||
private String gender;
|
|
||||||
|
|
||||||
@Column(name = "guardian_name", length = 100)
|
|
||||||
private String guardianName;
|
|
||||||
|
|
||||||
protected PartyNominee() {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
public PartyNominee(final LocalDate nominationDate, final String name, final String mobileNo, final LocalDate birthDate,
|
|
||||||
final boolean minor, final String relation, final String gender, final String guardianName) {
|
|
||||||
this.nominationDate = nominationDate;
|
|
||||||
this.name = name;
|
|
||||||
this.mobileNo = StringUtils.defaultIfEmpty(mobileNo, null);
|
|
||||||
this.birthDate = birthDate;
|
|
||||||
this.minor = minor;
|
|
||||||
this.relation = relation;
|
|
||||||
this.gender = gender;
|
|
||||||
this.guardianName = StringUtils.defaultIfEmpty(guardianName, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
void setParty(final Party party) {
|
|
||||||
this.party = party;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.party.exception;
|
|
||||||
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when a Party's savingsAccountNo does not match any non-closed Savings Account.
|
|
||||||
*/
|
|
||||||
public class PartySavingsAccountNotFoundException extends AbstractPlatformResourceNotFoundException {
|
|
||||||
|
|
||||||
public PartySavingsAccountNotFoundException(final String savingsAccountNo) {
|
|
||||||
super("error.msg.party.savings.account.not.found", "No open savings account found with account number " + savingsAccountNo,
|
|
||||||
savingsAccountNo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.report.data;
|
|
||||||
|
|
||||||
import java.io.Serial;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A generic tabular result shape shared by every DDS report - one column list plus one row list, both
|
|
||||||
* keyed the same way, so a single report viewer screen can render any of them without a bespoke
|
|
||||||
* result type per report.
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Builder
|
|
||||||
public final class DdsReportData implements Serializable {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private final String reportKey;
|
|
||||||
private final boolean available;
|
|
||||||
private final String message;
|
|
||||||
private final List<String> columns;
|
|
||||||
private final List<Map<String, Object>> rows;
|
|
||||||
}
|
|
||||||
-52
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.transaction.data;
|
|
||||||
|
|
||||||
import java.io.Serial;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Builder
|
|
||||||
public final class PartyTransactionData implements Serializable {
|
|
||||||
|
|
||||||
@Serial
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
private final Long id;
|
|
||||||
private final Long partyId;
|
|
||||||
private final String partyCode;
|
|
||||||
private final String partyName;
|
|
||||||
private final Long agentId;
|
|
||||||
private final String agentCode;
|
|
||||||
private final LocalDate transactionDate;
|
|
||||||
private final String transactionType;
|
|
||||||
private final String direction;
|
|
||||||
private final String transactionMode;
|
|
||||||
private final String accountType;
|
|
||||||
private final String accountNo;
|
|
||||||
private final BigDecimal amount;
|
|
||||||
private final String chequeNo;
|
|
||||||
private final String tokenNo;
|
|
||||||
private final String description;
|
|
||||||
private final BigDecimal balanceAfterTransaction;
|
|
||||||
}
|
|
||||||
-110
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.transaction.domain;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import lombok.Getter;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
|
||||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
|
||||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single posted entry on the DDS ledger: a Party's day's collection ("Party Tran", direction
|
|
||||||
* CREDIT), one leg of a "Withdraw Deposite" transfer between two parties (the creditor leg CREDIT,
|
|
||||||
* the debtor leg DEBIT), or an Agent's settlement of collected cash to its branch ("Agent Tran",
|
|
||||||
* direction DEBIT - no {@link Party} involved, so {@code party} is null for this type).
|
|
||||||
* {@code balanceAfterTransaction} holds the Party's balance for Party-linked rows, or the Agent's
|
|
||||||
* balance for an Agent Tran row. Immutable once posted - like any transaction, it is reversed with
|
|
||||||
* an opposing entry, not edited, so there is deliberately no update API for this entity.
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Entity
|
|
||||||
@Table(name = "m_dds_party_transaction")
|
|
||||||
public class PartyTransaction extends AbstractPersistableCustom<Long> {
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "party_id")
|
|
||||||
private Party party;
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "agent_id", nullable = false)
|
|
||||||
private Agent agent;
|
|
||||||
|
|
||||||
@Column(name = "transaction_date", nullable = false)
|
|
||||||
private LocalDate transactionDate;
|
|
||||||
|
|
||||||
@Column(name = "transaction_type", length = 30, nullable = false)
|
|
||||||
private String transactionType;
|
|
||||||
|
|
||||||
@Column(name = "direction", length = 10, nullable = false)
|
|
||||||
private String direction;
|
|
||||||
|
|
||||||
@Column(name = "transaction_mode", length = 20)
|
|
||||||
private String transactionMode;
|
|
||||||
|
|
||||||
@Column(name = "account_type", length = 100)
|
|
||||||
private String accountType;
|
|
||||||
|
|
||||||
@Column(name = "account_no", length = 50)
|
|
||||||
private String accountNo;
|
|
||||||
|
|
||||||
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal amount;
|
|
||||||
|
|
||||||
@Column(name = "cheque_no", length = 40)
|
|
||||||
private String chequeNo;
|
|
||||||
|
|
||||||
@Column(name = "token_no", length = 40)
|
|
||||||
private String tokenNo;
|
|
||||||
|
|
||||||
@Column(name = "description", length = 500)
|
|
||||||
private String description;
|
|
||||||
|
|
||||||
@Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false)
|
|
||||||
private BigDecimal balanceAfterTransaction;
|
|
||||||
|
|
||||||
protected PartyTransaction() {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
public PartyTransaction(final Party party, final Agent agent, final LocalDate transactionDate, final String transactionType,
|
|
||||||
final String direction, final String transactionMode, final String accountType, final String accountNo,
|
|
||||||
final BigDecimal amount, final String chequeNo, final String tokenNo, final String description,
|
|
||||||
final BigDecimal balanceAfterTransaction) {
|
|
||||||
this.party = party;
|
|
||||||
this.agent = agent;
|
|
||||||
this.transactionDate = transactionDate;
|
|
||||||
this.transactionType = transactionType;
|
|
||||||
this.direction = direction;
|
|
||||||
this.transactionMode = transactionMode;
|
|
||||||
this.accountType = accountType;
|
|
||||||
this.accountNo = accountNo;
|
|
||||||
this.amount = amount;
|
|
||||||
this.chequeNo = chequeNo;
|
|
||||||
this.tokenNo = tokenNo;
|
|
||||||
this.description = description;
|
|
||||||
this.balanceAfterTransaction = balanceAfterTransaction;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.transaction.exception;
|
|
||||||
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when an Agent Tran settlement amount exceeds the Agent's current (collected but not yet
|
|
||||||
* settled) balance.
|
|
||||||
*/
|
|
||||||
public class InsufficientAgentBalanceException extends AbstractPlatformDomainRuleException {
|
|
||||||
|
|
||||||
public InsufficientAgentBalanceException(final String agentCode) {
|
|
||||||
super("error.msg.agent.insufficient.balance",
|
|
||||||
"Agent " + agentCode + " does not have sufficient collected balance for this settlement", agentCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.transaction.exception;
|
|
||||||
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when a Withdraw Deposite transfer would take the debtor Party's balance below zero.
|
|
||||||
*/
|
|
||||||
public class InsufficientPartyBalanceException extends AbstractPlatformDomainRuleException {
|
|
||||||
|
|
||||||
public InsufficientPartyBalanceException(final String partyCode) {
|
|
||||||
super("error.msg.party.insufficient.balance", "Party " + partyCode + " does not have sufficient balance for this transfer",
|
|
||||||
partyCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.dds.transaction.exception;
|
|
||||||
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
|
||||||
import org.springframework.dao.EmptyResultDataAccessException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A {@link RuntimeException} thrown when a DDS party transaction resource is not found.
|
|
||||||
*/
|
|
||||||
public class PartyTransactionNotFoundException extends AbstractPlatformResourceNotFoundException {
|
|
||||||
|
|
||||||
public PartyTransactionNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
|
||||||
super("error.msg.partytransaction.id.invalid", "Party transaction with identifier " + id + " does not exist", id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-1
@@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType {
|
|||||||
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
|
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
|
||||||
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
||||||
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
||||||
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
|
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
|
||||||
|
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
private final String code;
|
private final String code;
|
||||||
@@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType {
|
|||||||
case 200 -> TRANSFER_TO_SAVINGS;
|
case 200 -> TRANSFER_TO_SAVINGS;
|
||||||
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
||||||
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
||||||
|
case 500 -> PARTIAL_REINVEST;
|
||||||
default -> INVALID;
|
default -> INVALID;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType {
|
|||||||
return this.equals(INVALID);
|
return this.equals(INVALID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isPartialReinvest() {
|
||||||
|
return this.equals(PARTIAL_REINVEST);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: do we really need this?!?
|
// TODO: do we really need this?!?
|
||||||
public static Object[] integerValues() {
|
public static Object[] integerValues() {
|
||||||
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
||||||
|
|||||||
+5
@@ -208,6 +208,7 @@ public final class DepositsApiConstants {
|
|||||||
public static final String onAccountClosureIdParamName = "onAccountClosureId";
|
public static final String onAccountClosureIdParamName = "onAccountClosureId";
|
||||||
public static final String transferDescriptionParamName = "transferDescription";
|
public static final String transferDescriptionParamName = "transferDescription";
|
||||||
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
||||||
|
public static final String reinvestAmountParamName = "reinvestAmount";
|
||||||
public static final String savingsAccounts = "savingsAccounts";
|
public static final String savingsAccounts = "savingsAccounts";
|
||||||
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
||||||
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
||||||
@@ -588,6 +589,10 @@ public final class DepositsApiConstants {
|
|||||||
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
|
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
|
||||||
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
|
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
|
||||||
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
|
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
|
||||||
|
// FD Special's engine-computed monthly interest schedule (see FixedDepositAccountsApiResource#retrieveOne)
|
||||||
|
fixedDepositResponseData.add("projectedInterestSchedule");
|
||||||
|
fixedDepositResponseData.add("totalProjectedInterest");
|
||||||
|
fixedDepositResponseData.add("totalValueAtMaturity");
|
||||||
return fixedDepositResponseData;
|
return fixedDepositResponseData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -92,6 +92,9 @@ public class SavingsApiConstants {
|
|||||||
public static final String statusParamName = "status";
|
public static final String statusParamName = "status";
|
||||||
public static final String subStatusParamName = "subStatus";
|
public static final String subStatusParamName = "subStatus";
|
||||||
public static final String reasonForBlockParamName = "reasonForBlock";
|
public static final String reasonForBlockParamName = "reasonForBlock";
|
||||||
|
/** "CASH" (default, a manual/staff-placed lien) or "CLEARING" (money still moving through the
|
||||||
|
* clearing process — a not-yet-realized cheque/DD deposit, or a presented cheque not yet settled). */
|
||||||
|
public static final String holdCategoryParamName = "holdCategory";
|
||||||
public static final String clientIdParamName = "clientId";
|
public static final String clientIdParamName = "clientId";
|
||||||
public static final String isRetailAccountParamName = "isRetailAccount";
|
public static final String isRetailAccountParamName = "isRetailAccount";
|
||||||
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
|
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
|
||||||
@@ -226,6 +229,7 @@ public class SavingsApiConstants {
|
|||||||
public static final String endDateParamName = "endDate";
|
public static final String endDateParamName = "endDate";
|
||||||
public static final String introducerRequiredParamName = "introducerRequired";
|
public static final String introducerRequiredParamName = "introducerRequired";
|
||||||
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
|
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
|
||||||
|
public static final String shareAccountIdParamName = "shareAccountId";
|
||||||
public static final String nominationFacilityParamName = "nominationFacility";
|
public static final String nominationFacilityParamName = "nominationFacility";
|
||||||
public static final String dormancyAlertParamName = "dormancyAlert";
|
public static final String dormancyAlertParamName = "dormancyAlert";
|
||||||
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
|
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
|
||||||
@@ -254,7 +258,6 @@ public class SavingsApiConstants {
|
|||||||
public static final String neftFacilityParamName = "neftFacility";
|
public static final String neftFacilityParamName = "neftFacility";
|
||||||
public static final String rtgsFacilityParamName = "rtgsFacility";
|
public static final String rtgsFacilityParamName = "rtgsFacility";
|
||||||
public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility";
|
public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility";
|
||||||
public static final String withdrawalFacilityParamName = "withdrawalFacility";
|
|
||||||
public static final String depositFacilityKycParamName = "depositFacilityKyc";
|
public static final String depositFacilityKycParamName = "depositFacilityKyc";
|
||||||
public static final String virtualDebitCardParamName = "virtualDebitCard";
|
public static final String virtualDebitCardParamName = "virtualDebitCard";
|
||||||
|
|
||||||
@@ -275,7 +278,6 @@ public class SavingsApiConstants {
|
|||||||
// Additional custom fields
|
// Additional custom fields
|
||||||
public static final String balanceFrequencyParamName = "balanceFrequency";
|
public static final String balanceFrequencyParamName = "balanceFrequency";
|
||||||
public static final String accountClosedZeroBalanceInDayClosingParamName = "accountClosedZeroBalanceInDayClosing";
|
public static final String accountClosedZeroBalanceInDayClosingParamName = "accountClosedZeroBalanceInDayClosing";
|
||||||
public static final String productCategoryParamName = "productCategory";
|
|
||||||
|
|
||||||
// Current Product — new UI fields
|
// Current Product — new UI fields
|
||||||
public static final String balanceSwapOptionParamName = "balanceSwapOption";
|
public static final String balanceSwapOptionParamName = "balanceSwapOption";
|
||||||
|
|||||||
+17
@@ -157,6 +157,8 @@ public final class SavingsAccountData implements Serializable {
|
|||||||
private LocalDate endDate;
|
private LocalDate endDate;
|
||||||
private Boolean introducerRequired;
|
private Boolean introducerRequired;
|
||||||
private Boolean shareLinkingEnabled;
|
private Boolean shareLinkingEnabled;
|
||||||
|
private Long shareAccountId;
|
||||||
|
private String shareAccountNo;
|
||||||
private Boolean nominationFacility;
|
private Boolean nominationFacility;
|
||||||
private Boolean dormancyAlert;
|
private Boolean dormancyAlert;
|
||||||
private Boolean isDormancyTrackingActiveFromProduct;
|
private Boolean isDormancyTrackingActiveFromProduct;
|
||||||
@@ -217,6 +219,21 @@ public final class SavingsAccountData implements Serializable {
|
|||||||
private BigDecimal interestPosting;
|
private BigDecimal interestPosting;
|
||||||
private BigDecimal overdraftPosting;
|
private BigDecimal overdraftPosting;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this deposit pays its interest out to a linked savings account every period (FD Special).
|
||||||
|
* Such interest never stays in the account, so it must never compound -- every period earns on the
|
||||||
|
* principal alone. Transient because it is only read by the batch interest-posting engine.
|
||||||
|
*/
|
||||||
|
private transient boolean transferInterestToLinkedAccount;
|
||||||
|
|
||||||
|
public void setTransferInterestToLinkedAccount(final boolean transferInterestToLinkedAccount) {
|
||||||
|
this.transferInterestToLinkedAccount = transferInterestToLinkedAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTransferInterestToLinkedAccount() {
|
||||||
|
return this.transferInterestToLinkedAccount;
|
||||||
|
}
|
||||||
|
|
||||||
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
|
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
|
||||||
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
||||||
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
||||||
|
|||||||
-1
@@ -144,7 +144,6 @@ public final class SavingsProductData implements Serializable {
|
|||||||
@Getter @Setter private Boolean enableLockinPeriod;
|
@Getter @Setter private Boolean enableLockinPeriod;
|
||||||
@Getter @Setter private String balanceFrequency;
|
@Getter @Setter private String balanceFrequency;
|
||||||
@Getter @Setter private Boolean accountClosedZeroBalanceInDayClosing;
|
@Getter @Setter private Boolean accountClosedZeroBalanceInDayClosing;
|
||||||
@Getter @Setter private String productCategory;
|
|
||||||
|
|
||||||
// New current product fields
|
// New current product fields
|
||||||
@Getter @Setter private Boolean balanceSwapOption;
|
@Getter @Setter private Boolean balanceSwapOption;
|
||||||
|
|||||||
+10
-1
@@ -256,7 +256,16 @@ public final class PostingPeriod {
|
|||||||
this.daysInYear = daysInYear;
|
this.daysInYear = daysInYear;
|
||||||
this.compoundingPeriods = compoundingPeriods;
|
this.compoundingPeriods = compoundingPeriods;
|
||||||
|
|
||||||
if (isSavingsInterestPostingAtCurrentPeriodEnd) {
|
if (isUserPosting) {
|
||||||
|
// "Post interest as on D" — a closure, or the same action from the account screen. The
|
||||||
|
// period it covers ends at D-1, because interest is earned up to but not including the
|
||||||
|
// day it is paid, and SavingsHelper closes the period there. The transaction itself
|
||||||
|
// still belongs on D: that is the day the member was credited, the day the branch did
|
||||||
|
// the work, and the day the cash is handed over. Dating it to the period end instead
|
||||||
|
// stamps it a day before the account was even closed, and the ledger then disagrees with
|
||||||
|
// the closure request, the voucher and the passbook — all of which carry D.
|
||||||
|
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
||||||
|
} else if (isSavingsInterestPostingAtCurrentPeriodEnd) {
|
||||||
this.dateOfPostingTransaction = periodInterval.endDate();
|
this.dateOfPostingTransaction = periodInterval.endDate();
|
||||||
} else {
|
} else {
|
||||||
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
|
||||||
|
|||||||
+4
@@ -797,6 +797,10 @@ public final class SavingsEnumerations {
|
|||||||
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
|
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
|
||||||
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
|
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
|
||||||
break;
|
break;
|
||||||
|
case PARTIAL_REINVEST:
|
||||||
|
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
|
||||||
|
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return optionData;
|
return optionData;
|
||||||
}
|
}
|
||||||
|
|||||||
-4
@@ -77,9 +77,6 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
@Column(name = "inter_bank_total_cap_facility", nullable = false)
|
@Column(name = "inter_bank_total_cap_facility", nullable = false)
|
||||||
private boolean interBankTotalCapFacility = false;
|
private boolean interBankTotalCapFacility = false;
|
||||||
|
|
||||||
@Column(name = "withdrawal_facility", nullable = false)
|
|
||||||
private boolean withdrawalFacility = false;
|
|
||||||
|
|
||||||
@Column(name = "deposit_facility_kyc", nullable = false)
|
@Column(name = "deposit_facility_kyc", nullable = false)
|
||||||
private boolean depositFacilityKyc = false;
|
private boolean depositFacilityKyc = false;
|
||||||
|
|
||||||
@@ -120,7 +117,6 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
|
if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
|
||||||
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
|
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
|
||||||
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean());
|
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean());
|
||||||
if (config.has("withdrawalFacility")) setWithdrawalFacility(config.get("withdrawalFacility").getAsBoolean());
|
|
||||||
if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
|
if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
|
||||||
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
|
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
|
||||||
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
|
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
|
||||||
|
|||||||
-3
@@ -136,9 +136,6 @@ public class CurrentAccountPendingEdit extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "inter_bank_total_cap_facility")
|
@Column(name = "inter_bank_total_cap_facility")
|
||||||
private Boolean interBankTotalCapFacility;
|
private Boolean interBankTotalCapFacility;
|
||||||
|
|
||||||
@Column(name = "withdrawal_facility")
|
|
||||||
private Boolean withdrawalFacility;
|
|
||||||
|
|
||||||
@Column(name = "deposit_facility_kyc")
|
@Column(name = "deposit_facility_kyc")
|
||||||
private Boolean depositFacilityKyc;
|
private Boolean depositFacilityKyc;
|
||||||
|
|
||||||
|
|||||||
-1
@@ -378,7 +378,6 @@ public class CurrentProductAssembler {
|
|||||||
config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false);
|
config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false);
|
||||||
config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
|
config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
|
||||||
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false);
|
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false);
|
||||||
config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ? facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false);
|
|
||||||
config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
|
config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
|
||||||
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false);
|
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false);
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
-6
@@ -75,9 +75,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
@Column(name = "inter_bank_total_cap_facility")
|
@Column(name = "inter_bank_total_cap_facility")
|
||||||
private Boolean interBankTotalCapFacility = false;
|
private Boolean interBankTotalCapFacility = false;
|
||||||
|
|
||||||
@Column(name = "withdrawal_facility")
|
|
||||||
private Boolean withdrawalFacility = false;
|
|
||||||
|
|
||||||
@Column(name = "deposit_facility_kyc")
|
@Column(name = "deposit_facility_kyc")
|
||||||
private Boolean depositFacilityKyc = false;
|
private Boolean depositFacilityKyc = false;
|
||||||
|
|
||||||
@@ -125,8 +122,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
public void setRtgsFacility(Boolean v) { this.rtgsFacility = v != null ? v : false; }
|
public void setRtgsFacility(Boolean v) { this.rtgsFacility = v != null ? v : false; }
|
||||||
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
|
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
|
||||||
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
|
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
|
||||||
public Boolean getWithdrawalFacility() { return withdrawalFacility; }
|
|
||||||
public void setWithdrawalFacility(Boolean v) { this.withdrawalFacility = v != null ? v : false; }
|
|
||||||
public Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
|
public Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
|
||||||
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
|
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
|
||||||
public Boolean getVirtualDebitCard() { return virtualDebitCard; }
|
public Boolean getVirtualDebitCard() { return virtualDebitCard; }
|
||||||
@@ -147,7 +142,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
this.neftFacility = other.getNeftFacility();
|
this.neftFacility = other.getNeftFacility();
|
||||||
this.rtgsFacility = other.getRtgsFacility();
|
this.rtgsFacility = other.getRtgsFacility();
|
||||||
this.interBankTotalCapFacility = other.getInterBankTotalCapFacility();
|
this.interBankTotalCapFacility = other.getInterBankTotalCapFacility();
|
||||||
this.withdrawalFacility = other.getWithdrawalFacility();
|
|
||||||
this.depositFacilityKyc = other.getDepositFacilityKyc();
|
this.depositFacilityKyc = other.getDepositFacilityKyc();
|
||||||
this.virtualDebitCard = other.getVirtualDebitCard();
|
this.virtualDebitCard = other.getVirtualDebitCard();
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -117,7 +117,6 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
|
|||||||
facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility());
|
facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility());
|
||||||
facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility());
|
facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility());
|
||||||
facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility());
|
facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility());
|
||||||
facilityConfigMap.put("withdrawalFacility", facilityConfig.getWithdrawalFacility());
|
|
||||||
facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc());
|
facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc());
|
||||||
facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard());
|
facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard());
|
||||||
product.setAccountFacilityConfig(facilityConfigMap);
|
product.setAccountFacilityConfig(facilityConfigMap);
|
||||||
|
|||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.accounting.balancesheet.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An "as on date" balance sheet built directly off the live chart of accounts and journal entries,
|
||||||
|
* rather than a stock Fineract trial balance report — this reproduces the specific sub-group-wise
|
||||||
|
* layout this bank's own paper balance sheet uses: every top-level Header account (Capital, Deposit
|
||||||
|
* Account, Other Liabilities, ...) is one named block, with its Detail (and any nested Header)
|
||||||
|
* children indented underneath it and a subtotal on the block itself. A Detail account with no
|
||||||
|
* Header at all (see GLAccountTransactionGuard's sibling concern - some accounts are seeded standalone)
|
||||||
|
* still needs to appear somewhere, so it is listed as its own root-level line rather than silently
|
||||||
|
* dropped.
|
||||||
|
*
|
||||||
|
* A Header account never itself carries a journal entry (see the header/detail discussion this
|
||||||
|
* feature grew out of), so its balance is always the recursive sum of its children - never read off
|
||||||
|
* acc_gl_journal_entry directly.
|
||||||
|
*/
|
||||||
|
@Path("/v1/accounting/balancesheet")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class BalanceSheetApiResource {
|
||||||
|
|
||||||
|
private static final String RESOURCE_NAME_FOR_PERMISSION = "GLACCOUNT";
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
// classification_enum values (see acc_gl_account / GLAccountType).
|
||||||
|
private static final int ASSET = 1;
|
||||||
|
private static final int LIABILITY = 2;
|
||||||
|
private static final int EQUITY = 3;
|
||||||
|
private static final int INCOME = 4;
|
||||||
|
private static final int EXPENSE = 5;
|
||||||
|
// account_usage values (see GLAccountUsage).
|
||||||
|
private static final int DETAIL = 1;
|
||||||
|
private static final int HEADER = 2;
|
||||||
|
|
||||||
|
private static final class Node {
|
||||||
|
|
||||||
|
Long id;
|
||||||
|
String name;
|
||||||
|
String glCode;
|
||||||
|
int usage;
|
||||||
|
int classification;
|
||||||
|
Long parentId;
|
||||||
|
BigDecimal ownBalance = BigDecimal.ZERO;
|
||||||
|
final List<Node> children = new ArrayList<>();
|
||||||
|
|
||||||
|
BigDecimal balance() {
|
||||||
|
if (usage == DETAIL) {
|
||||||
|
return ownBalance;
|
||||||
|
}
|
||||||
|
BigDecimal sum = BigDecimal.ZERO;
|
||||||
|
for (final Node child : children) {
|
||||||
|
sum = sum.add(child.balance());
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> toJson() {
|
||||||
|
final Map<String, Object> json = new LinkedHashMap<>();
|
||||||
|
json.put("id", id);
|
||||||
|
json.put("name", name);
|
||||||
|
json.put("glCode", glCode);
|
||||||
|
json.put("usage", usage == HEADER ? "HEADER" : "DETAIL");
|
||||||
|
json.put("balance", balance());
|
||||||
|
final List<Map<String, Object>> childJson = new ArrayList<>();
|
||||||
|
for (final Node child : children) {
|
||||||
|
childJson.add(child.toJson());
|
||||||
|
}
|
||||||
|
json.put("children", childJson);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
public String retrieveBalanceSheet(@QueryParam("asOnDate") final String asOnDateParam,
|
||||||
|
@QueryParam("officeId") final Long officeId) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
|
||||||
|
final LocalDate asOnDate = (asOnDateParam == null || asOnDateParam.isBlank()) ? DateUtils.getBusinessLocalDate()
|
||||||
|
: LocalDate.parse(asOnDateParam);
|
||||||
|
|
||||||
|
final Map<Long, Node> nodesById = new LinkedHashMap<>();
|
||||||
|
for (final Map<String, Object> row : this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT id, name, gl_code, parent_id, account_usage, classification_enum FROM acc_gl_account "
|
||||||
|
+ "WHERE disabled = false ORDER BY gl_code")) {
|
||||||
|
final Node node = new Node();
|
||||||
|
node.id = ((Number) row.get("id")).longValue();
|
||||||
|
node.name = (String) row.get("name");
|
||||||
|
node.glCode = (String) row.get("gl_code");
|
||||||
|
node.parentId = row.get("parent_id") == null ? null : ((Number) row.get("parent_id")).longValue();
|
||||||
|
node.usage = ((Number) row.get("account_usage")).intValue();
|
||||||
|
node.classification = ((Number) row.get("classification_enum")).intValue();
|
||||||
|
nodesById.put(node.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// entry_date (not transaction_date) and "reversed = false" are this codebase's settled
|
||||||
|
// convention for reading balances off the ledger - ProfitAndLossApiResource, the dividend
|
||||||
|
// reports and IncomeProfitTransferService all cut the same way, so this report agrees with
|
||||||
|
// them rather than quietly producing a second set of numbers.
|
||||||
|
//
|
||||||
|
// The reversal_id exclusion covers a case that plain "reversed = false" gets wrong. This
|
||||||
|
// codebase reverses in two different ways: the clerk/sponsor-DD undo paths just flip the
|
||||||
|
// flag on the original leg (no counter entry - excluding it is exactly right), while
|
||||||
|
// Fineract's own revertJournalEntry additionally posts an opposite-sign counter entry and
|
||||||
|
// links it back through reversal_id. For that second kind, dropping only the original would
|
||||||
|
// leave its counter entry standing alone and swing the balance by the full amount in the
|
||||||
|
// wrong direction, so the counter entry has to go too.
|
||||||
|
final Map<Long, BigDecimal[]> balances = new LinkedHashMap<>();
|
||||||
|
final String balanceSql = "SELECT account_id, "
|
||||||
|
+ "COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit, "
|
||||||
|
+ "COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit "
|
||||||
|
+ "FROM acc_gl_journal_entry WHERE entry_date <= ? "
|
||||||
|
+ "AND (reversed IS NULL OR reversed = false) "
|
||||||
|
+ "AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)"
|
||||||
|
+ (officeId != null ? " AND office_id = ?" : "") + " GROUP BY account_id";
|
||||||
|
final List<Map<String, Object>> balanceRows = officeId != null
|
||||||
|
? this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate), officeId)
|
||||||
|
: this.jdbcTemplate.queryForList(balanceSql, java.sql.Date.valueOf(asOnDate));
|
||||||
|
for (final Map<String, Object> row : balanceRows) {
|
||||||
|
balances.put(((Number) row.get("account_id")).longValue(),
|
||||||
|
new BigDecimal[] { (BigDecimal) row.get("total_debit"), (BigDecimal) row.get("total_credit") });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final Node node : nodesById.values()) {
|
||||||
|
if (node.usage != DETAIL) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final BigDecimal[] debitCredit = balances.get(node.id);
|
||||||
|
if (debitCredit == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final boolean debitNatured = node.classification == ASSET || node.classification == EXPENSE;
|
||||||
|
node.ownBalance = debitNatured ? debitCredit[0].subtract(debitCredit[1]) : debitCredit[1].subtract(debitCredit[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Node> roots = new ArrayList<>();
|
||||||
|
for (final Node node : nodesById.values()) {
|
||||||
|
if (node.parentId == null) {
|
||||||
|
roots.add(node);
|
||||||
|
} else {
|
||||||
|
final Node parent = nodesById.get(node.parentId);
|
||||||
|
if (parent != null) {
|
||||||
|
parent.children.add(node);
|
||||||
|
} else {
|
||||||
|
// Parent was disabled (filtered out above) - surface this node at the top level
|
||||||
|
// rather than silently losing its balance from the report.
|
||||||
|
roots.add(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> debitSide = buildSide("ASSETS", roots, ASSET, EXPENSE);
|
||||||
|
final Map<String, Object> creditSide = buildSide("LIABILITIES", roots, LIABILITY, EQUITY, INCOME);
|
||||||
|
|
||||||
|
final Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("asOnDate", asOnDate.toString());
|
||||||
|
response.put("officeId", officeId);
|
||||||
|
response.put("debitSide", debitSide);
|
||||||
|
response.put("creditSide", creditSide);
|
||||||
|
return this.gson.toJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildSide(final String label, final List<Node> roots, final int... classifications) {
|
||||||
|
final List<Map<String, Object>> groups = new ArrayList<>();
|
||||||
|
BigDecimal sideTotal = BigDecimal.ZERO;
|
||||||
|
for (final int classification : classifications) {
|
||||||
|
final List<Map<String, Object>> nodeJsons = new ArrayList<>();
|
||||||
|
BigDecimal groupTotal = BigDecimal.ZERO;
|
||||||
|
for (final Node root : roots) {
|
||||||
|
if (root.classification != classification) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nodeJsons.add(root.toJson());
|
||||||
|
groupTotal = groupTotal.add(root.balance());
|
||||||
|
}
|
||||||
|
if (!nodeJsons.isEmpty()) {
|
||||||
|
final Map<String, Object> group = new LinkedHashMap<>();
|
||||||
|
group.put("classification", classificationLabel(classification));
|
||||||
|
group.put("total", groupTotal);
|
||||||
|
group.put("nodes", nodeJsons);
|
||||||
|
groups.add(group);
|
||||||
|
sideTotal = sideTotal.add(groupTotal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final Map<String, Object> side = new LinkedHashMap<>();
|
||||||
|
side.put("label", label);
|
||||||
|
side.put("total", sideTotal);
|
||||||
|
side.put("groups", groups);
|
||||||
|
return side;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String classificationLabel(final int classification) {
|
||||||
|
return switch (classification) {
|
||||||
|
case ASSET -> "ASSET";
|
||||||
|
case LIABILITY -> "LIABILITY";
|
||||||
|
case EQUITY -> "EQUITY";
|
||||||
|
case INCOME -> "INCOME";
|
||||||
|
case EXPENSE -> "EXPENSE";
|
||||||
|
default -> "OTHER";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.accounting.common;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the GL accounts the dividend lifecycle posts to, from the Financial Activity Mappings an administrator
|
||||||
|
* configures.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* These accounts used to be found by hardcoded GL code - {@code BANKPROFITND01} and {@code BANKPROFIT01} - spread
|
||||||
|
* across five classes. A society whose chart of accounts did not happen to use those exact codes got no dividend
|
||||||
|
* journal entries at all, and nothing said so: every call site checked for null and returned quietly. Reading them from
|
||||||
|
* the mapping table instead lets each society name its own accounts, and keeps the whole lifecycle answerable from one
|
||||||
|
* screen.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Resolution is mapping-only, with no fallback to the old codes: a society that has not mapped an account has not made
|
||||||
|
* the decision yet, and quietly posting to an account it never chose is worse than refusing. Every method here throws
|
||||||
|
* rather than returning null, so a missing mapping surfaces at the point of posting with the name of the activity to
|
||||||
|
* configure, instead of leaving a declaration that appears to have worked but moved nothing.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DividendGlAccountResolver {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/** The year's closed-out profit a dividend is declared against. */
|
||||||
|
public Long distributableProfitAccount() {
|
||||||
|
return requireMapped(FinancialActivity.DISTRIBUTABLE_PROFIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where profit settles once it is no longer distributable: the undistributed remainder, rounding, forfeitures. */
|
||||||
|
public Long retainedProfitAccount() {
|
||||||
|
return requireMapped(FinancialActivity.RETAINED_PROFIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Equity the declared profit is set aside in, between declaration and board approval. */
|
||||||
|
public Long reserveForDividendAccount() {
|
||||||
|
return requireMapped(FinancialActivity.RESERVE_FOR_DIVIDEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The liability to members, created on approval and discharged as each member claims. */
|
||||||
|
public Long payableDividendsAccount() {
|
||||||
|
return requireMapped(FinancialActivity.PAYABLE_DIVIDENDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the paise a dividend cannot divide evenly are swept.
|
||||||
|
*
|
||||||
|
* Falls back to retained profit when unmapped, which is exactly where the residue went before this account existed
|
||||||
|
* - so a society that does not care to separate it keeps the behaviour it already had, and one that maps it gets
|
||||||
|
* the arithmetic artefact out of a figure it reads as a business result. This is a presentation choice rather than
|
||||||
|
* a decision the society has failed to make, which is why it resolves rather than throwing.
|
||||||
|
*/
|
||||||
|
public Long dividendRoundOffAccount() {
|
||||||
|
final Long roundOff = mappedAccount(FinancialActivity.DIVIDEND_ROUND_OFF);
|
||||||
|
return roundOff != null ? roundOff : retainedProfitAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contra the claim is routed through, so claimed and unclaimed read as two balances rather than one net figure.
|
||||||
|
*
|
||||||
|
* Optional by design - a society that has not mapped it still gets a correct payout, posted straight from Dividend
|
||||||
|
* Payable, just without that separate measure. This is the one account here that may legitimately be absent, so it
|
||||||
|
* returns null instead of throwing.
|
||||||
|
*/
|
||||||
|
public Long dividendPaidAccountOrNull() {
|
||||||
|
return mappedAccount(FinancialActivity.DIVIDEND_PAID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The GL mapped to an activity, or null when the society has not configured it. */
|
||||||
|
public Long mappedAccount(final FinancialActivity activity) {
|
||||||
|
return this.jdbcTemplate
|
||||||
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
activity.getValue())
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long requireMapped(final FinancialActivity activity) {
|
||||||
|
final Long glAccountId = mappedAccount(activity);
|
||||||
|
if (glAccountId == null) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.financial.activity.mapping." + activity.getCode() + ".not.configured",
|
||||||
|
"No GL account is mapped to the '" + activity + "' financial activity. Map one under Accounting > Financial Activity "
|
||||||
|
+ "Mappings before running the dividend cycle.",
|
||||||
|
activity.getValue());
|
||||||
|
}
|
||||||
|
return glAccountId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.accounting.dividendpaidreport.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every declared dividend, financial year by financial year: how much was declared, how much members have collected,
|
||||||
|
* how much is still unclaimed, and how much has come back to profit.
|
||||||
|
*
|
||||||
|
* The GL accounts are shared across years, so their balances only ever give running totals — "how much of FY 2026-27 is
|
||||||
|
* still unclaimed" cannot be read off Dividend Payable. This walks the declarations themselves instead: locks carry the
|
||||||
|
* per-member entitlement and their status says what became of it, which is the authoritative record. The GL is where the
|
||||||
|
* money moved; the locks are what it moved for.
|
||||||
|
*
|
||||||
|
* The columns mirror the money's actual path, which begins and ends in the same place:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* declare Dr Bank Profit Cr Dividend Payable
|
||||||
|
* member claims Dr Dividend Payable Cr Dividend Paid → Cr Sponsor DD Payable
|
||||||
|
* 3 yrs unclaimed Dr Dividend Payable Cr Bank Profit
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* So for a closed year, claimed + forfeited + rounding residue should account for the whole declaration; anything left
|
||||||
|
* is what is still sitting unclaimed and still within its three-year window.
|
||||||
|
*/
|
||||||
|
@Path("/v1/accounting/dividend-yearwise-report")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Dividend Year-wise Report", description = "Declared, claimed, unclaimed and forfeited dividend per financial year.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DividendYearwiseReportApiResource {
|
||||||
|
|
||||||
|
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String report() {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||||
|
|
||||||
|
// A declaration is labelled by the financial year its period CLOSES in, which is how a member
|
||||||
|
// and an auditor both refer to it: a period ending 31-Mar-2027 is FY 2026-27. Locks are joined
|
||||||
|
// through the per-member detail rather than counted off the declaration, because a lock is only
|
||||||
|
// created for a member who actually had something to collect.
|
||||||
|
// Declared amounts and lock outcomes are aggregated separately before being joined by year.
|
||||||
|
// Summing the declaration amount across a join to its locks would multiply it by the number
|
||||||
|
// of members it was split between.
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
WITH fy AS (
|
||||||
|
SELECT d.id,
|
||||||
|
CASE WHEN EXTRACT(MONTH FROM d.dividend_period_end_date) >= 4
|
||||||
|
THEN EXTRACT(YEAR FROM d.dividend_period_end_date)
|
||||||
|
ELSE EXTRACT(YEAR FROM d.dividend_period_end_date) - 1 END AS fy_start,
|
||||||
|
d.amount
|
||||||
|
FROM m_share_product_dividend_pay_out d
|
||||||
|
),
|
||||||
|
declared AS (
|
||||||
|
SELECT fy_start, COUNT(*) AS declaration_count, SUM(amount) AS declared_amount
|
||||||
|
FROM fy GROUP BY fy_start
|
||||||
|
),
|
||||||
|
outcomes AS (
|
||||||
|
SELECT fy.fy_start,
|
||||||
|
COALESCE(SUM(CASE WHEN l.status = 'CLAIMED' THEN l.amount ELSE 0 END), 0) AS claimed_amount,
|
||||||
|
COUNT(l.id) FILTER (WHERE l.status = 'CLAIMED') AS claimed_count,
|
||||||
|
COALESCE(SUM(CASE WHEN l.status = 'UNCLAIMED' THEN l.amount ELSE 0 END), 0) AS unclaimed_amount,
|
||||||
|
COUNT(l.id) FILTER (WHERE l.status = 'UNCLAIMED') AS unclaimed_count,
|
||||||
|
COALESCE(SUM(CASE WHEN l.status = 'FORFEITED' THEN l.amount ELSE 0 END), 0) AS forfeited_amount,
|
||||||
|
COUNT(l.id) FILTER (WHERE l.status = 'FORFEITED') AS forfeited_count,
|
||||||
|
MIN(l.release_due_date) FILTER (WHERE l.status = 'UNCLAIMED') AS earliest_release_due
|
||||||
|
FROM fy
|
||||||
|
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = fy.id
|
||||||
|
LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id
|
||||||
|
GROUP BY fy.fy_start
|
||||||
|
)
|
||||||
|
SELECT d.fy_start, d.declaration_count, d.declared_amount,
|
||||||
|
COALESCE(o.claimed_amount, 0) AS claimed_amount, COALESCE(o.claimed_count, 0) AS claimed_count,
|
||||||
|
COALESCE(o.unclaimed_amount, 0) AS unclaimed_amount, COALESCE(o.unclaimed_count, 0) AS unclaimed_count,
|
||||||
|
COALESCE(o.forfeited_amount, 0) AS forfeited_amount, COALESCE(o.forfeited_count, 0) AS forfeited_count,
|
||||||
|
o.earliest_release_due
|
||||||
|
FROM declared d
|
||||||
|
LEFT JOIN outcomes o ON o.fy_start = d.fy_start
|
||||||
|
ORDER BY d.fy_start DESC
|
||||||
|
""");
|
||||||
|
|
||||||
|
// Rounding residue is tracked per declaration rather than per lock — it never belonged to a
|
||||||
|
// member, so no lock was ever created for it. Pulled separately and matched by year.
|
||||||
|
final Map<String, BigDecimal> residueByYear = new LinkedHashMap<>();
|
||||||
|
for (final Map<String, Object> row : jdbcTemplate.queryForList("""
|
||||||
|
SELECT CASE WHEN EXTRACT(MONTH FROM period_end_date) >= 4
|
||||||
|
THEN EXTRACT(YEAR FROM period_end_date)
|
||||||
|
ELSE EXTRACT(YEAR FROM period_end_date) - 1 END AS fy_start,
|
||||||
|
COALESCE(SUM(residue_amount), 0) AS residue
|
||||||
|
FROM m_share_dividend_rounding_residue
|
||||||
|
GROUP BY fy_start
|
||||||
|
""")) {
|
||||||
|
residueByYear.put(financialYearLabel(((Number) row.get("fy_start")).intValue()), (BigDecimal) row.get("residue"));
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, Object>> years = new ArrayList<>();
|
||||||
|
BigDecimal totalDeclared = BigDecimal.ZERO;
|
||||||
|
BigDecimal totalClaimed = BigDecimal.ZERO;
|
||||||
|
BigDecimal totalUnclaimed = BigDecimal.ZERO;
|
||||||
|
BigDecimal totalForfeited = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final String financialYear = financialYearLabel(((Number) row.get("fy_start")).intValue());
|
||||||
|
final BigDecimal declared = (BigDecimal) row.get("declared_amount");
|
||||||
|
final BigDecimal claimed = (BigDecimal) row.get("claimed_amount");
|
||||||
|
final BigDecimal unclaimed = (BigDecimal) row.get("unclaimed_amount");
|
||||||
|
final BigDecimal forfeited = (BigDecimal) row.get("forfeited_amount");
|
||||||
|
final BigDecimal residue = residueByYear.getOrDefault(financialYear, BigDecimal.ZERO);
|
||||||
|
|
||||||
|
final Map<String, Object> entry = new LinkedHashMap<>();
|
||||||
|
entry.put("financialYear", financialYear);
|
||||||
|
entry.put("declarationCount", row.get("declaration_count"));
|
||||||
|
entry.put("declaredAmount", declared);
|
||||||
|
entry.put("claimedAmount", claimed);
|
||||||
|
entry.put("claimedCount", row.get("claimed_count"));
|
||||||
|
entry.put("unclaimedAmount", unclaimed);
|
||||||
|
entry.put("unclaimedCount", row.get("unclaimed_count"));
|
||||||
|
entry.put("forfeitedAmount", forfeited);
|
||||||
|
entry.put("forfeitedCount", row.get("forfeited_count"));
|
||||||
|
entry.put("roundingResidueAmount", residue);
|
||||||
|
// What has already gone back to profit for this year: forfeited entitlements plus the
|
||||||
|
// rounding residue swept at declaration time.
|
||||||
|
entry.put("returnedToProfitAmount", forfeited.add(residue));
|
||||||
|
// When this year's oldest unclaimed lock forfeits — the date the remaining unclaimed
|
||||||
|
// amount starts returning to profit.
|
||||||
|
entry.put("earliestReleaseDueDate",
|
||||||
|
row.get("earliest_release_due") == null ? null : String.valueOf(row.get("earliest_release_due")));
|
||||||
|
years.add(entry);
|
||||||
|
|
||||||
|
totalDeclared = totalDeclared.add(declared);
|
||||||
|
totalClaimed = totalClaimed.add(claimed);
|
||||||
|
totalUnclaimed = totalUnclaimed.add(unclaimed);
|
||||||
|
totalForfeited = totalForfeited.add(forfeited);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("years", years);
|
||||||
|
response.put("totalDeclared", totalDeclared);
|
||||||
|
response.put("totalClaimed", totalClaimed);
|
||||||
|
response.put("totalUnclaimed", totalUnclaimed);
|
||||||
|
response.put("totalForfeited", totalForfeited);
|
||||||
|
return gson.toJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String financialYearLabel(final int startYear) {
|
||||||
|
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -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.accounting.glaccount.validation;
|
||||||
|
|
||||||
|
import org.apache.fineract.accounting.glaccount.domain.GLAccountUsage;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Header GL account (account_usage = HEADER) is a pure grouping node in the chart-of-accounts tree - it
|
||||||
|
* exists to organize Detail accounts underneath it, never to receive a transaction itself. The standard
|
||||||
|
* Fineract journal-entry command path (JournalEntryWritePlatformServiceJpaRepositoryImpl) never checked
|
||||||
|
* this - only "disabled" and "manual entries allowed" - and several features in this codebase post journal
|
||||||
|
* entries via direct JDBC INSERT into acc_gl_journal_entry, bypassing that command path entirely. This is
|
||||||
|
* the one shared check every such call site uses so a Header account can never end up with its own
|
||||||
|
* transaction (which would silently break every report that assumes a Header's balance is just the sum of
|
||||||
|
* its Detail children).
|
||||||
|
*/
|
||||||
|
public final class GLAccountTransactionGuard {
|
||||||
|
|
||||||
|
private GLAccountTransactionGuard() {}
|
||||||
|
|
||||||
|
public static void assertPostable(final JdbcTemplate jdbcTemplate, final Long glAccountId) {
|
||||||
|
if (glAccountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Integer usage;
|
||||||
|
try {
|
||||||
|
usage = jdbcTemplate.queryForObject("SELECT account_usage FROM acc_gl_account WHERE id = ?", Integer.class, glAccountId);
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.glaccount.not.found",
|
||||||
|
"GL account with id " + glAccountId + " does not exist.", "glAccountId", glAccountId);
|
||||||
|
}
|
||||||
|
if (GLAccountUsage.HEADER.getValue().equals(usage)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.glaccount.header.account.not.postable",
|
||||||
|
"GL account " + glAccountId + " is a Header account (a chart-of-accounts grouping node) and cannot receive a "
|
||||||
|
+ "transaction directly. Select a Detail account instead.",
|
||||||
|
"glAccountId", glAccountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
@@ -25,12 +25,14 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.fineract.accounting.closure.domain.GLClosure;
|
import org.apache.fineract.accounting.closure.domain.GLClosure;
|
||||||
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
|
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants;
|
import org.apache.fineract.accounting.common.AccountingConstants;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan;
|
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan;
|
||||||
|
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForSavings;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
|
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
|
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
|
||||||
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares;
|
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares;
|
||||||
@@ -77,6 +79,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData;
|
|||||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
|
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
|
||||||
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData;
|
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AccountingProcessorHelper {
|
public class AccountingProcessorHelper {
|
||||||
@@ -89,6 +92,14 @@ public class AccountingProcessorHelper {
|
|||||||
public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P";
|
public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P";
|
||||||
public static final String SHARE_TRANSACTION_IDENTIFIER = "SH";
|
public static final String SHARE_TRANSACTION_IDENTIFIER = "SH";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved names on the savings product's Accounting tab, naming the two accounts an interest
|
||||||
|
* posting is routed through. The names are the contract with the product screen; the GL codes
|
||||||
|
* behind them are the bank's choice, so no code is named here.
|
||||||
|
*/
|
||||||
|
public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable";
|
||||||
|
public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid";
|
||||||
|
|
||||||
private static final ThreadLocal<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100);
|
private static final ThreadLocal<Integer> DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100);
|
||||||
|
|
||||||
public static void setDepositAccountTypeContext(int depositAccountType) {
|
public static void setDepositAccountTypeContext(int depositAccountType) {
|
||||||
@@ -116,6 +127,10 @@ public class AccountingProcessorHelper {
|
|||||||
private final AccountTransfersReadPlatformService accountTransfersReadPlatformService;
|
private final AccountTransfersReadPlatformService accountTransfersReadPlatformService;
|
||||||
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
|
private final ChargeRepositoryWrapper chargeRepositoryWrapper;
|
||||||
private final BusinessEventNotifierService businessEventNotifierService;
|
private final BusinessEventNotifierService businessEventNotifierService;
|
||||||
|
// The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a
|
||||||
|
// JSON document rather than in acc_product_mapping, so they are read directly. See
|
||||||
|
// namedSavingsGlAccount.
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
public LoanDTO populateLoanDtoFromDTO(
|
public LoanDTO populateLoanDtoFromDTO(
|
||||||
final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) {
|
final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) {
|
||||||
@@ -600,6 +615,144 @@ public class AccountingProcessorHelper {
|
|||||||
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
|
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interest credited to a member, routed through the payable and paid accounts the product names.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* Dr Interest On Savings (expense) the bank incurs the cost
|
||||||
|
* Cr Savings Interest Payable (liability) and now owes it
|
||||||
|
*
|
||||||
|
* Dr Savings Interest Payable
|
||||||
|
* Cr Savings Interest Paid the debt is on its way out
|
||||||
|
*
|
||||||
|
* Dr Savings Interest Paid
|
||||||
|
* Cr Savings Control and reaches the member
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The two middle accounts net to nil across the three steps, so the trial balance is unchanged
|
||||||
|
* from the plain two-legged entry; what they add is a reading of how much interest has been
|
||||||
|
* declared but not yet credited, which a single Dr/Cr pair cannot show.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Both are read from the product's Accounting tab and neither is required. A product that names
|
||||||
|
* no paid account collapses the middle step, and one that names no payable falls back to its own
|
||||||
|
* Savings Control - either way the posting degrades to exactly the two-legged entry it was
|
||||||
|
* before, so products configured before these mappings existed are unaffected.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Reversals swap every leg, the same way {@code createCashBasedJournalEntriesAndReversalsForSavings}
|
||||||
|
* does for the two-legged case.
|
||||||
|
*/
|
||||||
|
public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode,
|
||||||
|
final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId,
|
||||||
|
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
|
||||||
|
|
||||||
|
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||||
|
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
|
||||||
|
|
||||||
|
// The payable comes from the Interest Payable row on the product's Accounting tab, which is
|
||||||
|
// where a bank actually picks it. The reserved name in the product's other GL mappings is
|
||||||
|
// consulted only after that, for a product configured before that row was mapped, and the
|
||||||
|
// Savings Control last of all - interest owed is a liability either way.
|
||||||
|
final GLAccount payableAccount = Optional
|
||||||
|
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||||
|
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
|
||||||
|
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)))
|
||||||
|
.orElse(controlAccount);
|
||||||
|
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
|
||||||
|
.orElse(payableAccount);
|
||||||
|
|
||||||
|
// The interest expense is not raised here. It is recognised as the interest accrues, day by
|
||||||
|
// day, and this posting settles that recognised debt - raising the expense again at payout
|
||||||
|
// would put the same interest on the P&L twice. This mirrors the pooled posting service,
|
||||||
|
// which pays a product's whole membership the same way; a closure reaching this path must
|
||||||
|
// produce the same entries as the nightly run, or the two disagree on what one payment did.
|
||||||
|
//
|
||||||
|
// Stage 1: the liability moves from the payable to the account it is paid out of. Where the
|
||||||
|
// product names no paid account both resolve to the payable and this says nothing, so it is
|
||||||
|
// skipped and the payout comes straight out of the payable.
|
||||||
|
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
|
||||||
|
if (!payableIsPaid) {
|
||||||
|
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
|
||||||
|
isReversal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 2: into the member's own balance.
|
||||||
|
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
|
||||||
|
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||||
|
isReversal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Dr/Cr pair, with both sides swapped when the transaction is a reversal.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A leg whose two sides are the same account writes nothing. That is not a defensive check but
|
||||||
|
* the mechanism by which the three stages above collapse: a product that names no payable and no
|
||||||
|
* paid account resolves all three to its Savings Control, and what should remain is the plain
|
||||||
|
* {@code Dr Interest On Savings / Cr Savings Control} pair Fineract has always posted. Skipping
|
||||||
|
* the middle stage by hand was not enough — stage 3 still resolved to Control against Control and
|
||||||
|
* wrote a pair of entries against one account, which nets to nothing yet leaves the transaction's
|
||||||
|
* debits and credits unequal per account and shows up on the trial balance as movement that never
|
||||||
|
* happened. Deciding it here rather than at each call site means every collapse works the same
|
||||||
|
* way, whichever of the two names a product happens to leave unset.
|
||||||
|
*/
|
||||||
|
private void postSavingsLeg(final Office office, final String currencyCode, final GLAccount debitAccount,
|
||||||
|
final GLAccount creditAccount, final Long savingsId, final String transactionId, final LocalDate transactionDate,
|
||||||
|
final BigDecimal amount, final boolean isReversal) {
|
||||||
|
if (debitAccount == null || creditAccount == null || debitAccount.getId().equals(creditAccount.getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final GLAccount toDebit = isReversal ? creditAccount : debitAccount;
|
||||||
|
final GLAccount toCredit = isReversal ? debitAccount : creditAccount;
|
||||||
|
createDebitJournalEntryForSavings(office, currencyCode, toDebit, savingsId, transactionId, transactionDate, amount);
|
||||||
|
createCreditJournalEntryForSavings(office, currencyCode, toCredit, savingsId, transactionId, transactionDate, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GL account the savings product maps under one of the reserved names on its Accounting tab.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* These live in {@code m_savings_product.other_gl_mappings} as a JSON document of
|
||||||
|
* {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in
|
||||||
|
* {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's
|
||||||
|
* fixed financial account types. The name is matched across all four groups: which group a bank
|
||||||
|
* files it under is a presentation choice, and an account configured under the wrong heading
|
||||||
|
* should still be honoured rather than silently ignored.
|
||||||
|
*
|
||||||
|
* @return the account, or null where the product does not configure this name
|
||||||
|
*/
|
||||||
|
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
|
||||||
|
if (savingsProductId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// The column holds an empty string on a product saved with no named mappings, and casting
|
||||||
|
// that to jsonb is an error rather than an empty document - so the cast is done inside a
|
||||||
|
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE
|
||||||
|
// clause in any order, so guarding at the same query level as the cast would only work by
|
||||||
|
// luck.
|
||||||
|
final List<Long> found = jdbcTemplate.queryForList("""
|
||||||
|
SELECT gl.id
|
||||||
|
FROM (SELECT other_gl_mappings::jsonb AS doc
|
||||||
|
FROM m_savings_product
|
||||||
|
WHERE id = ?
|
||||||
|
AND other_gl_mappings IS NOT NULL
|
||||||
|
AND other_gl_mappings <> ''
|
||||||
|
AND other_gl_mappings LIKE '{%') sp
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(
|
||||||
|
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|
||||||
|
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|
||||||
|
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|
||||||
|
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
|
||||||
|
) AS m(entry)
|
||||||
|
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
|
||||||
|
WHERE m.entry ->> 'accountName' = ?
|
||||||
|
LIMIT 1
|
||||||
|
""", Long.class, savingsProductId, accountName);
|
||||||
|
return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a direct transfer journal entry between two savings-type accounts (savings or current),
|
* Creates a direct transfer journal entry between two savings-type accounts (savings or current),
|
||||||
* each with their own product GL mapping and entity ID so the correct account number appears per entry.
|
* each with their own product GL mapping and entity ID so the correct account number appears per entry.
|
||||||
@@ -1241,6 +1394,29 @@ public class AccountingProcessorHelper {
|
|||||||
return accountMapping.getGlAccount();
|
return accountMapping.getGlAccount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the savings product's Accounting tab, or null where the bank has not filled it in.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Separate from {@link #getLinkedGLAccountForSavingsProduct}, which throws: not every row is
|
||||||
|
* required. Interest Payable is offered on the product screen but a product configured before it
|
||||||
|
* existed has none, and that has to fall through to the next choice rather than stopping the
|
||||||
|
* account from being closed.
|
||||||
|
*/
|
||||||
|
private GLAccount optionalLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
||||||
|
final Long paymentTypeId) {
|
||||||
|
if (savingsProductId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Read directly rather than by catching what getLinkedGLAccountForSavingsProduct throws: on a
|
||||||
|
// missing mapping that method builds its message through CashAccountsForSavings.fromInt,
|
||||||
|
// which returns null for a row the cash enum does not carry - Interest Payable among them -
|
||||||
|
// and the lookup would die on a NullPointerException instead of the exception meant for it.
|
||||||
|
final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId,
|
||||||
|
PortfolioProductType.SAVING.getValue(), accountMappingTypeId);
|
||||||
|
return mapping == null ? null : mapping.getGlAccount();
|
||||||
|
}
|
||||||
|
|
||||||
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
||||||
final Long paymentTypeId) {
|
final Long paymentTypeId) {
|
||||||
GLAccount glAccount;
|
GLAccount glAccount;
|
||||||
@@ -1263,6 +1439,11 @@ public class AccountingProcessorHelper {
|
|||||||
accountMapping = paymentChannelSpecificAccountMapping;
|
accountMapping = paymentChannelSpecificAccountMapping;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accountMapping == null) {
|
||||||
|
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
|
||||||
|
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
|
||||||
|
}
|
||||||
glAccount = accountMapping.getGlAccount();
|
glAccount = accountMapping.getGlAccount();
|
||||||
}
|
}
|
||||||
return glAccount;
|
return glAccount;
|
||||||
|
|||||||
+6
-3
@@ -170,9 +170,12 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
|
|||||||
// Post journal entry if earned interest amount is greater than
|
// Post journal entry if earned interest amount is greater than
|
||||||
// zero
|
// zero
|
||||||
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||||
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
|
// Routed through the payable and paid accounts the product names, so the society
|
||||||
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
// can read interest declared but not yet credited as its own balance. Collapses
|
||||||
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal);
|
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
|
||||||
|
// that names neither.
|
||||||
|
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||||
|
savingsId, transactionId, transactionDate, amount, isReversal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+132
-1
@@ -21,9 +21,11 @@ package org.apache.fineract.accounting.journalentry.service;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -104,6 +106,8 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepositor
|
|||||||
import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations;
|
import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.dao.NonTransientDataAccessException;
|
import org.springframework.dao.NonTransientDataAccessException;
|
||||||
@@ -136,6 +140,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
||||||
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
||||||
private final LoanTransactionRepository loanTransactionRepository;
|
private final LoanTransactionRepository loanTransactionRepository;
|
||||||
|
private final VoucherService voucherService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -232,14 +237,73 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raise the voucher from the entries that were actually written, rather than from the
|
||||||
|
// command: an accounting rule can supply a side the request never named, so reading
|
||||||
|
// back by transaction id is the only view that matches what the ledger now holds.
|
||||||
|
final String voucherNo = raiseJournalVoucher(transactionId);
|
||||||
|
if (voucherNo != null) {
|
||||||
|
changes.put("voucherNo", voucherNo);
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
|
||||||
.withTransactionId(transactionId).build();
|
.withTransactionId(transactionId).with(changes).build();
|
||||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||||
final Throwable throwable = dve.getMostSpecificCause();
|
final Throwable throwable = dve.getMostSpecificCause();
|
||||||
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
|
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a manual journal in the voucher register and returns its number.
|
||||||
|
*
|
||||||
|
* The sides are read back from the entries just written rather than taken from the request,
|
||||||
|
* because an accounting rule can contribute a debit or credit the caller never named; only the
|
||||||
|
* persisted rows show what the ledger actually holds.
|
||||||
|
*
|
||||||
|
* A failure here does not fail the journal. The entries are already posted and correct, and
|
||||||
|
* refusing them because the register could not be written would reject a sound accounting
|
||||||
|
* transaction over its paperwork. The problem is logged and the journal stands without a
|
||||||
|
* voucher, which reads as a gap in the register rather than as a missing ledger entry.
|
||||||
|
*/
|
||||||
|
private String raiseJournalVoucher(final String transactionId) {
|
||||||
|
// A register that posts its ledger movement through this command raises its own voucher,
|
||||||
|
// naming the member and the instrument. Raising a journal one beside it would put the same
|
||||||
|
// money in the register twice.
|
||||||
|
if (!this.voucherService.shouldRaiseVoucher()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final List<JournalEntry> entries = this.glJournalEntryRepository
|
||||||
|
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
||||||
|
if (CollectionUtils.isEmpty(entries)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<VoucherLine> lines = new ArrayList<>();
|
||||||
|
for (final JournalEntry entry : entries) {
|
||||||
|
final GLAccount account = entry.getGlAccount();
|
||||||
|
final Long accountId = account == null ? null : account.getId();
|
||||||
|
final String name = account == null ? null : account.getName();
|
||||||
|
final String code = account == null ? null : account.getGlCode();
|
||||||
|
|
||||||
|
final VoucherLine line = entry.isDebitEntry() ? VoucherLine.debitGl(accountId, name, code, entry.getAmount())
|
||||||
|
: VoucherLine.creditGl(accountId, name, code, entry.getAmount());
|
||||||
|
lines.add(entry.getDescription() == null ? line : line.withDescription(entry.getDescription()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debits first, so the voucher prints the way a slip is read.
|
||||||
|
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
return this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, VoucherService.REF_JOURNAL_ENTRY, null,
|
||||||
|
transactionId, currentUser, VoucherService.PREFIX_JOURNAL, lines);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Could not raise a voucher for journal transaction {}", transactionId, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
|
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
|
||||||
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
|
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
|
||||||
/**
|
/**
|
||||||
@@ -328,6 +392,9 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
} else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) {
|
} else if (!creditOrDebitAccountHead.isManualEntriesAllowed()) {
|
||||||
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null,
|
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_MANUAL_ENTRIES_NOT_PERMITTED, null,
|
||||||
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
|
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
|
||||||
|
} else if (creditOrDebitAccountHead.isHeaderAccount()) {
|
||||||
|
throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.GL_ACCOUNT_IS_HEADER, null,
|
||||||
|
creditOrDebitAccountHead.getName(), creditOrDebitAccountHead.getGlCode());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,15 +779,30 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
|
final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue());
|
||||||
|
|
||||||
validateBusinessRulesForJournalEntries(journalEntryCommand);
|
validateBusinessRulesForJournalEntries(journalEntryCommand);
|
||||||
|
validateNoDenominationManagedCashAccounts(journalEntryCommand);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* revert old journal entries
|
* revert old journal entries
|
||||||
|
*
|
||||||
|
* A transaction that carries a leg against the denomination-managed cash account (see
|
||||||
|
* validateNoDenominationManagedCashAccounts) is skipped here rather than reversed: it is
|
||||||
|
* tied to this same office+contra pairing (Migrate Cash Denomination posts its opening
|
||||||
|
* entry against this same Opening Balances Contra account), so the query above finds it
|
||||||
|
* too. Reversing it here, while the new submission is barred from ever re-posting a cash
|
||||||
|
* leg, would silently erase the cash account's opening balance every time an admin
|
||||||
|
* redefines opening balances for any OTHER account at this office.
|
||||||
*/
|
*/
|
||||||
|
final List<Long> denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds();
|
||||||
final List<String> transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId,
|
final List<String> transactionIdsToBeReversed = this.glJournalEntryRepository.findNonReversedContraTransactionIds(contraId,
|
||||||
officeId);
|
officeId);
|
||||||
for (String transactionId : transactionIdsToBeReversed) {
|
for (String transactionId : transactionIdsToBeReversed) {
|
||||||
final List<JournalEntry> journalEntries = this.glJournalEntryRepository
|
final List<JournalEntry> journalEntries = this.glJournalEntryRepository
|
||||||
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
||||||
|
final boolean touchesDenominationManagedCashAccount = journalEntries.stream()
|
||||||
|
.anyMatch(je -> je.getGlAccount() != null && denominationManagedGlAccountIds.contains(je.getGlAccount().getId()));
|
||||||
|
if (touchesDenominationManagedCashAccount) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
revertJournalEntry(journalEntries, "defining opening balance");
|
revertJournalEntry(journalEntries, "defining opening balance");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,6 +884,55 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL account(s) Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) map to. Shared
|
||||||
|
* by the validation below and by defineOpeningBalance's own revert step, so both agree on exactly
|
||||||
|
* which account(s) Migrate Cash Denomination owns.
|
||||||
|
*/
|
||||||
|
private List<Long> resolveDenominationManagedCashGlAccountIds() {
|
||||||
|
final List<Long> ids = new ArrayList<>();
|
||||||
|
for (final FinancialActivityAccount mapping : this.financialActivityAccountRepositoryWrapper.findAll()) {
|
||||||
|
final Integer type = mapping.getFinancialActivityType();
|
||||||
|
if ((type != null && (type == 101 || type == 102)) && mapping.getGlAccount() != null) {
|
||||||
|
ids.add(mapping.getGlAccount().getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL account(s) mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) are
|
||||||
|
* managed exclusively through Migrate Cash Denomination (BranchDayRegisterApiResource#migrateCashDenomination),
|
||||||
|
* which keeps the GL figure tied to an actual counted note-and-coin breakdown, and validates any
|
||||||
|
* redefinition against that same count. Defining or redefining that account's opening balance here
|
||||||
|
* instead - as a bare number, with no denomination breakdown behind it - would let the GL figure
|
||||||
|
* drift away from what is physically in the till, with nothing left to catch the mismatch. So this
|
||||||
|
* command refuses to touch either of those accounts at all, on either side of the entry, rather than
|
||||||
|
* only when the amount would differ from what is already posted: the physical count is the only
|
||||||
|
* source of truth for this account, never a number typed into a generic opening-balance grid.
|
||||||
|
*/
|
||||||
|
private void validateNoDenominationManagedCashAccounts(final JournalEntryCommand command) {
|
||||||
|
final List<Long> denominationManagedGlAccountIds = resolveDenominationManagedCashGlAccountIds();
|
||||||
|
if (denominationManagedGlAccountIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final List<SingleDebitOrCreditEntryCommand> allEntries = new ArrayList<>();
|
||||||
|
if (command.getDebits() != null) {
|
||||||
|
allEntries.addAll(Arrays.asList(command.getDebits()));
|
||||||
|
}
|
||||||
|
if (command.getCredits() != null) {
|
||||||
|
allEntries.addAll(Arrays.asList(command.getCredits()));
|
||||||
|
}
|
||||||
|
for (final SingleDebitOrCreditEntryCommand entry : allEntries) {
|
||||||
|
if (denominationManagedGlAccountIds.contains(entry.getGlAccountId())) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.journalentry.opening.balance.cash.account.locked",
|
||||||
|
"This account is the tenant's Cash at Vault / Cash at Teller account and is managed through "
|
||||||
|
+ "Migrate Cash Denomination - it cannot be set or changed from Migrate Opening Balances.",
|
||||||
|
entry.getGlAccountId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) {
|
public void createJournalEntriesForClientTransactions(Map<String, Object> accountingBridgeData) {
|
||||||
final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData);
|
final ClientTransactionDTO clientTransactionDTO = this.helper.populateClientTransactionDtoFromMap(accountingBridgeData);
|
||||||
|
|||||||
+6
-4
@@ -52,6 +52,7 @@ import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
|
|||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -67,10 +68,10 @@ public class AccountingJournalEntryConfiguration {
|
|||||||
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
|
FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository, GLClosureRepository closureRepository,
|
||||||
GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
|
GLAccountRepository glAccountRepository, OfficeRepository officeRepository,
|
||||||
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
|
AccountTransfersReadPlatformService accountTransfersReadPlatformService, ChargeRepositoryWrapper chargeRepositoryWrapper,
|
||||||
BusinessEventNotifierService businessEventNotifierService) {
|
BusinessEventNotifierService businessEventNotifierService, JdbcTemplate jdbcTemplate) {
|
||||||
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
|
return new AccountingProcessorHelper(glJournalEntryRepository, accountMappingRepository, financialActivityAccountRepository,
|
||||||
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
|
closureRepository, glAccountRepository, officeRepository, accountTransfersReadPlatformService, chargeRepositoryWrapper,
|
||||||
businessEventNotifierService);
|
businessEventNotifierService, jdbcTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@@ -98,12 +99,13 @@ public class AccountingJournalEntryConfiguration {
|
|||||||
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
||||||
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
||||||
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
||||||
LoanTransactionRepository loanTransactionRepository) {
|
LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) {
|
||||||
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
||||||
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
||||||
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
||||||
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
||||||
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
|
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
|
||||||
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository);
|
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository,
|
||||||
|
voucherService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -301,6 +301,15 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
|
|||||||
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
|
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional: only mapped when the product screen supplies it. Every savings product that
|
||||||
|
// existed before this row was added has none, and a closure on one of those still has to
|
||||||
|
// work — see the closure's own fallback.
|
||||||
|
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element)) {
|
||||||
|
this.savingsProductToGLAccountMappingHelper.saveSavingsToExpenseAccountMapping(element,
|
||||||
|
SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), savingProductId,
|
||||||
|
CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
// advanced accounting mappings
|
// advanced accounting mappings
|
||||||
this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null);
|
this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null);
|
||||||
this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null);
|
this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null);
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.accounting.profitandloss.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.sql.Date;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much profit is available to fund a share dividend, for a given period.
|
||||||
|
*
|
||||||
|
* This is not what sizes a dividend — that is the declared rate on members' paid-up capital. It is what the society has
|
||||||
|
* to pay it with: the profit standing in the Bank Profit (Non-Distributable) GL, less every dividend already declared
|
||||||
|
* for another share product over the same period, since they all draw on the same balance.
|
||||||
|
*
|
||||||
|
* The statutory reserve and education fund are not deducted here. They are appropriated on the profit-distribution
|
||||||
|
* screen before any dividend is declared, so the balance this reads has already had them taken out; deducting them a
|
||||||
|
* second time would hold back money that has already left the account.
|
||||||
|
*
|
||||||
|
* Held in one place so the declaration screen and the declaration-time validation cannot disagree about the ceiling.
|
||||||
|
*/
|
||||||
|
@Path("/v1/accounting/distributable-profit")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Distributable Profit", description = "Profit available to fund a share dividend, less dividends already declared against the same period.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DistributableProfitApiResource {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The year's closed-out profit, which is what a dividend may be declared against.
|
||||||
|
*
|
||||||
|
* Deliberately not Retained Profit — that holds what earlier declarations left behind, and reading it here would
|
||||||
|
* offer profit already distributed once for distribution again.
|
||||||
|
*/
|
||||||
|
private final DividendGlAccountResolver dividendGlAccounts;
|
||||||
|
|
||||||
|
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String getDistributableProfit(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||||
|
@QueryParam("excludeProductId") final Long excludeProductId) {
|
||||||
|
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
||||||
|
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
|
||||||
|
}
|
||||||
|
final Date from;
|
||||||
|
final Date to;
|
||||||
|
try {
|
||||||
|
from = Date.valueOf(fromDate);
|
||||||
|
to = Date.valueOf(toDate);
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
|
||||||
|
}
|
||||||
|
if (to.before(from)) {
|
||||||
|
throw apiError(400, "toDate must not be before fromDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dividend is normally declared for a whole, closed financial year, and then the audited
|
||||||
|
// profit in Bank Profit is the figure to use. A period that is not a full financial year can
|
||||||
|
// only be an interim declaration, which necessarily runs against the still-open P&L.
|
||||||
|
final String financialYear = financialYearOf(from.toLocalDate());
|
||||||
|
final LocalDate[] fyRange = financialYearRange(financialYear);
|
||||||
|
final boolean coversWholeFinancialYear = !from.toLocalDate().isAfter(fyRange[0]) && !to.toLocalDate().isBefore(fyRange[1]);
|
||||||
|
|
||||||
|
final BigDecimal closedProfit = coversWholeFinancialYear ? closedProfitForFinancialYear(financialYear) : null;
|
||||||
|
final boolean yearClosed = closedProfit != null;
|
||||||
|
final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to);
|
||||||
|
|
||||||
|
final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE);
|
||||||
|
|
||||||
|
// The statutory reserve and education fund are NOT deducted here. They are appropriated on
|
||||||
|
// the profit-distribution screen before any dividend is declared, so the profit this reads
|
||||||
|
// has already had them taken out — subtracting them again would hold back money that is no
|
||||||
|
// longer in the account. A loss-making period funds nothing, so it collapses to zero rather
|
||||||
|
// than a negative figure a caller could read as a credit.
|
||||||
|
final BigDecimal distributableProfit = netProfit.signum() > 0 ? netProfit : BigDecimal.ZERO;
|
||||||
|
|
||||||
|
final List<Map<String, Object>> existingDeclarations = declarationsOverlapping(from, to, excludeProductId);
|
||||||
|
BigDecimal alreadyDeclared = BigDecimal.ZERO;
|
||||||
|
for (final Map<String, Object> declaration : existingDeclarations) {
|
||||||
|
alreadyDeclared = alreadyDeclared.add((BigDecimal) declaration.get("amount"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// An over-declaration that already happened (or appropriation rates raised after the fact)
|
||||||
|
// must not present as negative headroom, which a caller could read as a credit.
|
||||||
|
final BigDecimal availableForDeclaration = distributableProfit.subtract(alreadyDeclared).max(BigDecimal.ZERO);
|
||||||
|
|
||||||
|
final Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("fromDate", fromDate);
|
||||||
|
response.put("toDate", toDate);
|
||||||
|
response.put("financialYear", financialYear);
|
||||||
|
response.put("coversWholeFinancialYear", coversWholeFinancialYear);
|
||||||
|
response.put("yearClosed", yearClosed);
|
||||||
|
// Names the figure so the screen can say where it came from — the closed books, or a P&L
|
||||||
|
// that is still moving — rather than showing a bare number the officer has to interpret.
|
||||||
|
response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS");
|
||||||
|
response.put("netProfit", netProfit);
|
||||||
|
response.put("distributableProfit", distributableProfit);
|
||||||
|
response.put("alreadyDeclared", alreadyDeclared);
|
||||||
|
response.put("availableForDeclaration", availableForDeclaration);
|
||||||
|
response.put("maximumRatePercentage", maximumRatePercentage);
|
||||||
|
response.put("existingDeclarations", existingDeclarations);
|
||||||
|
return gson.toJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The year's profit as the books actually record it, once the year has been closed.
|
||||||
|
*
|
||||||
|
* A dividend is declared out of the AUDITED profit of a completed financial year, and that figure lives in the
|
||||||
|
* Bank Profit equity GL after year-end closing moves every income and expense GL into it. Reading it here — rather
|
||||||
|
* than re-deriving income-less-expense — means the dividend can never disagree with the closed books: if a
|
||||||
|
* closing entry was adjusted, reversed, or posted late, the dividend ceiling moves with it.
|
||||||
|
*
|
||||||
|
* Returns null when the year has not been closed, which the caller reports as a blocking condition rather than
|
||||||
|
* silently falling back to the live P&L. Distributing against an unclosed year would be distributing a profit
|
||||||
|
* figure that is still moving.
|
||||||
|
*/
|
||||||
|
private BigDecimal closedProfitForFinancialYear(final String financialYear) {
|
||||||
|
final Integer closedCount = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear);
|
||||||
|
if (closedCount == null || closedCount == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final LocalDate[] range = financialYearRange(financialYear);
|
||||||
|
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
|
||||||
|
FROM acc_gl_journal_entry je
|
||||||
|
WHERE je.account_id = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
||||||
|
""", BigDecimal.class, dividendGlAccounts.distributableProfitAccount(), java.sql.Date.valueOf(range[0]),
|
||||||
|
java.sql.Date.valueOf(range[1]));
|
||||||
|
return balance == null ? BigDecimal.ZERO : balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2026-27" -> 1 April 2026 to 31 March 2027. */
|
||||||
|
private LocalDate[] financialYearRange(final String financialYear) {
|
||||||
|
try {
|
||||||
|
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
|
||||||
|
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
throw apiError(400, "financialYear must look like 2026-27. Received: " + financialYear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Indian financial year a date falls in, as a "YYYY-YY" label. */
|
||||||
|
private String financialYearOf(final LocalDate date) {
|
||||||
|
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
||||||
|
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same income-less-expense computation as {@link ProfitAndLossApiResource}, over posted, non-reversed journal
|
||||||
|
* entries only. Used only for the interim case — a period inside a year that has not been closed yet.
|
||||||
|
*/
|
||||||
|
private BigDecimal netProfitForPeriod(final Date from, final Date to) {
|
||||||
|
final Map<String, Object> row = jdbcTemplate.queryForMap("""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
|
||||||
|
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
|
||||||
|
ELSE 0 END), 0) AS total_income,
|
||||||
|
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
|
||||||
|
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
|
||||||
|
ELSE 0 END), 0) AS total_expense
|
||||||
|
FROM acc_gl_journal_entry je
|
||||||
|
JOIN acc_gl_account a ON a.id = je.account_id
|
||||||
|
WHERE je.reversed = false
|
||||||
|
AND je.entry_date BETWEEN ? AND ?
|
||||||
|
""", from, to);
|
||||||
|
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
|
||||||
|
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
|
||||||
|
return totalIncome.subtract(totalExpense);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every dividend already declared against a period overlapping this one, across ALL share products — the profit is
|
||||||
|
* society-wide, so a second product's declaration competes for the same balance as the first.
|
||||||
|
*
|
||||||
|
* excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping
|
||||||
|
* declaration is separately blocked outright.
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> declarationsOverlapping(final Date from, final Date to, final Long excludeProductId) {
|
||||||
|
final StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT d.id AS declaration_id, d.product_id, p.name AS product_name, d.amount,
|
||||||
|
d.dividend_period_start_date, d.dividend_period_end_date, d.status
|
||||||
|
FROM m_share_product_dividend_pay_out d
|
||||||
|
JOIN m_share_product p ON p.id = d.product_id
|
||||||
|
WHERE d.dividend_period_start_date <= ? AND d.dividend_period_end_date >= ?
|
||||||
|
""");
|
||||||
|
final List<Object> params = new ArrayList<>(List.of(to, from));
|
||||||
|
if (excludeProductId != null) {
|
||||||
|
sql.append(" AND d.product_id <> ? ");
|
||||||
|
params.add(excludeProductId);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY d.dividend_period_start_date, d.id");
|
||||||
|
|
||||||
|
final List<Map<String, Object>> declarations = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
|
||||||
|
final Map<String, Object> declaration = new LinkedHashMap<>();
|
||||||
|
declaration.put("declarationId", row.get("declaration_id"));
|
||||||
|
declaration.put("productId", row.get("product_id"));
|
||||||
|
declaration.put("productName", row.get("product_name"));
|
||||||
|
declaration.put("amount", row.get("amount"));
|
||||||
|
declaration.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
|
||||||
|
declaration.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date")));
|
||||||
|
declaration.put("status", row.get("status"));
|
||||||
|
declarations.add(declaration);
|
||||||
|
}
|
||||||
|
return declarations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A missing or disabled configuration row means the society does not make that appropriation (or has no ceiling),
|
||||||
|
* which is 0 — not a failure. Only an enabled row with a value applies.
|
||||||
|
*/
|
||||||
|
private BigDecimal configuredPercentage(final String configurationName) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate
|
||||||
|
.queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
|
||||||
|
if (rows.isEmpty() || rows.get(0).get("value") == null) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
|
return new WebApplicationException(
|
||||||
|
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
package org.apache.fineract.accounting.yearend.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Financial year-end closing of the share entry fee into the Bank Profit equity GL.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* {@code GET} reports how much is waiting to be posted for the year and what has been posted before; {@code POST}
|
||||||
|
* performs the closing. The scheduled job {@code Post Share Entry Fee To Profit} runs the same service on 31 March, so
|
||||||
|
* this endpoint is the manual equivalent — useful when the job did not run, or the accountant wants to close early.
|
||||||
|
*/
|
||||||
|
@Path("/v1/accounting/incomeprofittransfer")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class IncomeProfitTransferApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final IncomeProfitTransferService service;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// GET — how much is waiting to be posted, and what has been posted before
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
@GET
|
||||||
|
public Response preview(@QueryParam("incomeGlCode") final String incomeGlCodeParam,
|
||||||
|
@QueryParam("financialYear") final String financialYearParam) {
|
||||||
|
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
final String incomeGlCode = incomeGlCodeParam == null || incomeGlCodeParam.isBlank()
|
||||||
|
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
||||||
|
: incomeGlCodeParam.trim();
|
||||||
|
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
|
||||||
|
final Map<String, Object> profitGl = service.loadDistributableProfitGl();
|
||||||
|
|
||||||
|
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
|
||||||
|
: financialYearParam.trim();
|
||||||
|
final LocalDate[] range = service.financialYearRange(financialYear);
|
||||||
|
|
||||||
|
final Long incomeGlId = toLong(incomeGl.get("id"));
|
||||||
|
final BigDecimal pending = service.balanceOf(incomeGlId, range);
|
||||||
|
final int entryCount = service.entryCountOf(incomeGlId, range);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> history = jdbcTemplate
|
||||||
|
.queryForList("SELECT t.id, t.financial_year, t.amount, t.entry_count, t.posted_on_date, t.posted_by_name, "
|
||||||
|
+ " t.gl_transaction_id, g.gl_code AS income_gl_code, g.name AS income_gl_name "
|
||||||
|
+ " FROM m_income_profit_transfer t JOIN acc_gl_account g ON g.id = t.income_gl_id "
|
||||||
|
+ " WHERE t.income_gl_id = ? ORDER BY t.financial_year DESC, t.id DESC", incomeGlId);
|
||||||
|
|
||||||
|
final boolean alreadyPosted = history.stream().anyMatch(h -> financialYear.equals(h.get("financial_year")));
|
||||||
|
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("financialYear", financialYear);
|
||||||
|
out.put("fromDate", range[0].toString());
|
||||||
|
out.put("toDate", range[1].toString());
|
||||||
|
out.put("incomeGlCode", incomeGl.get("gl_code"));
|
||||||
|
out.put("incomeGlName", incomeGl.get("name"));
|
||||||
|
out.put("profitGlCode", profitGl.get("gl_code"));
|
||||||
|
out.put("profitGlName", profitGl.get("name"));
|
||||||
|
out.put("pendingAmount", pending);
|
||||||
|
out.put("entryCount", entryCount);
|
||||||
|
out.put("alreadyPosted", alreadyPosted);
|
||||||
|
out.put("postedHistory", history);
|
||||||
|
out.put("totalPostedToProfit", history.stream().map(h -> (BigDecimal) h.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||||
|
return Response.ok(gson.toJson(out)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// POST — close the income GL into Bank Profit
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
@POST
|
||||||
|
@Transactional
|
||||||
|
public Response post(final String body) {
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||||
|
|
||||||
|
final JsonObject json = parseBody(body);
|
||||||
|
final String incomeGlCode = getString(json, "incomeGlCode") == null ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
||||||
|
: getString(json, "incomeGlCode").trim();
|
||||||
|
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
|
||||||
|
: getString(json, "financialYear").trim();
|
||||||
|
|
||||||
|
// strict = true: a manual posting reports "already closed" or "nothing to post" as an
|
||||||
|
// error, so the user is told why nothing happened.
|
||||||
|
final Map<String, Object> result = service.postToProfit(incomeGlCode, financialYear, currentUser.getId(), currentUser.getUsername(),
|
||||||
|
sanitiseNote(getString(json, "note")), true);
|
||||||
|
|
||||||
|
return Response.ok(gson.toJson(result)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// POST /close-year — close the WHOLE P&L into Bank Profit
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes every income and expense GL for the year, so Bank Profit ends up holding the year's real net profit.
|
||||||
|
*
|
||||||
|
* This is the full year-end close; the endpoint above closes one named income GL and remains for the narrower
|
||||||
|
* share-entry-fee case. The share dividend declaration reads Bank Profit as the year's profit, so it is this that
|
||||||
|
* has to have run — after a single-GL posting the rest of the profit is still sitting in the income accounts.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("/close-year")
|
||||||
|
@Transactional
|
||||||
|
public Response closeYear(final String body) {
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||||
|
|
||||||
|
final JsonObject json = parseBody(body);
|
||||||
|
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
|
||||||
|
: getString(json, "financialYear").trim();
|
||||||
|
|
||||||
|
// strict = true: a manual close reports "nothing left to close" as an error, so the user is
|
||||||
|
// told why nothing happened rather than getting a silent success.
|
||||||
|
final Map<String, Object> result = service.closeAllToProfit(financialYear, currentUser.getId(), currentUser.getUsername(),
|
||||||
|
sanitiseNote(getString(json, "note")), true);
|
||||||
|
|
||||||
|
return Response.ok(gson.toJson(result)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// Helpers
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private JsonObject parseBody(final String body) {
|
||||||
|
if (body == null || body.isBlank()) {
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.incomeprofit.request.invalid", "Request body must be valid JSON.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitiseNote(final String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String trimmed = raw.trim();
|
||||||
|
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getString(final JsonObject json, final String field) {
|
||||||
|
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long toLong(final Object o) {
|
||||||
|
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
|
||||||
|
|
||||||
|
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
||||||
|
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.Step;
|
||||||
|
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||||
|
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||||
|
import org.springframework.batch.core.repository.JobRepository;
|
||||||
|
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class PostShareEntryFeeToProfitConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobRepository jobRepository;
|
||||||
|
@Autowired
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
@Autowired
|
||||||
|
private IncomeProfitTransferService incomeProfitTransferService;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
protected Step postShareEntryFeeToProfitStep() {
|
||||||
|
return new StepBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository)
|
||||||
|
.tasklet(postShareEntryFeeToProfitTasklet(), transactionManager).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job postShareEntryFeeToProfitJob() {
|
||||||
|
return new JobBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository).start(postShareEntryFeeToProfitStep())
|
||||||
|
.incrementer(new RunIdIncrementer()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PostShareEntryFeeToProfitTasklet postShareEntryFeeToProfitTasklet() {
|
||||||
|
return new PostShareEntryFeeToProfitTasklet(incomeProfitTransferService);
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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.accounting.yearend.jobs.postshareentryfeetoprofit;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
||||||
|
import org.springframework.batch.core.StepContribution;
|
||||||
|
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||||
|
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||||
|
import org.springframework.batch.repeat.RepeatStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Year-end closing of the share entry fee: on 31 March the balance of Share Entry Fee Income is posted to the Bank
|
||||||
|
* Profit equity GL, leaving the income account at nil for the new year.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Runs unattended, so a no-op (already closed for the year, or no fee collected) is a normal outcome and is logged
|
||||||
|
* rather than failing the job. Only a genuine posting failure is raised.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PostShareEntryFeeToProfitTasklet implements Tasklet {
|
||||||
|
|
||||||
|
/** Fineract's system user id, used because a scheduled run has no logged-in user. */
|
||||||
|
private static final long SYSTEM_USER_ID = 1L;
|
||||||
|
|
||||||
|
private final IncomeProfitTransferService incomeProfitTransferService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||||
|
final String financialYear = incomeProfitTransferService.currentFinancialYear();
|
||||||
|
|
||||||
|
// Closes the WHOLE P&L, not just the share entry fee it was originally written for. Bank Profit
|
||||||
|
// is now what the share dividend declaration reads as "the year's profit", and a partial close
|
||||||
|
// would leave most of the profit still sitting in the income GLs — understating what may be
|
||||||
|
// distributed. Per-GL guards inside closeAllToProfit make a re-run safe.
|
||||||
|
final Map<String, Object> result = incomeProfitTransferService.closeAllToProfit(financialYear, SYSTEM_USER_ID, "system",
|
||||||
|
"Automatic year-end closing", false);
|
||||||
|
|
||||||
|
log.info("Year-end closing for {}: {}", financialYear, result.get("message"));
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
+396
@@ -0,0 +1,396 @@
|
|||||||
|
package org.apache.fineract.accounting.yearend.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.accounting.common.DividendGlAccountResolver;
|
||||||
|
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes an income GL into the Bank Profit equity GL at financial year end.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* Dr Share Entry Fee Income (10.4) = balance for the year
|
||||||
|
* Cr Distributable Profit (the GL mapped to that financial activity)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
|
||||||
|
* retained profit.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Shared by the scheduled year-end job and the manual posting endpoint, so both paths post identical entries and are
|
||||||
|
* protected by the same "once per year" guard. The caller supplies the acting user id because the job runs without a
|
||||||
|
* logged-in user.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class IncomeProfitTransferService {
|
||||||
|
|
||||||
|
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||||
|
private static final int DEBIT = 2;
|
||||||
|
private static final int CREDIT = 1;
|
||||||
|
|
||||||
|
/** Income GL closed to profit by the year-end job — the share entry fee. */
|
||||||
|
public static final String DEFAULT_INCOME_GL_CODE = "10.4";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The year's profit is closed into the Distributable Profit account, not the Retained one.
|
||||||
|
*
|
||||||
|
* A dividend is declared against this balance; whatever the members do not receive moves across to Retained Profit
|
||||||
|
* once the declaration is approved. Closing into Retained directly would mix the year just ended with earlier
|
||||||
|
* years' leftovers, and the next declaration would be sized on profit that had already been through a distribution
|
||||||
|
* decision — offering the same money for distribution twice.
|
||||||
|
*
|
||||||
|
* Both accounts are configured on Financial Activity Mappings; this was the hardcoded GL code BANKPROFITND01.
|
||||||
|
*/
|
||||||
|
private final DividendGlAccountResolver dividendGlAccounts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when
|
||||||
|
* there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
|
||||||
|
* errors.
|
||||||
|
*
|
||||||
|
* @param strict
|
||||||
|
* when true (manual posting) a no-op raises an error so the user sees why; when false (scheduled job) it
|
||||||
|
* returns quietly.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> postToProfit(final String incomeGlCode, final String financialYear, final Long userId, final String userName,
|
||||||
|
final String note, final boolean strict) {
|
||||||
|
|
||||||
|
final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode);
|
||||||
|
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||||
|
final Long incomeGlId = toLong(incomeGl.get("id"));
|
||||||
|
final Long profitGlId = toLong(profitGl.get("id"));
|
||||||
|
|
||||||
|
final LocalDate[] range = financialYearRange(financialYear);
|
||||||
|
final Long officeId = headOfficeId();
|
||||||
|
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("financialYear", financialYear);
|
||||||
|
out.put("incomeGlCode", incomeGl.get("gl_code"));
|
||||||
|
out.put("incomeGlName", incomeGl.get("name"));
|
||||||
|
out.put("profitGlCode", profitGl.get("gl_code"));
|
||||||
|
out.put("profitGlName", profitGl.get("name"));
|
||||||
|
|
||||||
|
final Integer existing = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
|
||||||
|
Integer.class, financialYear, incomeGlId, officeId);
|
||||||
|
if (existing != null && existing > 0) {
|
||||||
|
if (strict) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.incomeprofit.already.posted",
|
||||||
|
incomeGl.get("name") + " has already been closed to profit for " + financialYear + ".");
|
||||||
|
}
|
||||||
|
out.put("posted", false);
|
||||||
|
out.put("reason", "Already closed for " + financialYear);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal balance = balanceOf(incomeGlId, range);
|
||||||
|
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
if (strict) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.post",
|
||||||
|
"There is no income in " + incomeGl.get("name") + " to close for " + financialYear + ".");
|
||||||
|
}
|
||||||
|
out.put("posted", false);
|
||||||
|
out.put("reason", "No income to close for " + financialYear);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int entryCount = entryCountOf(incomeGlId, range);
|
||||||
|
final String currencyCode = resolveCurrency(incomeGlId);
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
final LocalDate postingDate = range[1];
|
||||||
|
final String txnId = "YEPROFIT-" + System.nanoTime();
|
||||||
|
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
|
||||||
|
+ (note != null ? " | " + note : "");
|
||||||
|
|
||||||
|
insertEntry(officeId, incomeGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, desc);
|
||||||
|
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
|
||||||
|
"Profit for " + financialYear + " from " + incomeGl.get("name"));
|
||||||
|
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO m_income_profit_transfer "
|
||||||
|
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
|
||||||
|
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
|
||||||
|
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
financialYear, incomeGlId, profitGlId, officeId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance,
|
||||||
|
entryCount, currencyCode, txnId, java.sql.Date.valueOf(postingDate), userId, userName, note);
|
||||||
|
|
||||||
|
log.info("Year-end profit posting: {} {} from {} to {} for {}", balance, currencyCode, incomeGl.get("name"), profitGl.get("name"),
|
||||||
|
financialYear);
|
||||||
|
|
||||||
|
out.put("posted", true);
|
||||||
|
out.put("amountPostedToProfit", balance);
|
||||||
|
out.put("entryCount", entryCount);
|
||||||
|
out.put("postedOnDate", postingDate.toString());
|
||||||
|
out.put("glTransactionId", txnId);
|
||||||
|
out.put("currency", currencyCode);
|
||||||
|
out.put("message", balance + " " + currencyCode + " posted to " + profitGl.get("name") + " for " + financialYear + ".");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes EVERY income and expense GL into Bank Profit for the year, leaving the equity GL holding the year's real
|
||||||
|
* net profit.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* each income GL: Dr Income GL Cr Bank Profit (income raises profit)
|
||||||
|
* each expense GL: Dr Bank Profit Cr Expense GL (expense reduces profit)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* {@link #postToProfit} closes one named income GL and is what the legacy share-entry-fee job calls; this closes
|
||||||
|
* the whole P&L, which is what an actual year-end close is. Anything reading Bank Profit as "the year's profit" —
|
||||||
|
* the share dividend declaration in particular — depends on this having run, because a partial close leaves the
|
||||||
|
* profit split across two places and understates it.
|
||||||
|
*
|
||||||
|
* Each GL still gets its own m_income_profit_transfer row, so the same "once per year per GL" guard applies: a
|
||||||
|
* re-run after new GL accounts appear closes only the ones not yet closed, and never double-posts one that was.
|
||||||
|
* Accounts with a nil balance are skipped rather than posted as zero entries.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
|
||||||
|
final boolean strict) {
|
||||||
|
|
||||||
|
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||||
|
final Long profitGlId = toLong(profitGl.get("id"));
|
||||||
|
final LocalDate[] range = financialYearRange(financialYear);
|
||||||
|
final Long officeId = headOfficeId();
|
||||||
|
|
||||||
|
// 4 = INCOME, 5 = EXPENSE (GLAccountType). Disabled accounts are excluded: they can hold a
|
||||||
|
// historical balance but must not attract new postings.
|
||||||
|
final List<Map<String, Object>> plAccounts = jdbcTemplate.queryForList(
|
||||||
|
"SELECT id, gl_code, name, classification_enum FROM acc_gl_account "
|
||||||
|
+ " WHERE classification_enum IN (4, 5) AND disabled = false ORDER BY classification_enum, gl_code");
|
||||||
|
|
||||||
|
final List<Map<String, Object>> closed = new ArrayList<>();
|
||||||
|
final List<Map<String, Object>> skipped = new ArrayList<>();
|
||||||
|
BigDecimal totalIncome = BigDecimal.ZERO;
|
||||||
|
BigDecimal totalExpense = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
for (final Map<String, Object> gl : plAccounts) {
|
||||||
|
final Long glId = toLong(gl.get("id"));
|
||||||
|
final boolean isIncome = ((Number) gl.get("classification_enum")).intValue() == 4;
|
||||||
|
|
||||||
|
final Integer existing = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
|
||||||
|
Integer.class, financialYear, glId, officeId);
|
||||||
|
if (existing != null && existing > 0) {
|
||||||
|
skipped.add(skipRow(gl, "Already closed for " + financialYear));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// balanceOf returns credit-less-debit. For an income GL that is the income earned; for an
|
||||||
|
// expense GL the natural balance is a debit, so the same figure comes back negative and
|
||||||
|
// is flipped here to read as a positive expense.
|
||||||
|
final BigDecimal rawBalance = balanceOf(glId, range);
|
||||||
|
final BigDecimal balance = isIncome ? rawBalance : rawBalance.negate();
|
||||||
|
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
skipped.add(skipRow(gl, "Nothing to close for " + financialYear));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int entryCount = entryCountOf(glId, range);
|
||||||
|
final String currencyCode = resolveCurrency(glId);
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
final LocalDate postingDate = range[1];
|
||||||
|
final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime();
|
||||||
|
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
|
||||||
|
+ (note != null ? " | " + note : "");
|
||||||
|
|
||||||
|
if (isIncome) {
|
||||||
|
insertEntry(officeId, glId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
|
||||||
|
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
|
||||||
|
"Profit for " + financialYear + " from " + gl.get("name"));
|
||||||
|
totalIncome = totalIncome.add(balance);
|
||||||
|
} else {
|
||||||
|
insertEntry(officeId, glId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
|
||||||
|
insertEntry(officeId, profitGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode,
|
||||||
|
"Expense for " + financialYear + " from " + gl.get("name"));
|
||||||
|
totalExpense = totalExpense.add(balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.update("INSERT INTO m_income_profit_transfer "
|
||||||
|
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
|
||||||
|
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
|
||||||
|
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", financialYear, glId, profitGlId, officeId,
|
||||||
|
java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, entryCount, currencyCode, txnId,
|
||||||
|
java.sql.Date.valueOf(postingDate), userId, userName, note);
|
||||||
|
|
||||||
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("glCode", gl.get("gl_code"));
|
||||||
|
row.put("glName", gl.get("name"));
|
||||||
|
row.put("type", isIncome ? "INCOME" : "EXPENSE");
|
||||||
|
row.put("amount", balance);
|
||||||
|
row.put("entryCount", entryCount);
|
||||||
|
row.put("glTransactionId", txnId);
|
||||||
|
closed.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closed.isEmpty() && strict) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.close",
|
||||||
|
"There is nothing left to close to profit for " + financialYear + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
|
||||||
|
log.info("Year-end close {}: income {}, expense {}, net profit {} posted to {}", financialYear, totalIncome, totalExpense,
|
||||||
|
netProfit, profitGl.get("name"));
|
||||||
|
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("financialYear", financialYear);
|
||||||
|
out.put("fromDate", range[0].toString());
|
||||||
|
out.put("toDate", range[1].toString());
|
||||||
|
out.put("profitGlCode", profitGl.get("gl_code"));
|
||||||
|
out.put("profitGlName", profitGl.get("name"));
|
||||||
|
out.put("totalIncomeClosed", totalIncome);
|
||||||
|
out.put("totalExpenseClosed", totalExpense);
|
||||||
|
out.put("netProfitPosted", netProfit);
|
||||||
|
out.put("accountsClosed", closed);
|
||||||
|
out.put("accountsSkipped", skipped);
|
||||||
|
out.put("message", closed.size() + " account(s) closed to " + profitGl.get("name") + " for " + financialYear + "; net profit "
|
||||||
|
+ netProfit + ".");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> skipRow(final Map<String, Object> gl, final String reason) {
|
||||||
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("glCode", gl.get("gl_code"));
|
||||||
|
row.put("glName", gl.get("name"));
|
||||||
|
row.put("reason", reason);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The year's profit as it stands in the Bank Profit equity GL — what a dividend may be declared out of once the
|
||||||
|
* year has been closed.
|
||||||
|
*
|
||||||
|
* Only entries dated inside the financial year count, so each year's profit is separable rather than being one
|
||||||
|
* ever-growing equity balance. Returns zero (not an error) when the year has not been closed yet; callers decide
|
||||||
|
* whether that is a blocking condition.
|
||||||
|
*/
|
||||||
|
public BigDecimal profitForFinancialYear(final String financialYear) {
|
||||||
|
final LocalDate[] range = financialYearRange(financialYear);
|
||||||
|
final Map<String, Object> profitGl = loadDistributableProfitGl();
|
||||||
|
final BigDecimal balance = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
|
||||||
|
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
|
||||||
|
BigDecimal.class, CREDIT, toLong(profitGl.get("id")), java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||||
|
return balance == null ? BigDecimal.ZERO : balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the year's P&L has been closed into Bank Profit at all. */
|
||||||
|
public boolean isYearClosed(final String financialYear) {
|
||||||
|
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?",
|
||||||
|
Integer.class, financialYear);
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Net credit balance sitting in the income GL for the year. */
|
||||||
|
public BigDecimal balanceOf(final Long incomeGlId, final LocalDate[] range) {
|
||||||
|
final BigDecimal balance = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
|
||||||
|
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + " AND entry_date BETWEEN ? AND ?",
|
||||||
|
BigDecimal.class, CREDIT, incomeGlId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||||
|
return balance == null ? BigDecimal.ZERO : balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many fee postings made up that balance. */
|
||||||
|
public int entryCountOf(final Long incomeGlId, final LocalDate[] range) {
|
||||||
|
final Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false "
|
||||||
|
+ " AND type_enum = ? AND entry_date BETWEEN ? AND ?",
|
||||||
|
Integer.class, incomeGlId, CREDIT, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
||||||
|
return count == null ? 0 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indian financial year label for today, e.g. "2026-27" for 19 Aug 2026. */
|
||||||
|
public String currentFinancialYear() {
|
||||||
|
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||||
|
final int startYear = today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1;
|
||||||
|
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1 April to 31 March for a "YYYY-YY" label. */
|
||||||
|
public LocalDate[] financialYearRange(final String financialYear) {
|
||||||
|
try {
|
||||||
|
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
|
||||||
|
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.incomeprofit.year.invalid",
|
||||||
|
"financialYear must look like 2026-27. Received: " + financialYear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> loadGlByCode(final String glCode) {
|
||||||
|
try {
|
||||||
|
return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE gl_code = ?", glCode);
|
||||||
|
} catch (EmptyResultDataAccessException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found", "GL account " + glCode + " does not exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As {@link #loadGlByCode}, for an account already identified by a financial activity mapping. */
|
||||||
|
public Map<String, Object> loadGlById(final Long glAccountId) {
|
||||||
|
try {
|
||||||
|
return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE id = ?", glAccountId);
|
||||||
|
} catch (EmptyResultDataAccessException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found",
|
||||||
|
"GL account " + glAccountId + " does not exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The distributable-profit GL the year's income closes into, as configured on Financial Activity Mappings. */
|
||||||
|
public Map<String, Object> loadDistributableProfitGl() {
|
||||||
|
return loadGlById(this.dividendGlAccounts.distributableProfitAccount());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long headOfficeId() {
|
||||||
|
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveCurrency(final Long glAccountId) {
|
||||||
|
try {
|
||||||
|
final String code = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
|
||||||
|
glAccountId);
|
||||||
|
return code == null ? "INR" : code;
|
||||||
|
} catch (EmptyResultDataAccessException e) {
|
||||||
|
return "INR";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||||
|
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
|
||||||
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||||
|
+ " entry_date, type_enum, amount, description, "
|
||||||
|
+ " created_by, last_modified_by, created_date, lastmodified_date, "
|
||||||
|
+ " is_running_balance_calculated, office_running_balance, "
|
||||||
|
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
|
||||||
|
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||||
|
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId,
|
||||||
|
now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), now.atOffset(ZoneOffset.UTC),
|
||||||
|
now.atOffset(ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long toLong(final Object o) {
|
||||||
|
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.businessdate.jobs.syncbusinessdate;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.Step;
|
||||||
|
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||||
|
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||||
|
import org.springframework.batch.core.repository.JobRepository;
|
||||||
|
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SyncBusinessDateConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobRepository jobRepository;
|
||||||
|
@Autowired
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
protected Step syncBusinessDateStep(final SyncBusinessDateTasklet syncBusinessDateTasklet) {
|
||||||
|
return new StepBuilder(JobName.SYNC_BUSINESS_DATE_TO_SYSTEM_DATE.name(), jobRepository)
|
||||||
|
.tasklet(syncBusinessDateTasklet, transactionManager).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job syncBusinessDateJob(final SyncBusinessDateTasklet syncBusinessDateTasklet) {
|
||||||
|
return new JobBuilder(JobName.SYNC_BUSINESS_DATE_TO_SYSTEM_DATE.name(), jobRepository)
|
||||||
|
.start(syncBusinessDateStep(syncBusinessDateTasklet)).incrementer(new RunIdIncrementer()).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.businessdate.jobs.syncbusinessdate;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.businessdate.data.service.BusinessDateDTO;
|
||||||
|
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
|
||||||
|
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateWritePlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.springframework.batch.core.StepContribution;
|
||||||
|
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||||
|
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||||
|
import org.springframework.batch.repeat.RepeatStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the business date to the tenant's own calendar date, every midnight.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Fineract's stock job adds one day to whatever the business date already holds. That is only the
|
||||||
|
* same thing as "today" while the scheduler has never missed a night: a server stopped over a
|
||||||
|
* weekend, a laptop that was closed, a tenant restored from an older backup, and the date is left
|
||||||
|
* behind by however many runs did not happen. It then stays behind, because adding a day to a stale
|
||||||
|
* date gives another stale date, and each night's run preserves the gap rather than closing it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Drift is not a cosmetic problem. Every transaction is stamped with the business date, so while it
|
||||||
|
* lags: a Demand Draft raised today is rejected as being dated in the future, the day's savings
|
||||||
|
* interest never accrues and a closure pays out short, and staff see yesterday's date on work they
|
||||||
|
* are doing now.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Reading the clock instead of incrementing is self-correcting: however many days were missed, the
|
||||||
|
* next run lands on today. Bringing the date forward is all this does — a date deliberately set
|
||||||
|
* ahead, which is how testing against a future day works, is left alone rather than pulled back
|
||||||
|
* under someone's feet.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Component
|
||||||
|
public class SyncBusinessDateTasklet implements Tasklet {
|
||||||
|
|
||||||
|
private final BusinessDateWritePlatformService businessDateWritePlatformService;
|
||||||
|
private final ConfigurationDomainService configurationDomainService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
|
||||||
|
if (!configurationDomainService.isBusinessDateEnabled()) {
|
||||||
|
// Without the setting on, the platform reads the clock directly and m_business_date is
|
||||||
|
// not consulted — writing to it would be busywork that misleads anyone reading the table.
|
||||||
|
log.debug("Business date is not enabled; nothing to sync.");
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDate systemDate = DateUtils.getLocalDateOfTenant();
|
||||||
|
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||||
|
|
||||||
|
if (!businessDate.isBefore(systemDate)) {
|
||||||
|
log.debug("Business date {} is already at or ahead of the system date {}; leaving it alone.", businessDate, systemDate);
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
|
||||||
|
businessDateWritePlatformService.updateBusinessDate(BusinessDateDTO.builder().type(BusinessDateType.BUSINESS_DATE)
|
||||||
|
.description(BusinessDateType.BUSINESS_DATE.getDescription()).date(systemDate).build());
|
||||||
|
|
||||||
|
log.info("Business date moved from {} to the system date {}.", businessDate, systemDate);
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -61,6 +61,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isMakerCheckerEnabled() {
|
||||||
|
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
||||||
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
||||||
|
|||||||
+27
-1
@@ -33,6 +33,7 @@ import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDoma
|
|||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.core.filters.CallerIpTrackingFilter;
|
import org.apache.fineract.infrastructure.core.filters.CallerIpTrackingFilter;
|
||||||
import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter;
|
import org.apache.fineract.infrastructure.core.filters.CorrelationHeaderFilter;
|
||||||
|
import org.apache.fineract.infrastructure.core.filters.DisabledFeatureApiFilter;
|
||||||
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter;
|
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreFilter;
|
||||||
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper;
|
import org.apache.fineract.infrastructure.core.filters.IdempotencyStoreHelper;
|
||||||
import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter;
|
import org.apache.fineract.infrastructure.core.filters.RequestResponseFilter;
|
||||||
@@ -163,6 +164,24 @@ public class SecurityConfig {
|
|||||||
// packages here without a Fineract Bearer token; security is enforced by the
|
// packages here without a Fineract Bearer token; security is enforced by the
|
||||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
||||||
|
// Client account summary, third-party callers — a partner integration calls this
|
||||||
|
// with an X-Api-Key header instead of a Fineract username/password (issued via
|
||||||
|
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
|
||||||
|
// outer gate stays open for this one narrow GET path, and
|
||||||
|
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
|
||||||
|
// the caller (ApiClientCredentialService checks the key's hash, active flag and
|
||||||
|
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
|
||||||
|
// it just fails inside the handler instead of at this filter.
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).permitAll()
|
||||||
|
// Tenant menu access — same dual-mode X-Api-Key posture as clientaccounts above
|
||||||
|
// for the two GETs (TenantMenuAccessApiResource#read still requires an ordinary
|
||||||
|
// session when no key is sent, same as ClientAllAccountsApiResource does; permitAll
|
||||||
|
// here only opens Spring Security's outer gate for the key path, it does not skip
|
||||||
|
// the handler's own check). The PUT has no session fallback inside the handler at
|
||||||
|
// all, even for a logged-in super user — see TenantMenuAccessApiResource#save.
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access")).permitAll()
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/tenant-menu-access/tenants")).permitAll()
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/tenant-menu-access")).permitAll()
|
||||||
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
|
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
|
||||||
// no Fineract auth required.
|
// no Fineract auth required.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
|
||||||
@@ -408,7 +427,10 @@ public class SecurityConfig {
|
|||||||
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
|
.addFilterBefore(tenantAwareBasicAuthenticationFilter(), SecurityContextHolderFilter.class)
|
||||||
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class)
|
.addFilterAfter(requestResponseFilter(), ExceptionTranslationFilter.class)
|
||||||
.addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class)
|
.addFilterAfter(correlationHeaderFilter(), RequestResponseFilter.class)
|
||||||
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class);
|
.addFilterAfter(fineractInstanceModeApiFilter(), CorrelationHeaderFilter.class)
|
||||||
|
// Refuses the handful of accounting features switched off for this installation.
|
||||||
|
// Nothing is deleted - see DisabledFeatureApiFilter for what is blocked and why.
|
||||||
|
.addFilterAfter(disabledFeatureApiFilter(), FineractInstanceModeApiFilter.class);
|
||||||
|
|
||||||
if (loanCOBFilterHelper != null) {
|
if (loanCOBFilterHelper != null) {
|
||||||
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
|
http.addFilterAfter(loanCOBApiFilter(), FineractInstanceModeApiFilter.class).addFilterAfter(idempotencyStoreFilter(),
|
||||||
@@ -445,6 +467,10 @@ public class SecurityConfig {
|
|||||||
return new RequestResponseFilter();
|
return new RequestResponseFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DisabledFeatureApiFilter disabledFeatureApiFilter() {
|
||||||
|
return new DisabledFeatureApiFilter();
|
||||||
|
}
|
||||||
|
|
||||||
public LoanCOBApiFilter loanCOBApiFilter() {
|
public LoanCOBApiFilter loanCOBApiFilter() {
|
||||||
return new LoanCOBApiFilter(loanCOBFilterHelper);
|
return new LoanCOBApiFilter(loanCOBFilterHelper);
|
||||||
}
|
}
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.core.filters;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns off a handful of accounting features at the HTTP edge, without deleting any of the code
|
||||||
|
* behind them: the resources, services and command handlers all stay exactly as they are, and only
|
||||||
|
* an inbound API call is refused. Re-enabling a feature is deleting its rule from RULES below.
|
||||||
|
*
|
||||||
|
* Blocking here rather than inside the resources matters for one specific reason: several internal
|
||||||
|
* flows raise journal entries through CommandWrapperBuilder().createJournalEntry() in Java
|
||||||
|
* (ClerkDisbursementRequestApiResource's GL offsets, clerk closure rounding, bank transfers). Those
|
||||||
|
* never travel over HTTP, so they keep working, while the "Create Journal Entries" screen and any
|
||||||
|
* direct call to the endpoint are refused.
|
||||||
|
*
|
||||||
|
* A rule carries an optional set of allowed "command" query parameter values, because
|
||||||
|
* POST /v1/journalentries is three different features behind one path: no command is the manual
|
||||||
|
* journal entry screen (blocked), command=defineOpeningBalance is Migrate Opening Balances and
|
||||||
|
* command=updateRunningBalance is the running-balance maintenance job (both still needed).
|
||||||
|
*/
|
||||||
|
public class DisabledFeatureApiFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private record Rule(String feature, Set<String> methods, Pattern path, Set<String> allowedCommands) {
|
||||||
|
|
||||||
|
boolean matches(final String method, final String pathInfo, final String command) {
|
||||||
|
if (!methods.isEmpty() && !methods.contains(method)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!path.matcher(pathInfo).matches()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A command this rule explicitly spares (a different feature sharing the same path).
|
||||||
|
return !(command != null && allowedCommands.contains(command));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final List<Rule> RULES = List.of(
|
||||||
|
// Create Journal Entries, and Frequent Postings which posts through the same endpoint.
|
||||||
|
new Rule("Create Journal Entries", Set.of("POST"), Pattern.compile("/v[1-9][0-9]*/journalentries/?"),
|
||||||
|
Set.of("defineOpeningBalance", "updateRunningBalance")),
|
||||||
|
// Closing Entries.
|
||||||
|
new Rule("Closing Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/glclosures(/.*)?"), Set.of()),
|
||||||
|
// Accruals.
|
||||||
|
new Rule("Accruals", Set.of(), Pattern.compile("/v[1-9][0-9]*/runaccruals(/.*)?"), Set.of()),
|
||||||
|
// Provisioning Entries, including the journal entries screen that belongs to it.
|
||||||
|
new Rule("Provisioning Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/provisioningentries(/.*)?"), Set.of()),
|
||||||
|
new Rule("Provisioning Entries", Set.of(), Pattern.compile("/v[1-9][0-9]*/journalentries/provisioning/?"), Set.of()),
|
||||||
|
// Accounting Rules. Only the Accounting Rules screens and Frequent Postings (disabled
|
||||||
|
// above) read these, so nothing still reachable depends on them.
|
||||||
|
new Rule("Accounting Rules", Set.of(), Pattern.compile("/v[1-9][0-9]*/accountingrules(/.*)?"), Set.of()));
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
|
||||||
|
final String pathInfo = request.getPathInfo();
|
||||||
|
if (StringUtils.isBlank(pathInfo)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String method = request.getMethod();
|
||||||
|
// Read "command" out of the query string rather than through getParameter(): the latter
|
||||||
|
// triggers servlet parameter parsing, which for a form-encoded or multipart POST consumes
|
||||||
|
// the request body before the resource ever sees it. This filter sits in front of every
|
||||||
|
// API call, so it must not touch the body of requests it has no interest in.
|
||||||
|
final String command = commandFromQueryString(request.getQueryString());
|
||||||
|
for (final Rule rule : RULES) {
|
||||||
|
if (rule.matches(method, pathInfo, command)) {
|
||||||
|
reject(response, rule.feature());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String commandFromQueryString(final String queryString) {
|
||||||
|
if (StringUtils.isBlank(queryString)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final String pair : queryString.split("&")) {
|
||||||
|
if (pair.startsWith("command=")) {
|
||||||
|
return URLDecoder.decode(pair.substring("command=".length()), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reject(final HttpServletResponse response, final String feature) throws IOException {
|
||||||
|
response.setStatus(HttpStatus.SC_FORBIDDEN);
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.getWriter()
|
||||||
|
.write("{\"developerMessage\":\"" + feature + " is disabled on this installation.\","
|
||||||
|
+ "\"httpStatusCode\":\"403\",\"defaultUserMessage\":\"" + feature + " is not available.\","
|
||||||
|
+ "\"userMessageGlobalisationCode\":\"error.msg.feature.disabled\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -66,6 +66,23 @@ public class JobSchedulerServiceImpl implements ApplicationListener<ContextRefre
|
|||||||
final List<ScheduledJobDetail> scheduledJobDetails = schedularWritePlatformService
|
final List<ScheduledJobDetail> scheduledJobDetails = schedularWritePlatformService
|
||||||
.retrieveAllJobs(fineractProperties.getNodeId());
|
.retrieveAllJobs(fineractProperties.getNodeId());
|
||||||
for (final ScheduledJobDetail jobDetails : scheduledJobDetails) {
|
for (final ScheduledJobDetail jobDetails : scheduledJobDetails) {
|
||||||
|
// Release any currently_running flag left over from before this boot.
|
||||||
|
//
|
||||||
|
// The flag is set in the scheduler's veto check before a job's body runs and cleared
|
||||||
|
// by the Quartz listener when it returns. Two things leave it set with nothing
|
||||||
|
// running: a JVM that stopped mid-run, and a job started through its own API rather
|
||||||
|
// than through Quartz — no listener is involved there, so nothing ever clears it.
|
||||||
|
//
|
||||||
|
// Once set, the veto silently drops every later trigger: no run-history row, no
|
||||||
|
// error, nothing in the log. The job reads as though it ran and did nothing, and one
|
||||||
|
// dead run mutes it for good. That is how a night of unpaid savings interest goes
|
||||||
|
// unnoticed.
|
||||||
|
//
|
||||||
|
// This loop is the platform coming up, so by definition none of these jobs is
|
||||||
|
// running: whatever the flag says, it is stale. scheduleJob() below already clears
|
||||||
|
// it on the paths it reaches, but not when it throws before that line, and the
|
||||||
|
// saveOrUpdate() after this loop persists whatever we set here either way.
|
||||||
|
jobDetails.setCurrentlyRunning(false);
|
||||||
try {
|
try {
|
||||||
jobRegisterService.scheduleJob(jobDetails);
|
jobRegisterService.scheduleJob(jobDetails);
|
||||||
} catch (JobIsNotFoundOrNotEnabledException e) {
|
} catch (JobIsNotFoundOrNotEnabledException e) {
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.security.apiclient;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DELETE;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
|
||||||
|
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
|
||||||
|
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
|
||||||
|
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
|
||||||
|
* key in the first place is not something to hand to just anyone.
|
||||||
|
*/
|
||||||
|
@Path("/v1/apiclients")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "API Client Keys", description = "Admin management of third-party API keys used by endpoints that skip Fineract login.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApiClientAdminApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final ApiClientCredentialService credentialService;
|
||||||
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String create(final String body) {
|
||||||
|
final AppUser user = requireSuperUser();
|
||||||
|
final JsonElement root = fromJsonHelper.parse(body);
|
||||||
|
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
|
||||||
|
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
|
||||||
|
|
||||||
|
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String list() {
|
||||||
|
requireSuperUser();
|
||||||
|
final List<Map<String, Object>> clients = credentialService.listClients();
|
||||||
|
return gson.toJson(clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("/{id}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String deactivate(@PathParam("id") final Long id) {
|
||||||
|
requireSuperUser();
|
||||||
|
credentialService.setActive(id, false);
|
||||||
|
return gson.toJson(Map.of("resourceId", id, "isActive", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/reactivate")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String reactivate(@PathParam("id") final Long id) {
|
||||||
|
requireSuperUser();
|
||||||
|
credentialService.setActive(id, true);
|
||||||
|
return gson.toJson(Map.of("resourceId", id, "isActive", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppUser requireSuperUser() {
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
if (!user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||||
|
throw new WebApplicationException(Response.status(403)
|
||||||
|
.entity("{\"message\":\"Only a super user can manage third-party API keys.\"}").type(MediaType.APPLICATION_JSON)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.security.apiclient;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates third-party callers of endpoints that are meant to be reachable WITHOUT a Fineract user
|
||||||
|
* login -- an {@code X-Api-Key} header instead of a username/password. Same posture already used in this
|
||||||
|
* fork for the IMPS and NPCI receivers ({@code SecurityConfig}: those paths are {@code permitAll()} at
|
||||||
|
* the Spring Security layer, and the handler itself verifies the caller's credential): the outer gate
|
||||||
|
* stays open for the specific path, and this class is the thing that actually decides "yes" or "no".
|
||||||
|
*
|
||||||
|
* {@code m_api_client} lives in the tenant-STORE database ({@code fineract_tenants}), reached here via
|
||||||
|
* the {@code hikariTenantDataSource}-qualified {@link DataSource} -- the same bean
|
||||||
|
* {@code TenantDatabaseUpgradeService} uses to manage that database -- rather than the ordinary
|
||||||
|
* tenant-routed {@code JdbcTemplate} every other resource in this fork injects. That is deliberate, not
|
||||||
|
* an oversight: a key is a caller's identity, independent of which bank's data a given request is asking
|
||||||
|
* for, so one minted key must keep working against every tenant that exists now AND every one
|
||||||
|
* provisioned later. A copy of this table inside each bank's own tenant database would need a manual
|
||||||
|
* re-insert on every new bank, and every key would still need one row per tenant -- exactly what storing
|
||||||
|
* it centrally, once, avoids. {@code Fineract-Platform-TenantId} keeps doing what it always did (routes
|
||||||
|
* the REST of the request, e.g. {@code ClientAllAccountsApiResource}'s own client/account lookups, to
|
||||||
|
* the right bank's database) -- this class only ever touches the one central table.
|
||||||
|
*
|
||||||
|
* Security properties, deliberately:
|
||||||
|
* <ul>
|
||||||
|
* <li>Only a SHA-256 hash of the key is ever stored ({@code m_api_client.api_key_hash}). A leaked
|
||||||
|
* database backup can never be turned back into a usable credential -- the same reason Fineract itself
|
||||||
|
* never stores a user's plaintext password.</li>
|
||||||
|
* <li>The raw key is shown to the caller (well, the admin minting it) exactly once, at creation. It is
|
||||||
|
* not retrievable afterwards, by design -- if it's lost, the only recourse is to mint a new one and
|
||||||
|
* deactivate the old.</li>
|
||||||
|
* <li>{@code authenticate()} raises the SAME 401 message ("Invalid or inactive API key.") whether the key
|
||||||
|
* doesn't exist, is deactivated, or the caller's IP isn't on its allowlist -- so a probing attacker
|
||||||
|
* cannot use the response to tell a wrong key apart from a valid-but-blocked one.</li>
|
||||||
|
* <li>The IP allowlist is a plain comma-separated exact-match list, not CIDR ranges -- adequate for a
|
||||||
|
* small, known set of partner integrations; if a partner calls from a rotating pool of addresses, leave
|
||||||
|
* {@code allowedIps} null for that key rather than trying to enumerate every address.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApiClientCredentialService {
|
||||||
|
|
||||||
|
private static final String GENERIC_AUTH_FAILURE = "Invalid or inactive API key.";
|
||||||
|
// Enough random bytes that guessing is infeasible (256 bits) -- the same order of magnitude as a
|
||||||
|
// session token, appropriate for a credential that never expires on its own.
|
||||||
|
private static final int KEY_ENTROPY_BYTES = 32;
|
||||||
|
|
||||||
|
@Qualifier("hikariTenantDataSource")
|
||||||
|
private final DataSource tenantStoreDataSource;
|
||||||
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
|
// Built once from the qualified DataSource in @PostConstruct rather than injected directly as a
|
||||||
|
// JdbcTemplate: @RequiredArgsConstructor only wires constructor parameters straight from fields,
|
||||||
|
// and JdbcTemplate itself isn't a bean registered under this qualifier -- only the DataSource is.
|
||||||
|
private JdbcTemplate tenantStoreJdbc;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void init() {
|
||||||
|
this.tenantStoreJdbc = new JdbcTemplate(tenantStoreDataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a raw {@code X-Api-Key} header value. On success, returns the registered client's
|
||||||
|
* display name (for the caller to log/attribute the request to) and records this call -- including
|
||||||
|
* which tenant it was made against -- as the key's most recent use. On any failure, throws a clean
|
||||||
|
* 401; the caller never gets far enough to tell a wrong key from a deactivated one or a blocked IP.
|
||||||
|
*/
|
||||||
|
public String authenticate(final String rawApiKey, final String callerIp) {
|
||||||
|
if (StringUtils.isBlank(rawApiKey)) {
|
||||||
|
throw unauthorized();
|
||||||
|
}
|
||||||
|
final String hash = sha256Hex(rawApiKey);
|
||||||
|
final List<Map<String, Object>> rows = tenantStoreJdbc.queryForList("""
|
||||||
|
SELECT id, client_name, is_active, allowed_ips FROM m_api_client WHERE api_key_hash = ?
|
||||||
|
""", hash);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw unauthorized();
|
||||||
|
}
|
||||||
|
final Map<String, Object> row = rows.get(0);
|
||||||
|
if (!Boolean.TRUE.equals(row.get("is_active"))) {
|
||||||
|
throw unauthorized();
|
||||||
|
}
|
||||||
|
final String allowedIps = (String) row.get("allowed_ips");
|
||||||
|
if (StringUtils.isNotBlank(allowedIps) && !isIpAllowed(allowedIps, callerIp)) {
|
||||||
|
throw unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long id = ((Number) row.get("id")).longValue();
|
||||||
|
tenantStoreJdbc.update("UPDATE m_api_client SET last_used_at = ?, last_used_ip = ?, last_used_tenant = ? WHERE id = ?",
|
||||||
|
java.sql.Timestamp.from(Instant.now()), callerIp, currentTenantId(), id);
|
||||||
|
|
||||||
|
return (String) row.get("client_name");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isIpAllowed(final String allowedIps, final String callerIp) {
|
||||||
|
if (StringUtils.isBlank(callerIp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Arrays.stream(allowedIps.split(",")).map(String::trim).anyMatch(ip -> ip.equalsIgnoreCase(callerIp));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin operation: mints a new key and returns it in the response -- the ONLY time the raw value
|
||||||
|
* is ever available. {@code allowedIps} may be null/blank for no IP restriction. The resulting key
|
||||||
|
* authenticates against every tenant, regardless of which one the admin minting it happened to be
|
||||||
|
* logged into.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> createClient(final String clientName, final String allowedIps, final Long createdByUserId) {
|
||||||
|
if (StringUtils.isBlank(clientName)) {
|
||||||
|
throw badRequest("clientName is required.");
|
||||||
|
}
|
||||||
|
final String rawKey = generateRawKey();
|
||||||
|
final String hash = sha256Hex(rawKey);
|
||||||
|
final String prefix = rawKey.substring(0, Math.min(12, rawKey.length()));
|
||||||
|
|
||||||
|
final Long id = tenantStoreJdbc.queryForObject("""
|
||||||
|
INSERT INTO m_api_client (client_name, api_key_hash, api_key_prefix, allowed_ips, created_by, created_by_tenant)
|
||||||
|
VALUES (?,?,?,?,?,?)
|
||||||
|
RETURNING id
|
||||||
|
""", Long.class, clientName, hash, prefix, StringUtils.isBlank(allowedIps) ? null : allowedIps, createdByUserId,
|
||||||
|
currentTenantId());
|
||||||
|
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("id", id);
|
||||||
|
result.put("clientName", clientName);
|
||||||
|
result.put("apiKey", rawKey);
|
||||||
|
result.put("warning", "This is the only time the full API key is shown. Store it now -- it cannot be retrieved again.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> listClients() {
|
||||||
|
return tenantStoreJdbc.queryForList("""
|
||||||
|
SELECT id, client_name, api_key_prefix, is_active, allowed_ips,
|
||||||
|
created_date, created_by_tenant, deactivated_date, last_used_at, last_used_ip, last_used_tenant
|
||||||
|
FROM m_api_client ORDER BY created_date DESC
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActive(final Long id, final boolean active) {
|
||||||
|
final int updated = tenantStoreJdbc.update(
|
||||||
|
"UPDATE m_api_client SET is_active = ?, deactivated_date = ? WHERE id = ?", active,
|
||||||
|
active ? null : java.sql.Timestamp.from(Instant.now()), id);
|
||||||
|
if (updated == 0) {
|
||||||
|
throw notFound("No API client with id " + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String currentTenantId() {
|
||||||
|
try {
|
||||||
|
return ThreadLocalContextUtil.getTenant().getTenantIdentifier();
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// Defensive only: every request through SecurityConfig's matcher chain has a tenant
|
||||||
|
// resolved before it reaches here (see TenantAwareBasicAuthenticationFilter), so this
|
||||||
|
// should be unreachable in practice.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateRawKey() {
|
||||||
|
final byte[] bytes = new byte[KEY_ENTROPY_BYTES];
|
||||||
|
secureRandom.nextBytes(bytes);
|
||||||
|
// URL-safe, no padding: safe to put directly in a header value with no further encoding.
|
||||||
|
return "cbsx_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256Hex(final String raw) {
|
||||||
|
try {
|
||||||
|
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
final byte[] hashBytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||||
|
final StringBuilder hex = new StringBuilder(hashBytes.length * 2);
|
||||||
|
for (final byte b : hashBytes) {
|
||||||
|
hex.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return hex.toString();
|
||||||
|
} catch (final NoSuchAlgorithmException e) {
|
||||||
|
// SHA-256 is a mandatory JCE algorithm on every JVM distribution Fineract supports --
|
||||||
|
// this can only mean a broken JVM install, not a recoverable runtime condition.
|
||||||
|
throw new IllegalStateException("SHA-256 is unavailable in this JVM", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException unauthorized() {
|
||||||
|
// Naming the tenant the caller sent is safe here -- it is the header value they themselves
|
||||||
|
// put on the request, not something this check reveals to them -- and it turns "why did my
|
||||||
|
// key fail" from a guess into something checkable: was this call even reaching the bank the
|
||||||
|
// caller thought it was?
|
||||||
|
final String tenantId = currentTenantId();
|
||||||
|
final String message = tenantId != null ? GENERIC_AUTH_FAILURE + " (tenant: " + tenantId + ")" : GENERIC_AUTH_FAILURE;
|
||||||
|
return jsonError(401, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException badRequest(final String message) {
|
||||||
|
return jsonError(400, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException notFound(final String message) {
|
||||||
|
return jsonError(404, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException jsonError(final int status, final String message) {
|
||||||
|
final String body = "{\"httpStatusCode\":\"" + status + "\",\"message\":\"" + message.replace("\"", "'") + "\"}";
|
||||||
|
return new WebApplicationException(Response.status(status).entity(body).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.security.apiclient;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.HeaderParam;
|
||||||
|
import jakarta.ws.rs.PUT;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import jakarta.ws.rs.core.Context;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which top-nav menu sections a tenant is allowed to ever show, regardless of what any role inside
|
||||||
|
* that tenant grants itself.
|
||||||
|
*
|
||||||
|
* {@code HasPermissionDirective} on the frontend already hides a menu item a user's own role lacks --
|
||||||
|
* but a role carrying {@code ALL_FUNCTIONS} (a super user, or a role simply misconfigured to hold it,
|
||||||
|
* as the "Clerk" role was found to in two tenants this session) bypasses that check unconditionally.
|
||||||
|
* A bank's own admin screen for roles and permissions therefore cannot be trusted as the only gate on
|
||||||
|
* "does this bank ever get to see the HRM/NPA/Admin section" -- whoever ends up with ALL_FUNCTIONS in
|
||||||
|
* that tenant, today or after some future role edit, would still see it. This table is the ceiling
|
||||||
|
* that check now also has to clear.
|
||||||
|
*
|
||||||
|
* Reading it accepts either credential (see {@link #read} below) -- a bank's own user needs their own
|
||||||
|
* tenant's list to render their nav correctly, the same way they already read their own permissions,
|
||||||
|
* and the platform operator needs to read ANY tenant's current list before toggling it. Writing it
|
||||||
|
* accepts only the platform's own {@code X-Api-Key} + {@link ApiClientCredentialService} credential --
|
||||||
|
* the same mechanism {@code ClientAllAccountsApiResource} uses for third-party callers, but with no
|
||||||
|
* session fallback at all here. That is deliberate -- a bank's own super user must never be able to
|
||||||
|
* lift its own ceiling by granting themselves a permission from inside their own tenant, which is
|
||||||
|
* exactly what a normal {@code ALL_FUNCTIONS} gate would allow. The key is minted once via
|
||||||
|
* {@code POST /v1/apiclients} by the platform operator and never handed to a bank.
|
||||||
|
*/
|
||||||
|
@Path("/v1/tenant-menu-access")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Tenant Menu Access",
|
||||||
|
description = "Per-tenant cap on which top-nav menu sections can ever be shown, independent of any role's own permissions. Read by any logged-in user of the tenant; written only via a platform-operator API key.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TenantMenuAccessApiResource {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final ApiClientCredentialService apiClientCredentialService;
|
||||||
|
private final TenantDetailsService tenantDetailsService;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" };
|
||||||
|
|
||||||
|
// The whole, fixed set of top-nav menu codes this screen knows about -- see 0541 for how this
|
||||||
|
// list was seeded. A write is only ever a PATCH over this table's existing rows (see save()); a
|
||||||
|
// code outside this set cannot be created by the API, so a typo in a request body is silently a
|
||||||
|
// no-op rather than junk data the frontend would then have to guard against.
|
||||||
|
private static final Set<String> KNOWN_MENU_CODES = Set.of("MENU_INSTITUTION", "SIDEBAR_CFCFRMS", "MENU_TASKS",
|
||||||
|
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
|
||||||
|
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
|
||||||
|
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
|
||||||
|
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
|
||||||
|
* Dual-mode read, same posture as {@code ClientAllAccountsApiResource}: an ordinary logged-in
|
||||||
|
* user reads their own tenant's list exactly like they already read their own permission list
|
||||||
|
* (no elevated permission required), or the platform operator reads ANY tenant's list with the
|
||||||
|
* API key, to know the current state before toggling it on their own control screen. Only the
|
||||||
|
* write side ({@link #save}) has no session fallback -- reading configuration is not sensitive,
|
||||||
|
* changing it is.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String read(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
|
||||||
|
if (StringUtils.isNotBlank(apiKey)) {
|
||||||
|
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
|
||||||
|
} else {
|
||||||
|
context.authenticatedUser();
|
||||||
|
}
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT menu_code, enabled FROM m_tenant_menu_access");
|
||||||
|
final JsonArray out = new JsonArray();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final JsonObject o = new JsonObject();
|
||||||
|
o.addProperty("menuCode", (String) row.get("menu_code"));
|
||||||
|
o.addProperty("enabled", Boolean.TRUE.equals(row.get("enabled")));
|
||||||
|
out.add(o);
|
||||||
|
}
|
||||||
|
return gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tenant configured on this platform, for the operator's own tenant picker -- always the
|
||||||
|
* master list from the tenant-store database, never the one tenant the caller's own
|
||||||
|
* {@code Fineract-Platform-TenantId} header happened to resolve to. That header still has to name
|
||||||
|
* a real tenant for the request to reach this handler at all (see
|
||||||
|
* {@code TenantAwareBasicAuthenticationFilter}); which one is otherwise irrelevant here.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/tenants")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String listTenants(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
|
||||||
|
requireApiKey(apiKey, servletRequest);
|
||||||
|
final JsonArray out = new JsonArray();
|
||||||
|
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
|
||||||
|
final JsonObject o = new JsonObject();
|
||||||
|
o.addProperty("identifier", tenant.getTenantIdentifier());
|
||||||
|
o.addProperty("name", tenant.getName());
|
||||||
|
out.add(o);
|
||||||
|
}
|
||||||
|
return gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets which menu codes the tenant named in {@code Fineract-Platform-TenantId} may show, from the
|
||||||
|
* operator's own control screen. API key only -- see the class comment for why a normal session,
|
||||||
|
* even a super user's, is never accepted here.
|
||||||
|
*
|
||||||
|
* Body: {@code [{"menuCode": "MENU_HRM", "enabled": false}, ...]}. Only codes already in
|
||||||
|
* {@link #KNOWN_MENU_CODES} are written; anything else in the body is ignored rather than
|
||||||
|
* rejected, so the operator's screen can always send its full known set without first checking
|
||||||
|
* this deployment's exact backend version.
|
||||||
|
*/
|
||||||
|
@PUT
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String save(@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest,
|
||||||
|
final String body) {
|
||||||
|
requireApiKey(apiKey, servletRequest);
|
||||||
|
|
||||||
|
final JsonArray entries = gson.fromJson(body, JsonArray.class);
|
||||||
|
if (entries == null) {
|
||||||
|
throw apiError(400, "Request body must be a JSON array of {menuCode, enabled}.");
|
||||||
|
}
|
||||||
|
final Set<String> touched = new LinkedHashSet<>();
|
||||||
|
for (final var el : entries) {
|
||||||
|
final JsonObject entry = el.getAsJsonObject();
|
||||||
|
if (!entry.has("menuCode") || entry.get("menuCode").isJsonNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String menuCode = entry.get("menuCode").getAsString();
|
||||||
|
if (!KNOWN_MENU_CODES.contains(menuCode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final boolean enabled = entry.has("enabled") && !entry.get("enabled").isJsonNull() && entry.get("enabled").getAsBoolean();
|
||||||
|
jdbcTemplate.update("UPDATE m_tenant_menu_access SET enabled = ? WHERE menu_code = ?", enabled, menuCode);
|
||||||
|
touched.add(menuCode);
|
||||||
|
}
|
||||||
|
final JsonObject res = new JsonObject();
|
||||||
|
res.addProperty("tenant", ThreadLocalContextUtil.getTenant().getTenantIdentifier());
|
||||||
|
res.addProperty("updated", touched.size());
|
||||||
|
return gson.toJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No session fallback, unlike every other API-key-capable endpoint in this codebase -- see the class comment. */
|
||||||
|
private void requireApiKey(final String apiKey, final HttpServletRequest servletRequest) {
|
||||||
|
if (StringUtils.isBlank(apiKey)) {
|
||||||
|
throw apiError(403, "This endpoint is only reachable with a platform operator API key.");
|
||||||
|
}
|
||||||
|
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveClientIp(final HttpServletRequest request) {
|
||||||
|
for (final String header : IP_HEADER_CANDIDATES) {
|
||||||
|
final String value = request.getHeader(header);
|
||||||
|
if (StringUtils.isNotBlank(value)) {
|
||||||
|
return value.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
|
final JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("message", message);
|
||||||
|
body.addProperty("defaultUserMessage", message);
|
||||||
|
body.addProperty("httpStatus", httpStatus);
|
||||||
|
return new WebApplicationException(
|
||||||
|
Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.api;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import jakarta.ws.rs.Consumes;
|
|
||||||
import jakarta.ws.rs.GET;
|
|
||||||
import jakarta.ws.rs.POST;
|
|
||||||
import jakarta.ws.rs.PUT;
|
|
||||||
import jakarta.ws.rs.Path;
|
|
||||||
import jakarta.ws.rs.PathParam;
|
|
||||||
import jakarta.ws.rs.Produces;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import java.util.Collection;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.organisation.agent.data.AgentData;
|
|
||||||
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Path("/v1/dds/agents")
|
|
||||||
@Component
|
|
||||||
@Tag(name = "DDS Agents", description = "A DDS collection agent - either a person or, at the top of the hierarchy, the bank itself - with its own settlement savings account and guarantors.")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AgentsApiResource {
|
|
||||||
|
|
||||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "AGENT";
|
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final AgentReadPlatformService readPlatformService;
|
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
@Operation(summary = "Retrieve DDS Agents", description = "Returns the list of DDS agents.")
|
|
||||||
public Collection<AgentData> retrieveAll() {
|
|
||||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
|
||||||
return readPlatformService.retrieveAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("{agentId}")
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
@Operation(summary = "Retrieve a DDS Agent", description = "Returns the details of a DDS agent, including its guarantors.")
|
|
||||||
public AgentData retrieveOne(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId) {
|
|
||||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
|
||||||
return readPlatformService.retrieveOne(agentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@POST
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
@Operation(summary = "Create a DDS Agent", description = "Mandatory fields: clientId, agentNameId, agentAddress1, agentCity, "
|
|
||||||
+ "agentPinCode, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, homeBranch, "
|
|
||||||
+ "savingBranch, tdsApplicable, commissionCalculation\n" + "\n"
|
|
||||||
+ "Optional fields: agentAddress2, agentTelephone, agentOpeningBalance, tdsPercent, transferBranch, "
|
|
||||||
+ "transferAmountAccountType, transferAccountNo, limit, machineRent, agentSecurityBranch, agentSecurityAccountType, "
|
|
||||||
+ "agentSecurityAccountNo, otherCommission, agentDeposit, festivalAdvance, otherDeduction, limits, guarantors")
|
|
||||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
|
||||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
|
||||||
.createAgent() //
|
|
||||||
.withJson(apiRequestBodyAsJson) //
|
|
||||||
.build();
|
|
||||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PUT
|
|
||||||
@Path("{agentId}")
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
@Operation(summary = "Update a DDS Agent", description = "Updates the details of a DDS agent.")
|
|
||||||
public CommandProcessingResult update(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId,
|
|
||||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
|
||||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
|
||||||
.updateAgent(agentId) //
|
|
||||||
.withJson(apiRequestBodyAsJson) //
|
|
||||||
.build();
|
|
||||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.domain;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
|
|
||||||
public interface AgentRepository extends JpaRepository<Agent, Long> {
|
|
||||||
|
|
||||||
@Query("select coalesce(max(cast(substring(a.agentCode, 3) as long)), 0) from Agent a where a.agentCode like 'AG%'")
|
|
||||||
Long retrieveMaxAgentCodeSuffix();
|
|
||||||
|
|
||||||
Optional<Agent> findByAgentCode(String agentCode);
|
|
||||||
|
|
||||||
@Query("select a from Agent a where a.id between :agentFrom and :agentTo")
|
|
||||||
List<Agent> findBetweenIds(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
|
|
||||||
}
|
|
||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.handler;
|
|
||||||
|
|
||||||
import org.apache.fineract.commands.annotation.CommandType;
|
|
||||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|
||||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@CommandType(entity = "AGENT", action = "UPDATE")
|
|
||||||
public class UpdateAgentCommandHandler implements NewCommandSourceHandler {
|
|
||||||
|
|
||||||
private final AgentWritePlatformService writePlatformService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public UpdateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
|
|
||||||
this.writePlatformService = writePlatformService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
@Override
|
|
||||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
|
||||||
return this.writePlatformService.updateAgent(command.entityId(), command);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-409
@@ -1,409 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.serialization;
|
|
||||||
|
|
||||||
import com.google.gson.JsonElement;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.google.gson.reflect.TypeToken;
|
|
||||||
import java.lang.reflect.Type;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates create/update commands for an Agent - the DDS collection agent. As of 2026-08-24, Agent
|
|
||||||
* does the daily collection work directly (the SubAgent tier was removed), so this deserializer carries
|
|
||||||
* the collection/commission/limit/security-account fields SubAgent used to own, alongside Agent's
|
|
||||||
* original identity/settlement-account fields.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public final class AgentCommandFromApiJsonDeserializer {
|
|
||||||
|
|
||||||
public static final String CLIENT_ID = "clientId";
|
|
||||||
public static final String AGENT_NAME_ID = "agentNameId";
|
|
||||||
public static final String AGENT_ADDRESS1 = "agentAddress1";
|
|
||||||
public static final String AGENT_ADDRESS2 = "agentAddress2";
|
|
||||||
public static final String AGENT_CITY = "agentCity";
|
|
||||||
public static final String AGENT_PINCODE = "agentPinCode";
|
|
||||||
public static final String AGENT_TELEPHONE = "agentTelephone";
|
|
||||||
public static final String AGENT_SAVING_ACCOUNT_TYPE = "agentSavingAccountType";
|
|
||||||
public static final String AGENT_SAVING_ACCOUNT_NO = "agentSavingAccountNo";
|
|
||||||
public static final String AGENT_OPENING_DATE = "agentOpeningDate";
|
|
||||||
public static final String AGENT_BIRTH_DATE = "agentBirthDate";
|
|
||||||
public static final String AGENT_OPENING_BALANCE = "agentOpeningBalance";
|
|
||||||
public static final String HOME_BRANCH = "homeBranch";
|
|
||||||
public static final String SAVING_BRANCH = "savingBranch";
|
|
||||||
public static final String TDS_APPLICABLE = "tdsApplicable";
|
|
||||||
public static final String TDS_PERCENT = "tdsPercent";
|
|
||||||
public static final String TRANSFER_BRANCH = "transferBranch";
|
|
||||||
public static final String TRANSFER_AMOUNT_ACCOUNT_TYPE = "transferAmountAccountType";
|
|
||||||
public static final String TRANSFER_ACCOUNT_NO = "transferAccountNo";
|
|
||||||
public static final String COMMISSION_CALCULATION = "commissionCalculation";
|
|
||||||
public static final String LIMIT = "limit";
|
|
||||||
public static final String MACHINE_RENT = "machineRent";
|
|
||||||
public static final String AGENT_SECURITY_BRANCH = "agentSecurityBranch";
|
|
||||||
public static final String AGENT_SECURITY_ACCOUNT_TYPE = "agentSecurityAccountType";
|
|
||||||
public static final String AGENT_SECURITY_ACCOUNT_NO = "agentSecurityAccountNo";
|
|
||||||
public static final String OTHER_COMMISSION = "otherCommission";
|
|
||||||
public static final String AGENT_DEPOSIT = "agentDeposit";
|
|
||||||
public static final String FESTIVAL_ADVANCE = "festivalAdvance";
|
|
||||||
public static final String OTHER_DEDUCTION = "otherDeduction";
|
|
||||||
public static final String LIMITS = "limits";
|
|
||||||
public static final String GUARANTORS = "guarantors";
|
|
||||||
public static final String DATE_FORMAT = "dateFormat";
|
|
||||||
public static final String LOCALE = "locale";
|
|
||||||
|
|
||||||
private static final String GUARANTOR_CLIENT_ID = "clientId";
|
|
||||||
private static final String GUARANTOR_NAME = "guarantorName";
|
|
||||||
private static final String GUARANTOR_ADDRESS1 = "address1";
|
|
||||||
private static final String GUARANTOR_ADDRESS2 = "address2";
|
|
||||||
private static final String GUARANTOR_CITY = "city";
|
|
||||||
private static final String GUARANTOR_PINCODE = "pinCode";
|
|
||||||
private static final String GUARANTOR_TELEPHONE = "telephone";
|
|
||||||
|
|
||||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(CLIENT_ID, AGENT_NAME_ID, AGENT_ADDRESS1,
|
|
||||||
AGENT_ADDRESS2, AGENT_CITY, AGENT_PINCODE, AGENT_TELEPHONE, AGENT_SAVING_ACCOUNT_TYPE, AGENT_SAVING_ACCOUNT_NO,
|
|
||||||
AGENT_OPENING_DATE, AGENT_BIRTH_DATE, AGENT_OPENING_BALANCE, HOME_BRANCH, SAVING_BRANCH, TDS_APPLICABLE, TDS_PERCENT,
|
|
||||||
TRANSFER_BRANCH, TRANSFER_AMOUNT_ACCOUNT_TYPE, TRANSFER_ACCOUNT_NO, COMMISSION_CALCULATION, LIMIT, MACHINE_RENT,
|
|
||||||
AGENT_SECURITY_BRANCH, AGENT_SECURITY_ACCOUNT_TYPE, AGENT_SECURITY_ACCOUNT_NO, OTHER_COMMISSION, AGENT_DEPOSIT,
|
|
||||||
FESTIVAL_ADVANCE, OTHER_DEDUCTION, LIMITS, GUARANTORS, DATE_FORMAT, LOCALE));
|
|
||||||
|
|
||||||
private static final Set<String> SUPPORTED_LIMIT_PARAMETERS = new HashSet<>(Arrays.asList("fromDate", "toDate", "limit"));
|
|
||||||
|
|
||||||
private static final Set<String> SUPPORTED_GUARANTOR_PARAMETERS = new HashSet<>(Arrays.asList(GUARANTOR_CLIENT_ID, GUARANTOR_NAME,
|
|
||||||
GUARANTOR_ADDRESS1, GUARANTOR_ADDRESS2, GUARANTOR_CITY, GUARANTOR_PINCODE, GUARANTOR_TELEPHONE));
|
|
||||||
|
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public AgentCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
|
|
||||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void validateForCreate(final String json) {
|
|
||||||
if (StringUtils.isBlank(json)) {
|
|
||||||
throw new InvalidJsonException();
|
|
||||||
}
|
|
||||||
|
|
||||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
|
||||||
|
|
||||||
}.getType();
|
|
||||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
|
|
||||||
|
|
||||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
|
||||||
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
|
|
||||||
|
|
||||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
|
||||||
|
|
||||||
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
|
|
||||||
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
|
|
||||||
|
|
||||||
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
|
|
||||||
|
|
||||||
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
|
|
||||||
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
|
|
||||||
|
|
||||||
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
|
|
||||||
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
|
|
||||||
}
|
|
||||||
|
|
||||||
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
|
|
||||||
.notExceedingLengthOf(100);
|
|
||||||
|
|
||||||
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
|
|
||||||
|
|
||||||
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
|
|
||||||
|
|
||||||
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
|
|
||||||
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
|
|
||||||
}
|
|
||||||
|
|
||||||
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
|
|
||||||
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
|
|
||||||
|
|
||||||
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
|
|
||||||
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
|
|
||||||
|
|
||||||
baseDataValidator.reset().parameter(TDS_APPLICABLE).trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE,
|
|
||||||
element));
|
|
||||||
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
|
|
||||||
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
|
|
||||||
|
|
||||||
validateOptionalStrings(element, baseDataValidator);
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
|
|
||||||
validateLimits(element.getAsJsonObject(), dataValidationErrors);
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
|
|
||||||
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
|
|
||||||
}
|
|
||||||
|
|
||||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void validateForUpdate(final String json) {
|
|
||||||
if (StringUtils.isBlank(json)) {
|
|
||||||
throw new InvalidJsonException();
|
|
||||||
}
|
|
||||||
|
|
||||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
|
||||||
|
|
||||||
}.getType();
|
|
||||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
|
|
||||||
|
|
||||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
|
||||||
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
|
|
||||||
|
|
||||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(CLIENT_ID, element)) {
|
|
||||||
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
|
|
||||||
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_NAME_ID, element)) {
|
|
||||||
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS1, element)) {
|
|
||||||
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
|
|
||||||
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_CITY, element)) {
|
|
||||||
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_PINCODE, element)) {
|
|
||||||
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
|
|
||||||
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_TYPE, element)) {
|
|
||||||
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
|
|
||||||
.notExceedingLengthOf(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_NO, element)) {
|
|
||||||
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_DATE, element)) {
|
|
||||||
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_BIRTH_DATE, element)) {
|
|
||||||
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
|
|
||||||
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
|
|
||||||
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(HOME_BRANCH, element)) {
|
|
||||||
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
|
|
||||||
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(SAVING_BRANCH, element)) {
|
|
||||||
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
|
|
||||||
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(TDS_APPLICABLE, element)) {
|
|
||||||
baseDataValidator.reset().parameter(TDS_APPLICABLE)
|
|
||||||
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE, element));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(COMMISSION_CALCULATION, element)) {
|
|
||||||
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
|
|
||||||
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
|
|
||||||
}
|
|
||||||
|
|
||||||
validateOptionalStrings(element, baseDataValidator);
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
|
|
||||||
validateLimits(element.getAsJsonObject(), dataValidationErrors);
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
|
|
||||||
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
|
|
||||||
}
|
|
||||||
|
|
||||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateOptionalStrings(final JsonElement element, final DataValidatorBuilder v) {
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(TDS_PERCENT, element)) {
|
|
||||||
v.reset().parameter(TDS_PERCENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_PERCENT, element))
|
|
||||||
.ignoreIfNull().zeroOrPositiveAmount();
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(TRANSFER_BRANCH, element)) {
|
|
||||||
v.reset().parameter(TRANSFER_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_BRANCH, element))
|
|
||||||
.ignoreIfNull().notExceedingLengthOf(100);
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)) {
|
|
||||||
v.reset().parameter(TRANSFER_AMOUNT_ACCOUNT_TYPE)
|
|
||||||
.value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)).ignoreIfNull()
|
|
||||||
.notExceedingLengthOf(100);
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(TRANSFER_ACCOUNT_NO, element)) {
|
|
||||||
v.reset().parameter(TRANSFER_ACCOUNT_NO).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_ACCOUNT_NO, element))
|
|
||||||
.ignoreIfNull().notExceedingLengthOf(50);
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(LIMIT, element)) {
|
|
||||||
v.reset().parameter(LIMIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(LIMIT, element)).ignoreIfNull()
|
|
||||||
.zeroOrPositiveAmount();
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(MACHINE_RENT, element)) {
|
|
||||||
v.reset().parameter(MACHINE_RENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(MACHINE_RENT, element))
|
|
||||||
.ignoreIfNull().zeroOrPositiveAmount();
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_BRANCH, element)) {
|
|
||||||
v.reset().parameter(AGENT_SECURITY_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_BRANCH, element))
|
|
||||||
.ignoreIfNull().notExceedingLengthOf(100);
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_TYPE, element)) {
|
|
||||||
v.reset().parameter(AGENT_SECURITY_ACCOUNT_TYPE)
|
|
||||||
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_TYPE, element)).ignoreIfNull()
|
|
||||||
.notExceedingLengthOf(100);
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_NO, element)) {
|
|
||||||
v.reset().parameter(AGENT_SECURITY_ACCOUNT_NO)
|
|
||||||
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_NO, element)).ignoreIfNull()
|
|
||||||
.notExceedingLengthOf(50);
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(OTHER_COMMISSION, element)) {
|
|
||||||
v.reset().parameter(OTHER_COMMISSION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_COMMISSION, element))
|
|
||||||
.ignoreIfNull().zeroOrPositiveAmount();
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(AGENT_DEPOSIT, element)) {
|
|
||||||
v.reset().parameter(AGENT_DEPOSIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_DEPOSIT, element))
|
|
||||||
.ignoreIfNull().zeroOrPositiveAmount();
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(FESTIVAL_ADVANCE, element)) {
|
|
||||||
v.reset().parameter(FESTIVAL_ADVANCE).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(FESTIVAL_ADVANCE, element))
|
|
||||||
.ignoreIfNull().zeroOrPositiveAmount();
|
|
||||||
}
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(OTHER_DEDUCTION, element)) {
|
|
||||||
v.reset().parameter(OTHER_DEDUCTION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_DEDUCTION, element))
|
|
||||||
.ignoreIfNull().zeroOrPositiveAmount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateLimits(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
|
|
||||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("agent.limit");
|
|
||||||
final var limitsArray = topLevelJson.getAsJsonArray(LIMITS);
|
|
||||||
for (int i = 0; i < limitsArray.size(); i++) {
|
|
||||||
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
|
|
||||||
this.fromApiJsonHelper.checkForUnsupportedParameters(limitJson, SUPPORTED_LIMIT_PARAMETERS);
|
|
||||||
|
|
||||||
v.reset().parameterAtIndexArray("fromDate", i).value(extractString(limitJson, "fromDate")).notBlank();
|
|
||||||
v.reset().parameterAtIndexArray("toDate", i).value(extractString(limitJson, "toDate")).notBlank();
|
|
||||||
v.reset().parameterAtIndexArray("limit", i).value(extractBigDecimal(limitJson, "limit")).notNull().zeroOrPositiveAmount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateGuarantors(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
|
|
||||||
final DataValidatorBuilder guarantorValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent.guarantor");
|
|
||||||
|
|
||||||
final var guarantorsArray = topLevelJson.getAsJsonArray(GUARANTORS);
|
|
||||||
for (int i = 0; i < guarantorsArray.size(); i++) {
|
|
||||||
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
|
|
||||||
this.fromApiJsonHelper.checkForUnsupportedParameters(guarantorJson, SUPPORTED_GUARANTOR_PARAMETERS);
|
|
||||||
|
|
||||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CLIENT_ID, i)
|
|
||||||
.value(extractLong(guarantorJson, GUARANTOR_CLIENT_ID)).notNull().integerGreaterThanZero();
|
|
||||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_NAME, i).value(extractString(guarantorJson, GUARANTOR_NAME))
|
|
||||||
.notBlank().notExceedingLengthOf(100);
|
|
||||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_ADDRESS1, i).value(extractString(guarantorJson, GUARANTOR_ADDRESS1))
|
|
||||||
.notBlank().notExceedingLengthOf(200);
|
|
||||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CITY, i).value(extractString(guarantorJson, GUARANTOR_CITY))
|
|
||||||
.notBlank().notExceedingLengthOf(100);
|
|
||||||
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_PINCODE, i).value(extractString(guarantorJson, GUARANTOR_PINCODE))
|
|
||||||
.notBlank().notExceedingLengthOf(20);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractString(final JsonObject json, final String paramName) {
|
|
||||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long extractLong(final JsonObject json, final String paramName) {
|
|
||||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsLong() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private java.math.BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
|
|
||||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
|
||||||
if (!dataValidationErrors.isEmpty()) {
|
|
||||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
|
|
||||||
dataValidationErrors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-176
@@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.service;
|
|
||||||
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
|
||||||
import org.apache.fineract.organisation.agent.data.AgentData;
|
|
||||||
import org.apache.fineract.organisation.agent.data.AgentGuarantorData;
|
|
||||||
import org.apache.fineract.organisation.agent.data.AgentLimitData;
|
|
||||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
|
||||||
import org.springframework.dao.EmptyResultDataAccessException;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
private static final AgentMapper AGENT_MAPPER = new AgentMapper();
|
|
||||||
private static final AgentLimitMapper LIMIT_MAPPER = new AgentLimitMapper();
|
|
||||||
private static final AgentGuarantorMapper GUARANTOR_MAPPER = new AgentGuarantorMapper();
|
|
||||||
|
|
||||||
private static final class AgentMapper implements RowMapper<AgentData> {
|
|
||||||
|
|
||||||
public String schema() {
|
|
||||||
return " a.id as id, a.client_id as clientId, c.display_name as clientName, a.agent_code as agentCode, "
|
|
||||||
+ " a.agent_name as agentNameId, a.agent_address1 as agentAddress1, a.agent_address2 as agentAddress2, "
|
|
||||||
+ " a.agent_city as agentCity, a.agent_pincode as agentPinCode, a.agent_telephone as agentTelephone, "
|
|
||||||
+ " a.agent_saving_account_type as agentSavingAccountType, a.agent_saving_account_no as agentSavingAccountNo, "
|
|
||||||
+ " a.agent_opening_date as agentOpeningDate, a.agent_birth_date as agentBirthDate, "
|
|
||||||
+ " a.agent_current_balance as agentCurrentBalance, a.agent_opening_balance as agentOpeningBalance, "
|
|
||||||
+ " a.home_branch as homeBranch, a.saving_branch as savingBranch, a.tds_applicable as tdsApplicable, "
|
|
||||||
+ " a.tds_percent as tdsPercent, a.current_tds as currentTds, a.tds_amount as tdsAmount, "
|
|
||||||
+ " a.transfer_branch as transferBranch, a.transfer_amount_account_type as transferAmountAccountType, "
|
|
||||||
+ " a.transfer_account_no as transferAccountNo, a.collector_commission_calculation as commissionCalculation, "
|
|
||||||
+ " a.collector_limit_amount as limitAmount, a.machine_rent as machineRent, "
|
|
||||||
+ " a.agent_security_branch as agentSecurityBranch, a.agent_security_account_type as agentSecurityAccountType, "
|
|
||||||
+ " a.agent_security_account_no as agentSecurityAccountNo, a.commission as commission, "
|
|
||||||
+ " a.other_commission as otherCommission, a.agent_deposit as agentDeposit, "
|
|
||||||
+ " a.festival_advance as festivalAdvance, a.other_deduction as otherDeduction "
|
|
||||||
+ " from m_dds_agent a join m_client c on c.id = a.client_id";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public AgentData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
|
||||||
return AgentData.instance(rs.getLong("id"), rs.getLong("clientId"), rs.getString("clientName"), rs.getString("agentCode"),
|
|
||||||
rs.getString("agentNameId"), rs.getString("agentAddress1"), rs.getString("agentAddress2"),
|
|
||||||
rs.getString("agentCity"), rs.getString("agentPinCode"), rs.getString("agentTelephone"),
|
|
||||||
rs.getString("agentSavingAccountType"), rs.getString("agentSavingAccountNo"),
|
|
||||||
JdbcSupport.getLocalDate(rs, "agentOpeningDate"), JdbcSupport.getLocalDate(rs, "agentBirthDate"),
|
|
||||||
rs.getBigDecimal("agentCurrentBalance"), rs.getBigDecimal("agentOpeningBalance"), rs.getString("homeBranch"),
|
|
||||||
rs.getString("savingBranch"), rs.getBoolean("tdsApplicable"), rs.getBigDecimal("tdsPercent"),
|
|
||||||
rs.getBigDecimal("currentTds"), rs.getBigDecimal("tdsAmount"), rs.getString("transferBranch"),
|
|
||||||
rs.getString("transferAmountAccountType"), rs.getString("transferAccountNo"),
|
|
||||||
rs.getBoolean("commissionCalculation"), rs.getBigDecimal("limitAmount"), rs.getBigDecimal("machineRent"),
|
|
||||||
rs.getString("agentSecurityBranch"), rs.getString("agentSecurityAccountType"),
|
|
||||||
rs.getString("agentSecurityAccountNo"), rs.getBigDecimal("commission"), rs.getBigDecimal("otherCommission"),
|
|
||||||
rs.getBigDecimal("agentDeposit"), rs.getBigDecimal("festivalAdvance"), rs.getBigDecimal("otherDeduction"),
|
|
||||||
new ArrayList<>(), new ArrayList<>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class AgentLimitMapper implements RowMapper<Map.Entry<Long, AgentLimitData>> {
|
|
||||||
|
|
||||||
public String schema() {
|
|
||||||
return " l.agent_id as agentId, l.id as id, l.from_date as fromDate, l.to_date as toDate, "
|
|
||||||
+ " l.limit_amount as limitAmount from m_dds_agent_limit l";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Map.Entry<Long, AgentLimitData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
|
||||||
final Long agentId = rs.getLong("agentId");
|
|
||||||
final AgentLimitData data = AgentLimitData.instance(rs.getLong("id"), JdbcSupport.getLocalDate(rs, "fromDate"),
|
|
||||||
JdbcSupport.getLocalDate(rs, "toDate"), rs.getBigDecimal("limitAmount"));
|
|
||||||
return Map.entry(agentId, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class AgentGuarantorMapper implements RowMapper<Map.Entry<Long, AgentGuarantorData>> {
|
|
||||||
|
|
||||||
public String schema() {
|
|
||||||
return " g.agent_id as agentId, g.id as id, g.client_id as clientId, g.guarantor_name as guarantorName, "
|
|
||||||
+ " g.address1 as address1, g.address2 as address2, g.city as city, g.pin_code as pinCode, "
|
|
||||||
+ " g.telephone as telephone from m_dds_agent_guarantor g";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Map.Entry<Long, AgentGuarantorData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
|
||||||
final Long agentId = rs.getLong("agentId");
|
|
||||||
final AgentGuarantorData data = AgentGuarantorData.instance(rs.getLong("id"), rs.getLong("clientId"),
|
|
||||||
rs.getString("guarantorName"), rs.getString("address1"), rs.getString("address2"), rs.getString("city"),
|
|
||||||
rs.getString("pinCode"), rs.getString("telephone"));
|
|
||||||
return Map.entry(agentId, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Collection<AgentData> retrieveAll() {
|
|
||||||
final String sql = "select " + AGENT_MAPPER.schema() + " order by a.id";
|
|
||||||
final List<AgentData> agents = this.jdbcTemplate.query(sql, AGENT_MAPPER); // NOSONAR
|
|
||||||
return attachChildren(agents);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public AgentData retrieveOne(final Long agentId) {
|
|
||||||
try {
|
|
||||||
final String sql = "select " + AGENT_MAPPER.schema() + " where a.id = ?";
|
|
||||||
final AgentData agent = this.jdbcTemplate.queryForObject(sql, AGENT_MAPPER, agentId); // NOSONAR
|
|
||||||
return attachChildren(List.of(agent)).iterator().next();
|
|
||||||
} catch (final EmptyResultDataAccessException e) {
|
|
||||||
throw new AgentNotFoundException(agentId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Collection<AgentData> attachChildren(final List<AgentData> agents) {
|
|
||||||
if (agents.isEmpty()) {
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String limitSql = "select " + LIMIT_MAPPER.schema() + " order by l.id";
|
|
||||||
final List<Map.Entry<Long, AgentLimitData>> limitRows = this.jdbcTemplate.query(limitSql, LIMIT_MAPPER); // NOSONAR
|
|
||||||
final Map<Long, List<AgentLimitData>> limitsByAgentId = new LinkedHashMap<>();
|
|
||||||
for (final Map.Entry<Long, AgentLimitData> row : limitRows) {
|
|
||||||
limitsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
final String guarantorSql = "select " + GUARANTOR_MAPPER.schema() + " order by g.id";
|
|
||||||
final List<Map.Entry<Long, AgentGuarantorData>> guarantorRows = this.jdbcTemplate.query(guarantorSql, GUARANTOR_MAPPER); // NOSONAR
|
|
||||||
final Map<Long, List<AgentGuarantorData>> guarantorsByAgentId = new LinkedHashMap<>();
|
|
||||||
for (final Map.Entry<Long, AgentGuarantorData> row : guarantorRows) {
|
|
||||||
guarantorsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<AgentData> result = new ArrayList<>(agents.size());
|
|
||||||
for (final AgentData agent : agents) {
|
|
||||||
final List<AgentLimitData> limits = limitsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
|
|
||||||
final List<AgentGuarantorData> guarantors = guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
|
|
||||||
result.add(AgentData.instance(agent.getId(), agent.getClientId(), agent.getClientName(), agent.getAgentCode(),
|
|
||||||
agent.getAgentNameId(), agent.getAgentAddress1(), agent.getAgentAddress2(), agent.getAgentCity(),
|
|
||||||
agent.getAgentPinCode(), agent.getAgentTelephone(), agent.getAgentSavingAccountType(),
|
|
||||||
agent.getAgentSavingAccountNo(), agent.getAgentOpeningDate(), agent.getAgentBirthDate(),
|
|
||||||
agent.getAgentCurrentBalance(), agent.getAgentOpeningBalance(), agent.getHomeBranch(), agent.getSavingBranch(),
|
|
||||||
agent.getTdsApplicable(), agent.getTdsPercent(), agent.getCurrentTds(), agent.getTdsAmount(),
|
|
||||||
agent.getTransferBranch(), agent.getTransferAmountAccountType(), agent.getTransferAccountNo(),
|
|
||||||
agent.getCommissionCalculation(), agent.getLimit(), agent.getMachineRent(), agent.getAgentSecurityBranch(),
|
|
||||||
agent.getAgentSecurityAccountType(), agent.getAgentSecurityAccountNo(), agent.getCommission(),
|
|
||||||
agent.getOtherCommission(), agent.getAgentDeposit(), agent.getFestivalAdvance(), agent.getOtherDeduction(),
|
|
||||||
limits, guarantors));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-187
@@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.service;
|
|
||||||
|
|
||||||
import com.google.gson.JsonArray;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import jakarta.persistence.PersistenceException;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
|
||||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
|
||||||
import org.apache.fineract.organisation.agent.domain.AgentGuarantor;
|
|
||||||
import org.apache.fineract.organisation.agent.domain.AgentLimit;
|
|
||||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
|
||||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
|
||||||
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePlatformService {
|
|
||||||
|
|
||||||
private final AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
|
||||||
private final AgentRepository agentRepository;
|
|
||||||
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
@Override
|
|
||||||
public CommandProcessingResult createAgent(final JsonCommand command) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
|
||||||
|
|
||||||
final Long clientId = command.longValueOfParameterNamed("clientId");
|
|
||||||
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
|
|
||||||
|
|
||||||
final String agentCode = generateNextAgentCode();
|
|
||||||
final Agent agent = Agent.fromJson(client, agentCode, command);
|
|
||||||
|
|
||||||
addLimitsFromCommand(agent, command);
|
|
||||||
addGuarantorsFromCommand(agent, command);
|
|
||||||
|
|
||||||
this.agentRepository.saveAndFlush(agent);
|
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
|
||||||
.withCommandId(command.commandId()) //
|
|
||||||
.withEntityId(agent.getId()) //
|
|
||||||
.build();
|
|
||||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
|
||||||
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
|
||||||
return CommandProcessingResult.empty();
|
|
||||||
} catch (final PersistenceException dve) {
|
|
||||||
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
|
||||||
handleAgentDataIntegrityIssues(throwable, dve);
|
|
||||||
return CommandProcessingResult.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
@Override
|
|
||||||
public CommandProcessingResult updateAgent(final Long agentId, final JsonCommand command) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
|
||||||
|
|
||||||
final Agent agentForUpdate = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
|
|
||||||
final Map<String, Object> changesOnly = agentForUpdate.update(command);
|
|
||||||
|
|
||||||
if (changesOnly.containsKey("clientId")) {
|
|
||||||
final Long newClientId = (Long) changesOnly.get("clientId");
|
|
||||||
final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId);
|
|
||||||
agentForUpdate.changeClient(newClient);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.parameterExists("limits")) {
|
|
||||||
agentForUpdate.resetLimits();
|
|
||||||
addLimitsFromCommand(agentForUpdate, command);
|
|
||||||
changesOnly.put("limits", command.jsonFragment("limits"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command.parameterExists("guarantors")) {
|
|
||||||
agentForUpdate.resetGuarantors();
|
|
||||||
addGuarantorsFromCommand(agentForUpdate, command);
|
|
||||||
changesOnly.put("guarantors", command.jsonFragment("guarantors"));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.agentRepository.saveAndFlush(agentForUpdate);
|
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agentId).with(changesOnly)
|
|
||||||
.build();
|
|
||||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
|
||||||
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
|
|
||||||
return CommandProcessingResult.empty();
|
|
||||||
} catch (final PersistenceException dve) {
|
|
||||||
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
|
|
||||||
handleAgentDataIntegrityIssues(throwable, dve);
|
|
||||||
return CommandProcessingResult.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addLimitsFromCommand(final Agent agent, final JsonCommand command) {
|
|
||||||
if (!command.parameterExists("limits")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final JsonArray limitsArray = command.arrayOfParameterNamed("limits");
|
|
||||||
if (limitsArray == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (int i = 0; i < limitsArray.size(); i++) {
|
|
||||||
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
|
|
||||||
final LocalDate fromDate = LocalDate.parse(limitJson.get("fromDate").getAsString());
|
|
||||||
final LocalDate toDate = LocalDate.parse(limitJson.get("toDate").getAsString());
|
|
||||||
final BigDecimal limit = limitJson.get("limit").getAsBigDecimal();
|
|
||||||
agent.addLimit(new AgentLimit(fromDate, toDate, limit));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addGuarantorsFromCommand(final Agent agent, final JsonCommand command) {
|
|
||||||
if (!command.parameterExists("guarantors")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final JsonArray guarantorsArray = command.arrayOfParameterNamed("guarantors");
|
|
||||||
if (guarantorsArray == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (int i = 0; i < guarantorsArray.size(); i++) {
|
|
||||||
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
|
|
||||||
final Long guarantorClientId = guarantorJson.get("clientId").getAsLong();
|
|
||||||
final String guarantorName = guarantorJson.get("guarantorName").getAsString();
|
|
||||||
final String address1 = guarantorJson.get("address1").getAsString();
|
|
||||||
final String address2 = guarantorJson.has("address2") && !guarantorJson.get("address2").isJsonNull()
|
|
||||||
? guarantorJson.get("address2").getAsString()
|
|
||||||
: null;
|
|
||||||
final String city = guarantorJson.get("city").getAsString();
|
|
||||||
final String pinCode = guarantorJson.get("pinCode").getAsString();
|
|
||||||
final String telephone = guarantorJson.has("telephone") && !guarantorJson.get("telephone").isJsonNull()
|
|
||||||
? guarantorJson.get("telephone").getAsString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
agent.addGuarantor(new AgentGuarantor(guarantorClientId, guarantorName, address1, address2, city, pinCode, telephone));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String generateNextAgentCode() {
|
|
||||||
final Long maxSuffix = this.agentRepository.retrieveMaxAgentCodeSuffix();
|
|
||||||
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
|
|
||||||
return String.format("AG%04d", nextSuffix);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleAgentDataIntegrityIssues(final Throwable realCause, final Exception dve) {
|
|
||||||
if (realCause.getMessage() != null && realCause.getMessage().contains("agent_code")) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.agent.duplicate.agentCode", "An agent with the generated code already exists, please retry");
|
|
||||||
}
|
|
||||||
|
|
||||||
log.error("Error occured.", dve);
|
|
||||||
throw ErrorHandler.getMappable(dve, "error.msg.agent.unknown.data.integrity.issue",
|
|
||||||
"Unknown data integrity issue with resource: " + realCause.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-48
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.agent.starter;
|
|
||||||
|
|
||||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
|
||||||
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
|
|
||||||
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
|
|
||||||
import org.apache.fineract.organisation.agent.service.AgentReadPlatformServiceImpl;
|
|
||||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
|
|
||||||
import org.apache.fineract.organisation.agent.service.AgentWritePlatformServiceJpaRepositoryImpl;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
public class OrganisationAgentConfiguration {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(AgentReadPlatformService.class)
|
|
||||||
public AgentReadPlatformService agentReadPlatformService(JdbcTemplate jdbcTemplate) {
|
|
||||||
return new AgentReadPlatformServiceImpl(jdbcTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(AgentWritePlatformService.class)
|
|
||||||
public AgentWritePlatformService agentWritePlatformService(AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
|
||||||
AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper) {
|
|
||||||
return new AgentWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, agentRepository, clientRepositoryWrapper);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-74
@@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.commissionslab.api;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import jakarta.ws.rs.Consumes;
|
|
||||||
import jakarta.ws.rs.GET;
|
|
||||||
import jakarta.ws.rs.POST;
|
|
||||||
import jakarta.ws.rs.Path;
|
|
||||||
import jakarta.ws.rs.Produces;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import java.util.Collection;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
|
|
||||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Path("/v1/dds/commissionslabs")
|
|
||||||
@Component
|
|
||||||
@Tag(name = "DDS Commission Slabs", description = "The agent commission chart: ACCOUNT slabs (commission percentage on collection volume) and COMMISSION slabs (the override a senior agent earns on a sub-agent's commission). Append-only reference data - no update or delete API.")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CommissionSlabsApiResource {
|
|
||||||
|
|
||||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "COMMISSIONSLAB";
|
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final CommissionSlabReadPlatformService readPlatformService;
|
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
@Operation(summary = "Retrieve DDS Commission Slabs", description = "Returns the full commission slab chart.")
|
|
||||||
public Collection<CommissionSlabData> retrieveAll() {
|
|
||||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
|
||||||
return readPlatformService.retrieveAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@POST
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
@Operation(summary = "Add DDS Commission Slabs", description = "Mandatory fields: slabs (array of {slabType: ACCOUNT|COMMISSION, "
|
|
||||||
+ "slabFrom, slabTo, commissionPercent}). Adds new slab rows to the chart - it does not replace or edit existing rows.")
|
|
||||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
|
||||||
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
|
||||||
.createCommissionSlabs() //
|
|
||||||
.withJson(apiRequestBodyAsJson) //
|
|
||||||
.build();
|
|
||||||
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.commissionslab.serialization;
|
|
||||||
|
|
||||||
import com.google.gson.JsonElement;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.google.gson.reflect.TypeToken;
|
|
||||||
import java.lang.reflect.Type;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public final class CommissionSlabCommandFromApiJsonDeserializer {
|
|
||||||
|
|
||||||
private static final Set<String> SUPPORTED_SLAB_TYPES = new HashSet<>(Arrays.asList("ACCOUNT", "COMMISSION"));
|
|
||||||
|
|
||||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
|
|
||||||
|
|
||||||
private static final Set<String> SUPPORTED_SLAB_PARAMETERS = new HashSet<>(
|
|
||||||
Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent"));
|
|
||||||
|
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public CommissionSlabCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
|
|
||||||
this.fromApiJsonHelper = fromApiJsonHelper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void validateForCreate(final String json) {
|
|
||||||
if (StringUtils.isBlank(json)) {
|
|
||||||
throw new InvalidJsonException();
|
|
||||||
}
|
|
||||||
|
|
||||||
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
|
|
||||||
|
|
||||||
}.getType();
|
|
||||||
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
|
|
||||||
|
|
||||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
|
||||||
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("commissionslab");
|
|
||||||
|
|
||||||
final JsonElement element = this.fromApiJsonHelper.parse(json);
|
|
||||||
|
|
||||||
if (!this.fromApiJsonHelper.parameterExists("slabs", element)) {
|
|
||||||
v.reset().parameter("slabs").value(null).notNull();
|
|
||||||
} else {
|
|
||||||
final JsonObject topLevel = element.getAsJsonObject();
|
|
||||||
final var slabsArray = topLevel.getAsJsonArray("slabs");
|
|
||||||
v.reset().parameter("slabs").value(slabsArray).jsonArrayNotEmpty();
|
|
||||||
|
|
||||||
for (int i = 0; i < slabsArray.size(); i++) {
|
|
||||||
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
|
|
||||||
this.fromApiJsonHelper.checkForUnsupportedParameters(slabJson, SUPPORTED_SLAB_PARAMETERS);
|
|
||||||
|
|
||||||
final String slabType = extractString(slabJson, "slabType");
|
|
||||||
v.reset().parameterAtIndexArray("slabType", i).value(slabType).notBlank()
|
|
||||||
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES.toArray());
|
|
||||||
v.reset().parameterAtIndexArray("slabFrom", i).value(extractBigDecimal(slabJson, "slabFrom")).notNull()
|
|
||||||
.zeroOrPositiveAmount();
|
|
||||||
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
|
|
||||||
.zeroOrPositiveAmount();
|
|
||||||
v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent"))
|
|
||||||
.notNull().zeroOrPositiveAmount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractString(final JsonObject json, final String paramName) {
|
|
||||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
|
|
||||||
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
|
|
||||||
if (!dataValidationErrors.isEmpty()) {
|
|
||||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
|
|
||||||
dataValidationErrors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.commissionslab.service;
|
|
||||||
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.util.List;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CommissionSlabReadPlatformServiceImpl implements CommissionSlabReadPlatformService {
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
private static final CommissionSlabMapper MAPPER = new CommissionSlabMapper();
|
|
||||||
|
|
||||||
private static final class CommissionSlabMapper implements RowMapper<CommissionSlabData> {
|
|
||||||
|
|
||||||
public String schema() {
|
|
||||||
return " s.id as id, s.slab_type as slabType, s.slab_from as slabFrom, s.slab_to as slabTo, "
|
|
||||||
+ " s.commission_percent as commissionPercent from m_dds_commission_slab s";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public CommissionSlabData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
|
||||||
return CommissionSlabData.builder() //
|
|
||||||
.id(rs.getLong("id")) //
|
|
||||||
.slabType(rs.getString("slabType")) //
|
|
||||||
.slabFrom(rs.getBigDecimal("slabFrom")) //
|
|
||||||
.slabTo(rs.getBigDecimal("slabTo")) //
|
|
||||||
.commissionPercent(rs.getBigDecimal("commissionPercent")) //
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<CommissionSlabData> retrieveAll() {
|
|
||||||
final String sql = "select " + MAPPER.schema() + " order by s.slab_type, s.slab_from";
|
|
||||||
return this.jdbcTemplate.query(sql, MAPPER); // NOSONAR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-78
@@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.commissionslab.service;
|
|
||||||
|
|
||||||
import com.google.gson.JsonArray;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import jakarta.persistence.PersistenceException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
|
||||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
|
|
||||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
|
||||||
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
|
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements CommissionSlabWritePlatformService {
|
|
||||||
|
|
||||||
private final CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
|
||||||
private final CommissionSlabRepository commissionSlabRepository;
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
@Override
|
|
||||||
public CommandProcessingResult createCommissionSlabs(final JsonCommand command) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
|
||||||
|
|
||||||
final List<CommissionSlab> slabs = new ArrayList<>();
|
|
||||||
final JsonArray slabsArray = command.arrayOfParameterNamed("slabs");
|
|
||||||
for (int i = 0; i < slabsArray.size(); i++) {
|
|
||||||
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
|
|
||||||
slabs.add(new CommissionSlab(slabJson.get("slabType").getAsString(), slabJson.get("slabFrom").getAsBigDecimal(),
|
|
||||||
slabJson.get("slabTo").getAsBigDecimal(), slabJson.get("commissionPercent").getAsBigDecimal()));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.commissionSlabRepository.saveAllAndFlush(slabs);
|
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
|
||||||
.withCommandId(command.commandId()) //
|
|
||||||
.withEntityId(slabs.isEmpty() ? null : slabs.get(0).getId()) //
|
|
||||||
.build();
|
|
||||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
|
||||||
log.error("Error occured.", dve);
|
|
||||||
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
|
|
||||||
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
|
|
||||||
} catch (final PersistenceException dve) {
|
|
||||||
log.error("Error occured.", dve);
|
|
||||||
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
|
|
||||||
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-47
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.organisation.commissionslab.starter;
|
|
||||||
|
|
||||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
|
||||||
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
|
|
||||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
|
|
||||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformServiceImpl;
|
|
||||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
|
|
||||||
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformServiceJpaRepositoryImpl;
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
public class OrganisationCommissionSlabConfiguration {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(CommissionSlabReadPlatformService.class)
|
|
||||||
public CommissionSlabReadPlatformService commissionSlabReadPlatformService(JdbcTemplate jdbcTemplate) {
|
|
||||||
return new CommissionSlabReadPlatformServiceImpl(jdbcTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@ConditionalOnMissingBean(CommissionSlabWritePlatformService.class)
|
|
||||||
public CommissionSlabWritePlatformService commissionSlabWritePlatformService(
|
|
||||||
CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer, CommissionSlabRepository commissionSlabRepository) {
|
|
||||||
return new CommissionSlabWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, commissionSlabRepository);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
@@ -29,6 +29,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.DefaultValue;
|
import jakarta.ws.rs.DefaultValue;
|
||||||
|
import jakarta.ws.rs.DELETE;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.PUT;
|
import jakarta.ws.rs.PUT;
|
||||||
@@ -200,6 +201,20 @@ public class OfficesApiResource {
|
|||||||
return toApiJsonSerializer.serialize(result);
|
return toApiJsonSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("{officeId}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Delete an Office", description = "Deletes an office outright. Fails if the office still has sub-offices, staff, clients, groups, users, or any other record attached to it.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "OK")
|
||||||
|
public String deleteOffice(@PathParam("officeId") @Parameter(description = "officeId") final Long officeId) {
|
||||||
|
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
|
||||||
|
.deleteOffice(officeId) //
|
||||||
|
.build();
|
||||||
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
|
return toApiJsonSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/external-id/{externalId}")
|
@Path("/external-id/{externalId}")
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
|||||||
+8
-7
@@ -16,31 +16,32 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.dds.batch.handler;
|
package org.apache.fineract.organisation.office.handler;
|
||||||
|
|
||||||
import org.apache.fineract.commands.annotation.CommandType;
|
import org.apache.fineract.commands.annotation.CommandType;
|
||||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
import org.apache.fineract.organisation.office.service.OfficeWritePlatformService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@CommandType(entity = "DDSBATCH", action = "POST")
|
@CommandType(entity = "OFFICE", action = "DELETE")
|
||||||
public class PostBatchRunCommandHandler implements NewCommandSourceHandler {
|
public class DeleteOfficeCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
private final BatchRunWritePlatformService writePlatformService;
|
private final OfficeWritePlatformService writePlatformService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public PostBatchRunCommandHandler(final BatchRunWritePlatformService writePlatformService) {
|
public DeleteOfficeCommandHandler(final OfficeWritePlatformService writePlatformService) {
|
||||||
this.writePlatformService = writePlatformService;
|
this.writePlatformService = writePlatformService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
return this.writePlatformService.postBatchRun(command.entityId(), command);
|
|
||||||
|
return this.writePlatformService.deleteOffice(command.entityId(), command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
@@ -27,6 +27,8 @@ public interface OfficeWritePlatformService {
|
|||||||
|
|
||||||
CommandProcessingResult updateOffice(Long officeId, JsonCommand command);
|
CommandProcessingResult updateOffice(Long officeId, JsonCommand command);
|
||||||
|
|
||||||
|
CommandProcessingResult deleteOffice(Long officeId, JsonCommand command);
|
||||||
|
|
||||||
CommandProcessingResult officeTransaction(JsonCommand command);
|
CommandProcessingResult officeTransaction(JsonCommand command);
|
||||||
|
|
||||||
CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command);
|
CommandProcessingResult deleteOfficeTransaction(Long id, JsonCommand command);
|
||||||
|
|||||||
+75
@@ -44,6 +44,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
|
|||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
import org.springframework.cache.annotation.Caching;
|
import org.springframework.cache.annotation.Caching;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -57,6 +58,13 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
|
|||||||
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
||||||
private final OfficeTransactionRepository officeTransactionRepository;
|
private final OfficeTransactionRepository officeTransactionRepository;
|
||||||
private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository;
|
private final ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository;
|
||||||
|
// Lightweight existence checks ahead of delete -- m_office has ~30 tables with a foreign key back
|
||||||
|
// to it (clients, staff, GL entries, tellers, cheque stock, current accounts, ...); pulling in a
|
||||||
|
// repository for every one of them just to count rows would be a lot of new wiring for a check
|
||||||
|
// that is fundamentally "does anything point at this row". A handful of raw counts up front cover
|
||||||
|
// the cases a user can actually resolve themselves (move the clients, reassign the staff); the
|
||||||
|
// long tail that is left is caught by the try/catch around the delete itself.
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -147,6 +155,73 @@ public class OfficeWritePlatformServiceJpaRepositoryImpl implements OfficeWriteP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an office outright -- distinct from every other office command, which only ever changes
|
||||||
|
* status or fields. There is no "closed" state for an office the way there is for a client or a
|
||||||
|
* savings account, and no command handler ever implemented this, so the UI's "Remove" action always
|
||||||
|
* failed with a generic error no matter how empty the office was; the endpoint it called never
|
||||||
|
* existed.
|
||||||
|
*
|
||||||
|
* Checked up front, in the order a user could actually act on them: sub-offices first (the deepest
|
||||||
|
* structural block), then staff, clients and groups (the day-to-day reason an office still has
|
||||||
|
* anything in it), then any AppUser still homed there. Everything else that references m_office --
|
||||||
|
* GL journal entries, teller cash positions, cheque stock, current accounts, loan/savings
|
||||||
|
* transactions, and more -- is not spelled out here one table at a time; a real foreign-key
|
||||||
|
* violation on the delete itself is caught below and turned into one clear message instead. An
|
||||||
|
* office with real banking history behind it is not something this should make easy to remove either
|
||||||
|
* way, checked up front or not.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
@Caching(evict = {
|
||||||
|
@CacheEvict(value = "offices", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'of')"),
|
||||||
|
@CacheEvict(value = "officesForDropdown", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#root.target.context.authenticatedUser().getOffice().getHierarchy()+'ofd')"),
|
||||||
|
@CacheEvict(value = "officesById", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat(#officeId)") })
|
||||||
|
public CommandProcessingResult deleteOffice(final Long officeId, final JsonCommand command) {
|
||||||
|
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||||
|
|
||||||
|
requireNoneReference(officeId, "m_office", "parent_id", "sub-office", "sub-offices",
|
||||||
|
"Move or remove its sub-offices before deleting this office.");
|
||||||
|
requireNoneReference(officeId, "m_staff", "office_id", "staff member", "staff members",
|
||||||
|
"Reassign its staff to another office before deleting this office.");
|
||||||
|
requireNoneReference(officeId, "m_client", "office_id", "client", "clients",
|
||||||
|
"Transfer its clients to another office before deleting this office.");
|
||||||
|
requireNoneReference(officeId, "m_group", "office_id", "group", "groups",
|
||||||
|
"Transfer its groups to another office before deleting this office.");
|
||||||
|
requireNoneReference(officeId, "m_appuser", "office_id", "user", "users",
|
||||||
|
"Reassign the users homed at this office before deleting it.");
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.officeRepositoryWrapper.deleteAndFlush(office);
|
||||||
|
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||||
|
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
|
||||||
|
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
|
||||||
|
} catch (final PersistenceException dve) {
|
||||||
|
throw ErrorHandler.getMappable(dve, "error.msg.office.cannot.delete.has.references",
|
||||||
|
"This office cannot be deleted: other records (accounts, transactions or history) still reference it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CommandProcessingResultBuilder() //
|
||||||
|
.withCommandId(command.commandId()) //
|
||||||
|
.withEntityId(officeId) //
|
||||||
|
.withOfficeId(officeId) //
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireNoneReference(final Long officeId, final String table, final String column, final String singular,
|
||||||
|
final String plural, final String remedy) {
|
||||||
|
final Long count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ?", Long.class,
|
||||||
|
officeId);
|
||||||
|
if (count != null && count > 0) {
|
||||||
|
final String noun = count == 1 ? singular : plural;
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.office.cannot.delete.still.referenced",
|
||||||
|
"Office cannot be deleted: it has " + count + " " + noun + " attached. " + remedy, "officeId", officeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult officeTransaction(final JsonCommand command) {
|
public CommandProcessingResult officeTransaction(final JsonCommand command) {
|
||||||
|
|||||||
+2
-2
@@ -56,9 +56,9 @@ public class OrganisationOfficeConfiguration {
|
|||||||
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
OfficeCommandFromApiJsonDeserializer fromApiJsonDeserializer,
|
||||||
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
|
OfficeTransactionCommandFromApiJsonDeserializer moneyTransferCommandFromApiJsonDeserializer,
|
||||||
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
|
OfficeRepositoryWrapper officeRepositoryWrapper, OfficeTransactionRepository officeTransactionRepository,
|
||||||
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository) {
|
ApplicationCurrencyRepositoryWrapper applicationCurrencyRepository, JdbcTemplate jdbcTemplate) {
|
||||||
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
|
return new OfficeWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer,
|
||||||
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
|
moneyTransferCommandFromApiJsonDeserializer, officeRepositoryWrapper, officeTransactionRepository,
|
||||||
applicationCurrencyRepository);
|
applicationCurrencyRepository, jdbcTemplate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user