Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c961f713 |
@@ -1,68 +0,0 @@
|
|||||||
-- Removes the duplicate vouchers left behind by the double-count bug.
|
|
||||||
--
|
|
||||||
-- Until 2026-09-03 the cashier deposit and clerk withdrawal registers did not wrap their inner
|
|
||||||
-- savings command in VoucherService.withOwnVoucher(...), so one counter transaction raised two
|
|
||||||
-- vouchers over the same money: the register's own (CASHIER_DEPOSIT_REQUEST / CLERK_WITHDRAWAL_
|
|
||||||
-- REQUEST) and a second one from the savings command (SAVINGS_TRANSACTION). The cashier's Received
|
|
||||||
-- and Payment reports counted both, doubling the day's cash.
|
|
||||||
--
|
|
||||||
-- The code is fixed, so no new duplicates are created. This clears the ones already recorded.
|
|
||||||
--
|
|
||||||
-- WHICH ONE IS DELETED: the SAVINGS_TRANSACTION voucher. The register's voucher is the keeper --
|
|
||||||
-- it is the one the register owns, attaches its DR/CR lines to, and stamps onto
|
|
||||||
-- m_savings_account_transaction.voucher_no. Deleting the register's voucher instead would break
|
|
||||||
-- that link and orphan the request row.
|
|
||||||
--
|
|
||||||
-- Run: psql -h localhost -U postgres -d fineract_default -v ON_ERROR_STOP=1 -f cleanup_duplicate_vouchers.sql
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- The duplicates: a SAVINGS_TRANSACTION voucher whose transaction is already claimed by a
|
|
||||||
-- register voucher. Matching through savings_txn_id rather than by date or amount so that two
|
|
||||||
-- genuinely separate transactions of the same value on the same day are never confused.
|
|
||||||
CREATE TEMP TABLE dup_vouchers ON COMMIT DROP AS
|
|
||||||
SELECT sav.voucher_no
|
|
||||||
FROM m_voucher sav
|
|
||||||
JOIN m_voucher reg
|
|
||||||
ON reg.savings_txn_id = sav.reference_id
|
|
||||||
AND reg.reference_type IN ('CLERK_WITHDRAWAL_REQUEST', 'CASHIER_DEPOSIT_REQUEST')
|
|
||||||
WHERE sav.reference_type = 'SAVINGS_TRANSACTION'
|
|
||||||
AND sav.voucher_type IN ('DEPOSIT', 'WITHDRAWAL');
|
|
||||||
|
|
||||||
-- Refuse to run if anything else has attached itself to a voucher about to be deleted: a
|
|
||||||
-- verification or an attachment means someone acted on that slip, and it must be looked at by hand
|
|
||||||
-- rather than removed silently.
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
blocked INT;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*) INTO blocked
|
|
||||||
FROM (SELECT voucher_no FROM m_voucher_verification
|
|
||||||
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)
|
|
||||||
UNION ALL
|
|
||||||
SELECT voucher_no FROM m_voucher_attachment
|
|
||||||
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)) x;
|
|
||||||
IF blocked > 0 THEN
|
|
||||||
RAISE EXCEPTION 'Aborting: % duplicate voucher(s) carry a verification or attachment. Review these by hand.', blocked;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- Likewise refuse if a savings transaction is stamped with a voucher about to be deleted. It should
|
|
||||||
-- carry the register's number, not the duplicate's; if it does not, the pair needs looking at.
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
stamped INT;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*) INTO stamped FROM m_savings_account_transaction
|
|
||||||
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
|
||||||
IF stamped > 0 THEN
|
|
||||||
RAISE EXCEPTION 'Aborting: % savings transaction(s) are stamped with a duplicate voucher number.', stamped;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
SELECT voucher_no AS "about to delete" FROM dup_vouchers ORDER BY voucher_no;
|
|
||||||
|
|
||||||
DELETE FROM m_voucher_line WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
|
||||||
DELETE FROM m_voucher WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -127,6 +127,14 @@ SELECT 'Inter-Bank Cap Charge', 'INR', 0.00, 2, 3, 1, 0, 1, 0, 0, 0,
|
|||||||
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
|
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0);
|
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO m_charge
|
||||||
|
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
|
||||||
|
charge_calculation_enum, is_penalty, is_active, is_deleted,
|
||||||
|
is_free_withdrawal, is_payment_type, income_or_liability_account_id)
|
||||||
|
SELECT 'Withdrawal Facility Charge', 'INR', 5.00, 2, 3, 1, 0, 1, 0, 0, 0,
|
||||||
|
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0);
|
||||||
|
|
||||||
INSERT IGNORE INTO m_charge
|
INSERT IGNORE INTO m_charge
|
||||||
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
|
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
|
||||||
charge_calculation_enum, is_penalty, is_active, is_deleted,
|
charge_calculation_enum, is_penalty, is_active, is_deleted,
|
||||||
@@ -160,5 +168,6 @@ VALUES
|
|||||||
('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
||||||
('RTGS_FACILITY', 'RTGS Facility Charge', 25.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'RTGS Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
('RTGS_FACILITY', 'RTGS Facility Charge', 25.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'RTGS Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
||||||
('INTER_BANK_TOTAL_CAP','Inter-Bank Cap Charge', 0.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0 LIMIT 1)),
|
('INTER_BANK_TOTAL_CAP','Inter-Bank Cap Charge', 0.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0 LIMIT 1)),
|
||||||
|
('WITHDRAWAL_FACILITY', 'Withdrawal Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0 LIMIT 1)),
|
||||||
('DEPOSIT_FACILITY_KYC','Deposit KYC Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Deposit KYC Charge' AND is_deleted = 0 LIMIT 1)),
|
('DEPOSIT_FACILITY_KYC','Deposit KYC Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Deposit KYC Charge' AND is_deleted = 0 LIMIT 1)),
|
||||||
('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1));
|
('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1));
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ ALTER TABLE m_current_product_facility_config
|
|||||||
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
|
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ ALTER TABLE m_current_product_facility_config
|
|||||||
ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_cpfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_cpfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_cpfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_cpfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
|
||||||
|
ADD CONSTRAINT fk_cpfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_cpfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_cpfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_cpfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
|
ADD CONSTRAINT fk_cpfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ ALTER TABLE m_savings_product_facility_config
|
|||||||
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
|
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
|
||||||
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
|
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
|
||||||
|
|
||||||
@@ -71,5 +74,6 @@ ALTER TABLE m_savings_product_facility_config
|
|||||||
ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_spfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_spfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_spfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_spfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
|
||||||
|
ADD CONSTRAINT fk_spfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_spfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
|
ADD CONSTRAINT fk_spfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
|
||||||
ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
|
ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
|
||||||
|
|||||||
+16
-29
@@ -181,14 +181,10 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
|||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
|
||||||
.notNull().integerGreaterThanZero();
|
.notNull().integerGreaterThanZero();
|
||||||
|
|
||||||
// Transfers In Suspense is not offered by every savings product UI, so it is validated only
|
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
||||||
// when the client actually sends it. Omitting it leaves the mapping unset rather than failing.
|
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
||||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
||||||
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
||||||
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
|
||||||
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long interestOnSavingsAccountId = this.fromApiJsonHelper
|
final Long interestOnSavingsAccountId = this.fromApiJsonHelper
|
||||||
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
|
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
|
||||||
@@ -214,29 +210,20 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
|
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
|
||||||
// Overdraft Control / Income From Interest / Write Off apply to overdraft-capable products
|
final Long overdraftAccount = this.fromApiJsonHelper
|
||||||
// only, so they are validated when supplied rather than demanded from every savings product.
|
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
|
||||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(),
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
|
||||||
element)) {
|
.value(overdraftAccount).notNull().integerGreaterThanZero();
|
||||||
final Long overdraftAccount = this.fromApiJsonHelper
|
|
||||||
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
|
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
|
|
||||||
.value(overdraftAccount).notNull().integerGreaterThanZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) {
|
final Long incomeFromInterest = this.fromApiJsonHelper
|
||||||
final Long incomeFromInterest = this.fromApiJsonHelper
|
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
|
||||||
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()).value(incomeFromInterest)
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
|
.notNull().integerGreaterThanZero();
|
||||||
.value(incomeFromInterest).notNull().integerGreaterThanZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) {
|
final Long writtenOff = this.fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
|
||||||
final Long writtenOff = this.fromApiJsonHelper
|
element);
|
||||||
.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull()
|
||||||
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff)
|
.integerGreaterThanZero();
|
||||||
.notNull().integerGreaterThanZero();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -437,7 +437,6 @@ 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); //
|
||||||
|
|
||||||
|
|||||||
+1
-20
@@ -137,7 +137,7 @@ public class CommandSourceService {
|
|||||||
String permission = commandSource.getPermissionCode();
|
String permission = commandSource.getPermissionCode();
|
||||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||||
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
if (isApprovedByChecker || user.isCheckerSuperUser()) {
|
||||||
commandSource.markAsChecked(user);
|
commandSource.markAsChecked(user);
|
||||||
} else {
|
} else {
|
||||||
if (commandSource.isSanitized()) {
|
if (commandSource.isSanitized()) {
|
||||||
@@ -151,25 +151,6 @@ public class CommandSourceService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
|
||||||
if (!user.isCheckerSuperUser()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
|
||||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+72
-25
@@ -664,6 +664,78 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createAgent() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "AGENT";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/dds/agents/template";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder updateAgent(final Long agentId) {
|
||||||
|
this.actionName = "UPDATE";
|
||||||
|
this.entityName = "AGENT";
|
||||||
|
this.entityId = agentId;
|
||||||
|
this.href = "/dds/agents/" + agentId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder 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;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder createOfficeTransaction() {
|
public CommandWrapperBuilder createOfficeTransaction() {
|
||||||
this.actionName = "CREATE";
|
this.actionName = "CREATE";
|
||||||
this.entityName = "OFFICETRANSACTION";
|
this.entityName = "OFFICETRANSACTION";
|
||||||
@@ -2211,14 +2283,6 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder createCbsBaseInterestRate() {
|
|
||||||
this.actionName = "CREATE";
|
|
||||||
this.entityName = "CBSBASEINTERESTRATE";
|
|
||||||
this.entityId = null;
|
|
||||||
this.href = "/fixeddepositproducts/baseinterestrates";
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
||||||
this.actionName = "UPDATE";
|
this.actionName = "UPDATE";
|
||||||
this.entityName = "FIXEDDEPOSITPRODUCT";
|
this.entityName = "FIXEDDEPOSITPRODUCT";
|
||||||
@@ -2905,15 +2969,6 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) {
|
|
||||||
this.actionName = "PAYMENT";
|
|
||||||
this.entityName = "FIXEDDEPOSITACCOUNT";
|
|
||||||
this.entityId = accountId;
|
|
||||||
this.savingsId = accountId;
|
|
||||||
this.href = "/fixeddepositaccounts/" + accountId + "?command=payment";
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
|
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
|
||||||
this.actionName = "CALCULATEINTEREST";
|
this.actionName = "CALCULATEINTEREST";
|
||||||
this.entityName = "FIXEDDEPOSITACCOUNT";
|
this.entityName = "FIXEDDEPOSITACCOUNT";
|
||||||
@@ -3120,14 +3175,6 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder updateFacilityChargeMapping(final Long mappingId) {
|
|
||||||
this.actionName = "UPDATE";
|
|
||||||
this.entityName = "FACILITYCHARGEMAPPING";
|
|
||||||
this.entityId = mappingId;
|
|
||||||
this.href = "/facilitychargemappings/" + mappingId;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) {
|
public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) {
|
||||||
this.actionName = "DELETE";
|
this.actionName = "DELETE";
|
||||||
this.entityName = "FINANCIALACTIVITYACCOUNT";
|
this.entityName = "FINANCIALACTIVITYACCOUNT";
|
||||||
|
|||||||
-2
@@ -26,8 +26,6 @@ public interface ConfigurationDomainService {
|
|||||||
|
|
||||||
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
||||||
|
|
||||||
boolean isMakerCheckerEnabled();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
||||||
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
||||||
|
|||||||
-13
@@ -130,19 +130,6 @@ 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 + ".";
|
||||||
|
|||||||
-3
@@ -39,10 +39,7 @@ public enum JobName {
|
|||||||
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
||||||
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
||||||
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
||||||
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
|
|
||||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||||
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
|
||||||
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
|
||||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||||
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
||||||
|
|||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* 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
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable data object representing a single agent guarantor.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public final class AgentGuarantorData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long clientId;
|
||||||
|
private final String guarantorName;
|
||||||
|
private final String address1;
|
||||||
|
private final String address2;
|
||||||
|
private final String city;
|
||||||
|
private final String pinCode;
|
||||||
|
private final String telephone;
|
||||||
|
|
||||||
|
public static AgentGuarantorData instance(final Long id, final Long clientId, final String guarantorName, final String address1,
|
||||||
|
final String address2, final String city, final String pinCode, final String telephone) {
|
||||||
|
return new AgentGuarantorData(id, clientId, guarantorName, address1, address2, city, pinCode, telephone);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentGuarantorData(final Long id, final Long clientId, final String guarantorName, final String address1,
|
||||||
|
final String address2, final String city, final String pinCode, final String telephone) {
|
||||||
|
this.id = id;
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.guarantorName = guarantorName;
|
||||||
|
this.address1 = address1;
|
||||||
|
this.address2 = address2;
|
||||||
|
this.city = city;
|
||||||
|
this.pinCode = pinCode;
|
||||||
|
this.telephone = telephone;
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
/**
|
||||||
|
* 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
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.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
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.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
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RuntimeException} thrown when a DDS agent resource is not found.
|
||||||
|
*/
|
||||||
|
public class AgentNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public AgentNotFoundException(final Long id) {
|
||||||
|
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||||
|
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentNotFoundException(final String agentCode) {
|
||||||
|
super("error.msg.agent.code.invalid", "Agent with code " + agentCode + " does not exist", agentCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-7
@@ -16,15 +16,24 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.useradministration.service;
|
package org.apache.fineract.organisation.commissionslab.data;
|
||||||
|
|
||||||
import org.apache.fineract.infrastructure.core.service.Page;
|
import java.io.Serial;
|
||||||
import org.apache.fineract.useradministration.data.UserPendingRequestData;
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
public interface UserPendingReadPlatformService {
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public final class CommissionSlabData implements Serializable {
|
||||||
|
|
||||||
Page<UserPendingRequestData> retrieveAllPendingRequests(String status, String username, String fromDate, String toDate,
|
@Serial
|
||||||
Integer offset, Integer limit);
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
UserPendingRequestData retrievePendingRequest(Long id);
|
private final Long id;
|
||||||
|
private final String slabType;
|
||||||
|
private final BigDecimal slabFrom;
|
||||||
|
private final BigDecimal slabTo;
|
||||||
|
private final BigDecimal commissionPercent;
|
||||||
}
|
}
|
||||||
+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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-16
@@ -72,26 +72,11 @@ public class SavingsAccountSummaryData {
|
|||||||
// differentiate deposit accounts Savings, FD and RD accounts
|
// differentiate deposit accounts Savings, FD and RD accounts
|
||||||
private final EnumOptionData depositType;
|
private final EnumOptionData depositType;
|
||||||
|
|
||||||
/**
|
|
||||||
* For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal
|
|
||||||
* chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo
|
|
||||||
* for an FD that has never been renewed, and is null for non-deposit accounts.
|
|
||||||
*/
|
|
||||||
private final String fdAccountNumber;
|
|
||||||
/**
|
|
||||||
* Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so
|
|
||||||
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
|
|
||||||
*/
|
|
||||||
private final Integer renewalCycleNo;
|
|
||||||
|
|
||||||
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
|
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
|
||||||
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
||||||
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
||||||
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
||||||
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
|
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
|
||||||
final String fdAccountNumber, final Integer renewalCycleNo) {
|
|
||||||
this.fdAccountNumber = fdAccountNumber;
|
|
||||||
this.renewalCycleNo = renewalCycleNo;
|
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.accountNo = accountNo;
|
this.accountNo = accountNo;
|
||||||
this.externalId = externalId;
|
this.externalId = externalId;
|
||||||
|
|||||||
-9
@@ -162,15 +162,6 @@ public class ClientApiConstants {
|
|||||||
public static final String ageDaysParamName = "ageDays";
|
public static final String ageDaysParamName = "ageDays";
|
||||||
public static final String nationalityIdParamName = "nationalityId";
|
public static final String nationalityIdParamName = "nationalityId";
|
||||||
public static final String isMinorParamName = "isMinor";
|
public static final String isMinorParamName = "isMinor";
|
||||||
/**
|
|
||||||
* Below this age a client is treated as a minor: guardian mapping required and a restricted KYC/OVD
|
|
||||||
* document set. Must stay in step with MINOR_AGE_THRESHOLD in the web app's client-age util -- the
|
|
||||||
* KYC template endpoint is authoritative for the document list, so a mismatch would serve an adult
|
|
||||||
* list (with mandatory PAN) for a client the UI classifies as a minor.
|
|
||||||
*/
|
|
||||||
public static final int MINOR_AGE_THRESHOLD = 10;
|
|
||||||
/** At or above this age a client qualifies for senior-citizen deposit rates. */
|
|
||||||
public static final int SENIOR_CITIZEN_AGE_THRESHOLD = 60;
|
|
||||||
public static final String guardianClientIdParamName = "guardianClientId";
|
public static final String guardianClientIdParamName = "guardianClientId";
|
||||||
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
|
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
|
||||||
public static final String guardianRelationParamName = "guardianRelation";
|
public static final String guardianRelationParamName = "guardianRelation";
|
||||||
|
|||||||
-39
@@ -484,14 +484,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
actualChanges.put(riskParam, newValue);
|
actualChanges.put(riskParam, newValue);
|
||||||
recordOverwrite(riskParam, this.riskCategory);
|
recordOverwrite(riskParam, this.riskCategory);
|
||||||
this.riskCategory = newValue;
|
this.riskCategory = newValue;
|
||||||
|
|
||||||
// The re-KYC cycle is a property of the risk category, so moving the category moves the
|
|
||||||
// due date with it — otherwise a customer raised to High keeps a Low customer's ten-year
|
|
||||||
// renewal and is never called back in for the two-yearly review the rating requires.
|
|
||||||
final LocalDate newExpiry = recalculateExpiryForRiskCategory();
|
|
||||||
if (newExpiry != null) {
|
|
||||||
actualChanges.put("expiryDate", newExpiry);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// remarks is the audit justification itself, so a change to it is deliberately not recorded
|
// remarks is the audit justification itself, so a change to it is deliberately not recorded
|
||||||
@@ -685,34 +677,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-dates the re-KYC due date after the risk category moves.
|
|
||||||
*
|
|
||||||
* The cycle length belongs to the category the customer is in <em>now</em> (Low 10, Medium 8,
|
|
||||||
* High 2), and it is counted from the day of the change rather than from the original
|
|
||||||
* verification. A customer moved to High in year two of a Low rating is then due in two years'
|
|
||||||
* time, not immediately — dating it from {@code verifiedOn} would push most of the book into
|
|
||||||
* an overdue queue the moment a re-rating ran.
|
|
||||||
*
|
|
||||||
* <p>Only a verified KYC carries an expiry: an unverified record has nothing to renew, and
|
|
||||||
* {@link #markVerified} sets the date when it is first verified.
|
|
||||||
*
|
|
||||||
* @return the new expiry date, or {@code null} if the record carries no expiry
|
|
||||||
*/
|
|
||||||
private LocalDate recalculateExpiryForRiskCategory() {
|
|
||||||
if (this.verifiedOn == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
|
|
||||||
if (risk.getReKycYears() <= 0) {
|
|
||||||
return this.expiryDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.expiryDate = DateUtils.getLocalDateOfTenant().plusYears(risk.getReKycYears());
|
|
||||||
return this.expiryDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called when verification fails. */
|
/** Called when verification fails. */
|
||||||
public void markRejected(final String remarks) {
|
public void markRejected(final String remarks) {
|
||||||
this.kycStatus = ClientKycStatus.REJECTED.getValue();
|
this.kycStatus = ClientKycStatus.REJECTED.getValue();
|
||||||
@@ -834,9 +798,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
*/
|
*/
|
||||||
public void escalateRiskCategory(Integer newCategory) {
|
public void escalateRiskCategory(Integer newCategory) {
|
||||||
this.riskCategory = newCategory;
|
this.riskCategory = newCategory;
|
||||||
// An auto-escalation moves the rating for the same reason an officer would, so it moves the
|
|
||||||
// re-KYC due date on the same terms — see recalculateExpiryForRiskCategory().
|
|
||||||
recalculateExpiryForRiskCategory();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getPanNumber() {
|
public String getPanNumber() {
|
||||||
|
|||||||
-13
@@ -18,7 +18,6 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.client.domain;
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@@ -49,18 +48,6 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
|
|||||||
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
||||||
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
|
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
|
||||||
|
|
||||||
/**
|
|
||||||
* KYC records whose periodic re-KYC due date has passed, oldest first.
|
|
||||||
*
|
|
||||||
* <p>Only records that carry an expiry are considered: an expiry is set when the KYC is first
|
|
||||||
* verified, so one without a date has never been verified and has no renewal cycle to lapse.
|
|
||||||
* A rejected record is excluded for the same reason — it is not a lapsed KYC, it is one that
|
|
||||||
* never took effect, and restricting an account over it would punish the wrong thing.
|
|
||||||
*/
|
|
||||||
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate <= :asOn "
|
|
||||||
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
|
|
||||||
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
|
|
||||||
|
|
||||||
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
||||||
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
||||||
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
||||||
|
|||||||
-106
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.client.domain;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far an account has travelled down the restriction ladder after its periodic re-KYC lapsed.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* RBI's Master Direction on KYC does not treat an overdue re-KYC as grounds to shut a customer out.
|
|
||||||
* The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not to be
|
|
||||||
* closed merely because periodic KYC is pending, and that any restriction follows notice and a fair
|
|
||||||
* opportunity to comply. The ladder therefore leaves the account untouched for a grace period,
|
|
||||||
* stops debits before it stops anything else, and never reaches closure on its own — closure stays
|
|
||||||
* a manual decision with its own notice.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The day counts are bank policy rather than a regulatory figure — the Direction requires "adequate
|
|
||||||
* notice" without fixing a number — so they are declared here once and read from here everywhere.
|
|
||||||
*/
|
|
||||||
public enum KycExpiryStage {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inside the grace window. Nothing is restricted; the customer is being chased.
|
|
||||||
*/
|
|
||||||
GRACE(0),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debits stopped, credits still accepted. The first actual restriction, and the one that brings
|
|
||||||
* the customer into the branch without putting their incoming money at risk.
|
|
||||||
*/
|
|
||||||
PARTIAL_FREEZE(90),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Both directions restricted, barring the credits that must always be let through.
|
|
||||||
*/
|
|
||||||
INOPERATIVE(180);
|
|
||||||
|
|
||||||
/** Days past the expiry date at which this stage begins. */
|
|
||||||
private final int fromDaysOverdue;
|
|
||||||
|
|
||||||
KycExpiryStage(final int fromDaysOverdue) {
|
|
||||||
this.fromDaysOverdue = fromDaysOverdue;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getFromDaysOverdue() {
|
|
||||||
return fromDaysOverdue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stage an account sits at, given how far past its re-KYC due date it is.
|
|
||||||
*
|
|
||||||
* @param daysOverdue
|
|
||||||
* days since the expiry date; zero or negative means the KYC has not lapsed
|
|
||||||
* @return the stage, or {@code null} where the KYC is still current and nothing applies
|
|
||||||
*/
|
|
||||||
public static KycExpiryStage fromDaysOverdue(final long daysOverdue) {
|
|
||||||
if (daysOverdue < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (daysOverdue >= INOPERATIVE.fromDaysOverdue) {
|
|
||||||
return INOPERATIVE;
|
|
||||||
}
|
|
||||||
if (daysOverdue >= PARTIAL_FREEZE.fromDaysOverdue) {
|
|
||||||
return PARTIAL_FREEZE;
|
|
||||||
}
|
|
||||||
return GRACE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isGrace() {
|
|
||||||
return this == GRACE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isPartialFreeze() {
|
|
||||||
return this == PARTIAL_FREEZE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isInoperative() {
|
|
||||||
return this == INOPERATIVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether debits should be stopped at this stage. */
|
|
||||||
public boolean blocksDebits() {
|
|
||||||
return this == PARTIAL_FREEZE || this == INOPERATIVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether ordinary credits should be stopped at this stage. */
|
|
||||||
public boolean blocksCredits() {
|
|
||||||
return this == INOPERATIVE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.batch.data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public final class BatchRunRowData implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final Long partyId;
|
||||||
|
private final String partyCode;
|
||||||
|
private final String partyName;
|
||||||
|
private final Long agentId;
|
||||||
|
private final String agentCode;
|
||||||
|
private final BigDecimal amount;
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* 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
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 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
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.batch.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when Post is attempted on a batch run that has already been posted - a run applies once,
|
||||||
|
* matching the calculate-then-post safety control (never re-post the same preview).
|
||||||
|
*/
|
||||||
|
public class BatchRunAlreadyPostedException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public BatchRunAlreadyPostedException(final Long batchRunId) {
|
||||||
|
super("error.msg.ddsbatchrun.already.posted", "DDS batch run " + batchRunId + " has already been posted", batchRunId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -16,13 +16,13 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.useradministration.exception;
|
package org.apache.fineract.portfolio.dds.batch.exception;
|
||||||
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
|
||||||
public class UserPendingRequestNotFoundException extends AbstractPlatformResourceNotFoundException {
|
public class BatchRunNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
public UserPendingRequestNotFoundException(final Long id) {
|
public BatchRunNotFoundException(final Long id) {
|
||||||
super("error.msg.userpendingrequest.id.invalid", "User pending request with identifier " + id + " does not exist", id);
|
super("error.msg.ddsbatchrun.id.invalid", "DDS batch run with identifier " + id + " does not exist", id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* 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
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.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
@@ -0,0 +1,569 @@
|
|||||||
|
/**
|
||||||
|
* 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
@@ -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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-5
@@ -16,13 +16,17 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.savings.exception;
|
package org.apache.fineract.portfolio.dds.party.exception;
|
||||||
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
public class FacilityChargeMappingNotFoundException extends AbstractPlatformResourceNotFoundException {
|
/**
|
||||||
|
* Thrown when a Close Party command, or a Party Tran collection, targets a Party that is already
|
||||||
|
* closed.
|
||||||
|
*/
|
||||||
|
public class PartyAlreadyClosedException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
public FacilityChargeMappingNotFoundException(final Long id) {
|
public PartyAlreadyClosedException(final String partyCode) {
|
||||||
super("error.msg.facilitychargemapping.id.invalid", "Facility charge mapping with identifier " + id + " does not exist", id);
|
super("error.msg.party.already.closed", "Party " + partyCode + " is already closed", partyCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.party.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a Close Party command is attempted while the Party's balance is still non-zero - the
|
||||||
|
* remaining balance must be paid out (a Withdraw Deposite to zero) before the account can be closed.
|
||||||
|
*/
|
||||||
|
public class PartyHasBalanceException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public PartyHasBalanceException(final String partyCode) {
|
||||||
|
super("error.msg.party.has.balance", "Party " + partyCode + " still has a non-zero balance and cannot be closed", partyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.party.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RuntimeException} thrown when a DDS party resource is not found.
|
||||||
|
*/
|
||||||
|
public class PartyNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
|
public PartyNotFoundException(final Long id) {
|
||||||
|
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartyNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||||
|
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartyNotFoundException(final String partyCode) {
|
||||||
|
super("error.msg.party.code.invalid", "Party with code " + partyCode + " does not exist", partyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.party.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the savingsAccountNo given for a Party belongs to a different client than the Party's
|
||||||
|
* own clientId - a Party can only be linked to its own client's savings account.
|
||||||
|
*/
|
||||||
|
public class PartySavingsAccountClientMismatchException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public PartySavingsAccountClientMismatchException(final String savingsAccountNo) {
|
||||||
|
super("error.msg.party.savings.account.client.mismatch",
|
||||||
|
"Savings account " + savingsAccountNo + " does not belong to this party's client", savingsAccountNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-14
@@ -16,21 +16,17 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.useradministration.domain;
|
package org.apache.fineract.portfolio.dds.party.exception;
|
||||||
|
|
||||||
public enum UserPendingRequestType {
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
CREATE_USER,
|
|
||||||
UPDATE_USER;
|
|
||||||
|
|
||||||
public static UserPendingRequestType fromString(final String type) {
|
/**
|
||||||
if (type == null) {
|
* Thrown when a Party's savingsAccountNo does not match any non-closed Savings Account.
|
||||||
return null;
|
*/
|
||||||
}
|
public class PartySavingsAccountNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
for (final UserPendingRequestType requestType : values()) {
|
|
||||||
if (requestType.name().equalsIgnoreCase(type)) {
|
public PartySavingsAccountNotFoundException(final String savingsAccountNo) {
|
||||||
return requestType;
|
super("error.msg.party.savings.account.not.found", "No open savings account found with account number " + savingsAccountNo,
|
||||||
}
|
savingsAccountNo);
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("Unknown UserPendingRequestType: " + type);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.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
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.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
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 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
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.dds.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-15
@@ -16,22 +16,17 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.useradministration.domain;
|
package org.apache.fineract.portfolio.dds.transaction.exception;
|
||||||
|
|
||||||
public enum UserPendingRequestStatus {
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
PENDING,
|
|
||||||
APPROVED,
|
|
||||||
REJECTED;
|
|
||||||
|
|
||||||
public static UserPendingRequestStatus fromString(final String status) {
|
/**
|
||||||
if (status == null) {
|
* Thrown when a Withdraw Deposite transfer would take the debtor Party's balance below zero.
|
||||||
return null;
|
*/
|
||||||
}
|
public class InsufficientPartyBalanceException extends AbstractPlatformDomainRuleException {
|
||||||
for (final UserPendingRequestStatus requestStatus : values()) {
|
|
||||||
if (requestStatus.name().equalsIgnoreCase(status)) {
|
public InsufficientPartyBalanceException(final String partyCode) {
|
||||||
return requestStatus;
|
super("error.msg.party.insufficient.balance", "Party " + partyCode + " does not have sufficient balance for this transfer",
|
||||||
}
|
partyCode);
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("Unknown UserPendingRequestStatus: " + status);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-13
@@ -16,22 +16,17 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.savings.service;
|
package org.apache.fineract.portfolio.dds.transaction.exception;
|
||||||
|
|
||||||
import java.util.List;
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
||||||
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the accounts the dormancy job has marked inactive, dormant or escheated.
|
* A {@link RuntimeException} thrown when a DDS party transaction resource is not found.
|
||||||
*/
|
*/
|
||||||
public interface DormantAccountReadPlatformService {
|
public class PartyTransactionNotFoundException extends AbstractPlatformResourceNotFoundException {
|
||||||
|
|
||||||
/**
|
public PartyTransactionNotFoundException(final Long id, final EmptyResultDataAccessException e) {
|
||||||
* @param subStatus
|
super("error.msg.partytransaction.id.invalid", "Party transaction with identifier " + id + " does not exist", id, e);
|
||||||
* 100 inactive, 200 dormant, 300 escheat; null for all three
|
}
|
||||||
* @param officeName
|
|
||||||
* restricts to one branch; null or blank for every branch
|
|
||||||
* @return the matching accounts, longest idle first
|
|
||||||
*/
|
|
||||||
List<DormantAccountData> retrieveDormantAccounts(Integer subStatus, String officeName);
|
|
||||||
}
|
}
|
||||||
+1
-7
@@ -31,8 +31,7 @@ public enum DepositAccountOnClosureType {
|
|||||||
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
|
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
|
||||||
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
|
||||||
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
|
||||||
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
|
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
|
||||||
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
|
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
private final String code;
|
private final String code;
|
||||||
@@ -52,7 +51,6 @@ public enum DepositAccountOnClosureType {
|
|||||||
case 200 -> TRANSFER_TO_SAVINGS;
|
case 200 -> TRANSFER_TO_SAVINGS;
|
||||||
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
|
||||||
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
case 400 -> REINVEST_PRINCIPAL_ONLY;
|
||||||
case 500 -> PARTIAL_REINVEST;
|
|
||||||
default -> INVALID;
|
default -> INVALID;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -87,10 +85,6 @@ public enum DepositAccountOnClosureType {
|
|||||||
return this.equals(INVALID);
|
return this.equals(INVALID);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isPartialReinvest() {
|
|
||||||
return this.equals(PARTIAL_REINVEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: do we really need this?!?
|
// TODO: do we really need this?!?
|
||||||
public static Object[] integerValues() {
|
public static Object[] integerValues() {
|
||||||
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
|
||||||
|
|||||||
-5
@@ -208,7 +208,6 @@ public final class DepositsApiConstants {
|
|||||||
public static final String onAccountClosureIdParamName = "onAccountClosureId";
|
public static final String onAccountClosureIdParamName = "onAccountClosureId";
|
||||||
public static final String transferDescriptionParamName = "transferDescription";
|
public static final String transferDescriptionParamName = "transferDescription";
|
||||||
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
|
||||||
public static final String reinvestAmountParamName = "reinvestAmount";
|
|
||||||
public static final String savingsAccounts = "savingsAccounts";
|
public static final String savingsAccounts = "savingsAccounts";
|
||||||
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
public static final String maturityInstructionIdParamName = "maturityInstructionId";
|
||||||
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
public static final String transferToSavingsIdParamName = "transferToSavingsId";
|
||||||
@@ -589,10 +588,6 @@ public final class DepositsApiConstants {
|
|||||||
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
|
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
|
||||||
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
|
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
|
||||||
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
|
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
|
||||||
// FD Special's engine-computed monthly interest schedule (see FixedDepositAccountsApiResource#retrieveOne)
|
|
||||||
fixedDepositResponseData.add("projectedInterestSchedule");
|
|
||||||
fixedDepositResponseData.add("totalProjectedInterest");
|
|
||||||
fixedDepositResponseData.add("totalValueAtMaturity");
|
|
||||||
return fixedDepositResponseData;
|
return fixedDepositResponseData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -92,9 +92,6 @@ public class SavingsApiConstants {
|
|||||||
public static final String statusParamName = "status";
|
public static final String statusParamName = "status";
|
||||||
public static final String subStatusParamName = "subStatus";
|
public static final String subStatusParamName = "subStatus";
|
||||||
public static final String reasonForBlockParamName = "reasonForBlock";
|
public static final String reasonForBlockParamName = "reasonForBlock";
|
||||||
/** "CASH" (default, a manual/staff-placed lien) or "CLEARING" (money still moving through the
|
|
||||||
* clearing process — a not-yet-realized cheque/DD deposit, or a presented cheque not yet settled). */
|
|
||||||
public static final String holdCategoryParamName = "holdCategory";
|
|
||||||
public static final String clientIdParamName = "clientId";
|
public static final String clientIdParamName = "clientId";
|
||||||
public static final String isRetailAccountParamName = "isRetailAccount";
|
public static final String isRetailAccountParamName = "isRetailAccount";
|
||||||
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
|
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
|
||||||
@@ -229,7 +226,6 @@ public class SavingsApiConstants {
|
|||||||
public static final String endDateParamName = "endDate";
|
public static final String endDateParamName = "endDate";
|
||||||
public static final String introducerRequiredParamName = "introducerRequired";
|
public static final String introducerRequiredParamName = "introducerRequired";
|
||||||
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
|
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
|
||||||
public static final String shareAccountIdParamName = "shareAccountId";
|
|
||||||
public static final String nominationFacilityParamName = "nominationFacility";
|
public static final String nominationFacilityParamName = "nominationFacility";
|
||||||
public static final String dormancyAlertParamName = "dormancyAlert";
|
public static final String dormancyAlertParamName = "dormancyAlert";
|
||||||
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
|
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
|
||||||
@@ -258,6 +254,7 @@ public class SavingsApiConstants {
|
|||||||
public static final String neftFacilityParamName = "neftFacility";
|
public static final String neftFacilityParamName = "neftFacility";
|
||||||
public static final String rtgsFacilityParamName = "rtgsFacility";
|
public static final String rtgsFacilityParamName = "rtgsFacility";
|
||||||
public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility";
|
public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility";
|
||||||
|
public static final String withdrawalFacilityParamName = "withdrawalFacility";
|
||||||
public static final String depositFacilityKycParamName = "depositFacilityKyc";
|
public static final String depositFacilityKycParamName = "depositFacilityKyc";
|
||||||
public static final String virtualDebitCardParamName = "virtualDebitCard";
|
public static final String virtualDebitCardParamName = "virtualDebitCard";
|
||||||
|
|
||||||
@@ -278,6 +275,7 @@ 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,8 +157,6 @@ public final class SavingsAccountData implements Serializable {
|
|||||||
private LocalDate endDate;
|
private LocalDate endDate;
|
||||||
private Boolean introducerRequired;
|
private Boolean introducerRequired;
|
||||||
private Boolean shareLinkingEnabled;
|
private Boolean shareLinkingEnabled;
|
||||||
private Long shareAccountId;
|
|
||||||
private String shareAccountNo;
|
|
||||||
private Boolean nominationFacility;
|
private Boolean nominationFacility;
|
||||||
private Boolean dormancyAlert;
|
private Boolean dormancyAlert;
|
||||||
private Boolean isDormancyTrackingActiveFromProduct;
|
private Boolean isDormancyTrackingActiveFromProduct;
|
||||||
@@ -219,21 +217,6 @@ public final class SavingsAccountData implements Serializable {
|
|||||||
private BigDecimal interestPosting;
|
private BigDecimal interestPosting;
|
||||||
private BigDecimal overdraftPosting;
|
private BigDecimal overdraftPosting;
|
||||||
|
|
||||||
/**
|
|
||||||
* True when this deposit pays its interest out to a linked savings account every period (FD Special).
|
|
||||||
* Such interest never stays in the account, so it must never compound -- every period earns on the
|
|
||||||
* principal alone. Transient because it is only read by the batch interest-posting engine.
|
|
||||||
*/
|
|
||||||
private transient boolean transferInterestToLinkedAccount;
|
|
||||||
|
|
||||||
public void setTransferInterestToLinkedAccount(final boolean transferInterestToLinkedAccount) {
|
|
||||||
this.transferInterestToLinkedAccount = transferInterestToLinkedAccount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isTransferInterestToLinkedAccount() {
|
|
||||||
return this.transferInterestToLinkedAccount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
|
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
|
||||||
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
|
||||||
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
|
||||||
|
|||||||
+1
@@ -144,6 +144,7 @@ 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;
|
||||||
|
|||||||
-4
@@ -797,10 +797,6 @@ public final class SavingsEnumerations {
|
|||||||
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
|
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
|
||||||
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
|
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
|
||||||
break;
|
break;
|
||||||
case PARTIAL_REINVEST:
|
|
||||||
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
|
|
||||||
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
return optionData;
|
return optionData;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -77,6 +77,9 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
@Column(name = "inter_bank_total_cap_facility", nullable = false)
|
@Column(name = "inter_bank_total_cap_facility", nullable = false)
|
||||||
private boolean interBankTotalCapFacility = false;
|
private boolean interBankTotalCapFacility = false;
|
||||||
|
|
||||||
|
@Column(name = "withdrawal_facility", nullable = false)
|
||||||
|
private boolean withdrawalFacility = false;
|
||||||
|
|
||||||
@Column(name = "deposit_facility_kyc", nullable = false)
|
@Column(name = "deposit_facility_kyc", nullable = false)
|
||||||
private boolean depositFacilityKyc = false;
|
private boolean depositFacilityKyc = false;
|
||||||
|
|
||||||
@@ -117,6 +120,7 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
|
if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
|
||||||
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
|
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
|
||||||
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean());
|
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean());
|
||||||
|
if (config.has("withdrawalFacility")) setWithdrawalFacility(config.get("withdrawalFacility").getAsBoolean());
|
||||||
if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
|
if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
|
||||||
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
|
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
|
||||||
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
|
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
|
||||||
|
|||||||
+3
@@ -136,6 +136,9 @@ public class CurrentAccountPendingEdit extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "inter_bank_total_cap_facility")
|
@Column(name = "inter_bank_total_cap_facility")
|
||||||
private Boolean interBankTotalCapFacility;
|
private Boolean interBankTotalCapFacility;
|
||||||
|
|
||||||
|
@Column(name = "withdrawal_facility")
|
||||||
|
private Boolean withdrawalFacility;
|
||||||
|
|
||||||
@Column(name = "deposit_facility_kyc")
|
@Column(name = "deposit_facility_kyc")
|
||||||
private Boolean depositFacilityKyc;
|
private Boolean depositFacilityKyc;
|
||||||
|
|
||||||
|
|||||||
+1
@@ -378,6 +378,7 @@ public class CurrentProductAssembler {
|
|||||||
config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false);
|
config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false);
|
||||||
config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
|
config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
|
||||||
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false);
|
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ? facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false);
|
||||||
|
config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ? facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false);
|
||||||
config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
|
config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ? facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
|
||||||
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false);
|
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false);
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
+6
@@ -75,6 +75,9 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
@Column(name = "inter_bank_total_cap_facility")
|
@Column(name = "inter_bank_total_cap_facility")
|
||||||
private Boolean interBankTotalCapFacility = false;
|
private Boolean interBankTotalCapFacility = false;
|
||||||
|
|
||||||
|
@Column(name = "withdrawal_facility")
|
||||||
|
private Boolean withdrawalFacility = false;
|
||||||
|
|
||||||
@Column(name = "deposit_facility_kyc")
|
@Column(name = "deposit_facility_kyc")
|
||||||
private Boolean depositFacilityKyc = false;
|
private Boolean depositFacilityKyc = false;
|
||||||
|
|
||||||
@@ -122,6 +125,8 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
public void setRtgsFacility(Boolean v) { this.rtgsFacility = v != null ? v : false; }
|
public void setRtgsFacility(Boolean v) { this.rtgsFacility = v != null ? v : false; }
|
||||||
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
|
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
|
||||||
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
|
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
|
||||||
|
public Boolean getWithdrawalFacility() { return withdrawalFacility; }
|
||||||
|
public void setWithdrawalFacility(Boolean v) { this.withdrawalFacility = v != null ? v : false; }
|
||||||
public Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
|
public Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
|
||||||
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
|
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
|
||||||
public Boolean getVirtualDebitCard() { return virtualDebitCard; }
|
public Boolean getVirtualDebitCard() { return virtualDebitCard; }
|
||||||
@@ -142,6 +147,7 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
|
|||||||
this.neftFacility = other.getNeftFacility();
|
this.neftFacility = other.getNeftFacility();
|
||||||
this.rtgsFacility = other.getRtgsFacility();
|
this.rtgsFacility = other.getRtgsFacility();
|
||||||
this.interBankTotalCapFacility = other.getInterBankTotalCapFacility();
|
this.interBankTotalCapFacility = other.getInterBankTotalCapFacility();
|
||||||
|
this.withdrawalFacility = other.getWithdrawalFacility();
|
||||||
this.depositFacilityKyc = other.getDepositFacilityKyc();
|
this.depositFacilityKyc = other.getDepositFacilityKyc();
|
||||||
this.virtualDebitCard = other.getVirtualDebitCard();
|
this.virtualDebitCard = other.getVirtualDebitCard();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -117,6 +117,7 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
|
|||||||
facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility());
|
facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility());
|
||||||
facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility());
|
facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility());
|
||||||
facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility());
|
facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility());
|
||||||
|
facilityConfigMap.put("withdrawalFacility", facilityConfig.getWithdrawalFacility());
|
||||||
facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc());
|
facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc());
|
||||||
facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard());
|
facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard());
|
||||||
product.setAccountFacilityConfig(facilityConfigMap);
|
product.setAccountFacilityConfig(facilityConfigMap);
|
||||||
|
|||||||
-185
@@ -1,185 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.accounting.dividendpaidreport.api;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.GsonBuilder;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import jakarta.ws.rs.GET;
|
|
||||||
import jakarta.ws.rs.Path;
|
|
||||||
import jakarta.ws.rs.Produces;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every declared dividend, financial year by financial year: how much was declared, how much members have collected,
|
|
||||||
* how much is still unclaimed, and how much has come back to profit.
|
|
||||||
*
|
|
||||||
* The GL accounts are shared across years, so their balances only ever give running totals — "how much of FY 2026-27 is
|
|
||||||
* still unclaimed" cannot be read off Dividend Payable. This walks the declarations themselves instead: locks carry the
|
|
||||||
* per-member entitlement and their status says what became of it, which is the authoritative record. The GL is where the
|
|
||||||
* money moved; the locks are what it moved for.
|
|
||||||
*
|
|
||||||
* The columns mirror the money's actual path, which begins and ends in the same place:
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* declare Dr Bank Profit Cr Dividend Payable
|
|
||||||
* member claims Dr Dividend Payable Cr Dividend Paid → Cr Sponsor DD Payable
|
|
||||||
* 3 yrs unclaimed Dr Dividend Payable Cr Bank Profit
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* So for a closed year, claimed + forfeited + rounding residue should account for the whole declaration; anything left
|
|
||||||
* is what is still sitting unclaimed and still within its three-year window.
|
|
||||||
*/
|
|
||||||
@Path("/v1/accounting/dividend-yearwise-report")
|
|
||||||
@Component
|
|
||||||
@Tag(name = "Dividend Year-wise Report", description = "Declared, claimed, unclaimed and forfeited dividend per financial year.")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DividendYearwiseReportApiResource {
|
|
||||||
|
|
||||||
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
|
||||||
public String report() {
|
|
||||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
|
||||||
|
|
||||||
// A declaration is labelled by the financial year its period CLOSES in, which is how a member
|
|
||||||
// and an auditor both refer to it: a period ending 31-Mar-2027 is FY 2026-27. Locks are joined
|
|
||||||
// through the per-member detail rather than counted off the declaration, because a lock is only
|
|
||||||
// created for a member who actually had something to collect.
|
|
||||||
// Declared amounts and lock outcomes are aggregated separately before being joined by year.
|
|
||||||
// Summing the declaration amount across a join to its locks would multiply it by the number
|
|
||||||
// of members it was split between.
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
|
||||||
WITH fy AS (
|
|
||||||
SELECT d.id,
|
|
||||||
CASE WHEN EXTRACT(MONTH FROM d.dividend_period_end_date) >= 4
|
|
||||||
THEN EXTRACT(YEAR FROM d.dividend_period_end_date)
|
|
||||||
ELSE EXTRACT(YEAR FROM d.dividend_period_end_date) - 1 END AS fy_start,
|
|
||||||
d.amount
|
|
||||||
FROM m_share_product_dividend_pay_out d
|
|
||||||
),
|
|
||||||
declared AS (
|
|
||||||
SELECT fy_start, COUNT(*) AS declaration_count, SUM(amount) AS declared_amount
|
|
||||||
FROM fy GROUP BY fy_start
|
|
||||||
),
|
|
||||||
outcomes AS (
|
|
||||||
SELECT fy.fy_start,
|
|
||||||
COALESCE(SUM(CASE WHEN l.status = 'CLAIMED' THEN l.amount ELSE 0 END), 0) AS claimed_amount,
|
|
||||||
COUNT(l.id) FILTER (WHERE l.status = 'CLAIMED') AS claimed_count,
|
|
||||||
COALESCE(SUM(CASE WHEN l.status = 'UNCLAIMED' THEN l.amount ELSE 0 END), 0) AS unclaimed_amount,
|
|
||||||
COUNT(l.id) FILTER (WHERE l.status = 'UNCLAIMED') AS unclaimed_count,
|
|
||||||
COALESCE(SUM(CASE WHEN l.status = 'FORFEITED' THEN l.amount ELSE 0 END), 0) AS forfeited_amount,
|
|
||||||
COUNT(l.id) FILTER (WHERE l.status = 'FORFEITED') AS forfeited_count,
|
|
||||||
MIN(l.release_due_date) FILTER (WHERE l.status = 'UNCLAIMED') AS earliest_release_due
|
|
||||||
FROM fy
|
|
||||||
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = fy.id
|
|
||||||
LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id
|
|
||||||
GROUP BY fy.fy_start
|
|
||||||
)
|
|
||||||
SELECT d.fy_start, d.declaration_count, d.declared_amount,
|
|
||||||
COALESCE(o.claimed_amount, 0) AS claimed_amount, COALESCE(o.claimed_count, 0) AS claimed_count,
|
|
||||||
COALESCE(o.unclaimed_amount, 0) AS unclaimed_amount, COALESCE(o.unclaimed_count, 0) AS unclaimed_count,
|
|
||||||
COALESCE(o.forfeited_amount, 0) AS forfeited_amount, COALESCE(o.forfeited_count, 0) AS forfeited_count,
|
|
||||||
o.earliest_release_due
|
|
||||||
FROM declared d
|
|
||||||
LEFT JOIN outcomes o ON o.fy_start = d.fy_start
|
|
||||||
ORDER BY d.fy_start DESC
|
|
||||||
""");
|
|
||||||
|
|
||||||
// Rounding residue is tracked per declaration rather than per lock — it never belonged to a
|
|
||||||
// member, so no lock was ever created for it. Pulled separately and matched by year.
|
|
||||||
final Map<String, BigDecimal> residueByYear = new LinkedHashMap<>();
|
|
||||||
for (final Map<String, Object> row : jdbcTemplate.queryForList("""
|
|
||||||
SELECT CASE WHEN EXTRACT(MONTH FROM period_end_date) >= 4
|
|
||||||
THEN EXTRACT(YEAR FROM period_end_date)
|
|
||||||
ELSE EXTRACT(YEAR FROM period_end_date) - 1 END AS fy_start,
|
|
||||||
COALESCE(SUM(residue_amount), 0) AS residue
|
|
||||||
FROM m_share_dividend_rounding_residue
|
|
||||||
GROUP BY fy_start
|
|
||||||
""")) {
|
|
||||||
residueByYear.put(financialYearLabel(((Number) row.get("fy_start")).intValue()), (BigDecimal) row.get("residue"));
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<Map<String, Object>> years = new ArrayList<>();
|
|
||||||
BigDecimal totalDeclared = BigDecimal.ZERO;
|
|
||||||
BigDecimal totalClaimed = BigDecimal.ZERO;
|
|
||||||
BigDecimal totalUnclaimed = BigDecimal.ZERO;
|
|
||||||
BigDecimal totalForfeited = BigDecimal.ZERO;
|
|
||||||
|
|
||||||
for (final Map<String, Object> row : rows) {
|
|
||||||
final String financialYear = financialYearLabel(((Number) row.get("fy_start")).intValue());
|
|
||||||
final BigDecimal declared = (BigDecimal) row.get("declared_amount");
|
|
||||||
final BigDecimal claimed = (BigDecimal) row.get("claimed_amount");
|
|
||||||
final BigDecimal unclaimed = (BigDecimal) row.get("unclaimed_amount");
|
|
||||||
final BigDecimal forfeited = (BigDecimal) row.get("forfeited_amount");
|
|
||||||
final BigDecimal residue = residueByYear.getOrDefault(financialYear, BigDecimal.ZERO);
|
|
||||||
|
|
||||||
final Map<String, Object> entry = new LinkedHashMap<>();
|
|
||||||
entry.put("financialYear", financialYear);
|
|
||||||
entry.put("declarationCount", row.get("declaration_count"));
|
|
||||||
entry.put("declaredAmount", declared);
|
|
||||||
entry.put("claimedAmount", claimed);
|
|
||||||
entry.put("claimedCount", row.get("claimed_count"));
|
|
||||||
entry.put("unclaimedAmount", unclaimed);
|
|
||||||
entry.put("unclaimedCount", row.get("unclaimed_count"));
|
|
||||||
entry.put("forfeitedAmount", forfeited);
|
|
||||||
entry.put("forfeitedCount", row.get("forfeited_count"));
|
|
||||||
entry.put("roundingResidueAmount", residue);
|
|
||||||
// What has already gone back to profit for this year: forfeited entitlements plus the
|
|
||||||
// rounding residue swept at declaration time.
|
|
||||||
entry.put("returnedToProfitAmount", forfeited.add(residue));
|
|
||||||
// When this year's oldest unclaimed lock forfeits — the date the remaining unclaimed
|
|
||||||
// amount starts returning to profit.
|
|
||||||
entry.put("earliestReleaseDueDate",
|
|
||||||
row.get("earliest_release_due") == null ? null : String.valueOf(row.get("earliest_release_due")));
|
|
||||||
years.add(entry);
|
|
||||||
|
|
||||||
totalDeclared = totalDeclared.add(declared);
|
|
||||||
totalClaimed = totalClaimed.add(claimed);
|
|
||||||
totalUnclaimed = totalUnclaimed.add(unclaimed);
|
|
||||||
totalForfeited = totalForfeited.add(forfeited);
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object> response = new LinkedHashMap<>();
|
|
||||||
response.put("years", years);
|
|
||||||
response.put("totalDeclared", totalDeclared);
|
|
||||||
response.put("totalClaimed", totalClaimed);
|
|
||||||
response.put("totalUnclaimed", totalUnclaimed);
|
|
||||||
response.put("totalForfeited", totalForfeited);
|
|
||||||
return gson.toJson(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String financialYearLabel(final int startYear) {
|
|
||||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-5
@@ -1263,11 +1263,6 @@ 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;
|
||||||
|
|||||||
+1
-64
@@ -24,7 +24,6 @@ import java.util.ArrayList;
|
|||||||
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;
|
||||||
@@ -105,8 +104,6 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepositor
|
|||||||
import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations;
|
import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.dao.NonTransientDataAccessException;
|
import org.springframework.dao.NonTransientDataAccessException;
|
||||||
@@ -139,7 +136,6 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
private final ExternalAssetOwnerRepository externalAssetOwnerRepository;
|
||||||
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
private final LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository;
|
||||||
private final LoanTransactionRepository loanTransactionRepository;
|
private final LoanTransactionRepository loanTransactionRepository;
|
||||||
private final VoucherService voucherService;
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -236,73 +232,14 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raise the voucher from the entries that were actually written, rather than from the
|
|
||||||
// command: an accounting rule can supply a side the request never named, so reading
|
|
||||||
// back by transaction id is the only view that matches what the ledger now holds.
|
|
||||||
final String voucherNo = raiseJournalVoucher(transactionId);
|
|
||||||
if (voucherNo != null) {
|
|
||||||
changes.put("voucherNo", voucherNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withOfficeId(officeId)
|
||||||
.withTransactionId(transactionId).with(changes).build();
|
.withTransactionId(transactionId).build();
|
||||||
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
|
||||||
final Throwable throwable = dve.getMostSpecificCause();
|
final Throwable throwable = dve.getMostSpecificCause();
|
||||||
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
|
throw handleJournalEntryDataIntegrityIssues(throwable, dve);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a manual journal in the voucher register and returns its number.
|
|
||||||
*
|
|
||||||
* The sides are read back from the entries just written rather than taken from the request,
|
|
||||||
* because an accounting rule can contribute a debit or credit the caller never named; only the
|
|
||||||
* persisted rows show what the ledger actually holds.
|
|
||||||
*
|
|
||||||
* A failure here does not fail the journal. The entries are already posted and correct, and
|
|
||||||
* refusing them because the register could not be written would reject a sound accounting
|
|
||||||
* transaction over its paperwork. The problem is logged and the journal stands without a
|
|
||||||
* voucher, which reads as a gap in the register rather than as a missing ledger entry.
|
|
||||||
*/
|
|
||||||
private String raiseJournalVoucher(final String transactionId) {
|
|
||||||
// A register that posts its ledger movement through this command raises its own voucher,
|
|
||||||
// naming the member and the instrument. Raising a journal one beside it would put the same
|
|
||||||
// money in the register twice.
|
|
||||||
if (!this.voucherService.shouldRaiseVoucher()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final List<JournalEntry> entries = this.glJournalEntryRepository
|
|
||||||
.findUnReversedManualJournalEntriesByTransactionId(transactionId);
|
|
||||||
if (CollectionUtils.isEmpty(entries)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<VoucherLine> lines = new ArrayList<>();
|
|
||||||
for (final JournalEntry entry : entries) {
|
|
||||||
final GLAccount account = entry.getGlAccount();
|
|
||||||
final Long accountId = account == null ? null : account.getId();
|
|
||||||
final String name = account == null ? null : account.getName();
|
|
||||||
final String code = account == null ? null : account.getGlCode();
|
|
||||||
|
|
||||||
final VoucherLine line = entry.isDebitEntry() ? VoucherLine.debitGl(accountId, name, code, entry.getAmount())
|
|
||||||
: VoucherLine.creditGl(accountId, name, code, entry.getAmount());
|
|
||||||
lines.add(entry.getDescription() == null ? line : line.withDescription(entry.getDescription()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debits first, so the voucher prints the way a slip is read.
|
|
||||||
lines.sort(Comparator.comparing(VoucherLine::isDebit).reversed());
|
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
|
||||||
return this.voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, VoucherService.REF_JOURNAL_ENTRY, null,
|
|
||||||
transactionId, currentUser, VoucherService.PREFIX_JOURNAL, lines);
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
log.error("Could not raise a voucher for journal transaction {}", transactionId, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
|
private void validateDebitOrCreditArrayForExistingGLAccount(final GLAccount glaccount,
|
||||||
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
|
final SingleDebitOrCreditEntryCommand[] creditOrDebits) {
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
-4
@@ -52,7 +52,6 @@ import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
|
|||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanAmortizationAllocationMappingRepository;
|
||||||
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
|
||||||
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -99,13 +98,12 @@ public class AccountingJournalEntryConfiguration {
|
|||||||
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
ConfigurationReadPlatformService configurationReadPlatformService, AccountingService accountingService,
|
||||||
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
ExternalAssetOwnerRepository externalAssetOwnerRepository,
|
||||||
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
LoanAmortizationAllocationMappingRepository loanAmortizationAllocationMappingRepository,
|
||||||
LoanTransactionRepository loanTransactionRepository, VoucherService voucherService) {
|
LoanTransactionRepository loanTransactionRepository) {
|
||||||
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
return new JournalEntryWritePlatformServiceJpaRepositoryImpl(glClosureRepository, glAccountRepository, glJournalEntryRepository,
|
||||||
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
officeRepositoryWrapper, accountingProcessorForLoanFactory, accountingProcessorForSavingsFactory,
|
||||||
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
accountingProcessorForSharesFactory, helper, fromApiJsonDeserializer, accountingRuleRepository,
|
||||||
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
glAccountReadPlatformService, organisationCurrencyRepository, context, paymentDetailWritePlatformService,
|
||||||
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
|
financialActivityAccountRepositoryWrapper, accountingProcessorForClientTransactions, configurationReadPlatformService,
|
||||||
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository,
|
accountingService, externalAssetOwnerRepository, loanAmortizationAllocationMappingRepository, loanTransactionRepository);
|
||||||
voucherService);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-264
@@ -1,264 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.accounting.profitandloss.api;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.GsonBuilder;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import jakarta.ws.rs.GET;
|
|
||||||
import jakarta.ws.rs.Path;
|
|
||||||
import jakarta.ws.rs.Produces;
|
|
||||||
import jakarta.ws.rs.QueryParam;
|
|
||||||
import jakarta.ws.rs.WebApplicationException;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import jakarta.ws.rs.core.Response;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
import java.sql.Date;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.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; kept in step with
|
|
||||||
* IncomeProfitTransferService.PROFIT_GL_CODE.
|
|
||||||
*
|
|
||||||
* Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would
|
|
||||||
* offer profit already distributed once for distribution again.
|
|
||||||
*/
|
|
||||||
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
|
||||||
|
|
||||||
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
|
|
||||||
JOIN acc_gl_account a ON a.id = je.account_id
|
|
||||||
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
|
|
||||||
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
|
|
||||||
return balance == null ? BigDecimal.ZERO : balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "2026-27" -> 1 April 2026 to 31 March 2027. */
|
|
||||||
private LocalDate[] financialYearRange(final String financialYear) {
|
|
||||||
try {
|
|
||||||
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
|
|
||||||
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
throw apiError(400, "financialYear must look like 2026-27. Received: " + financialYear);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The Indian financial year a date falls in, as a "YYYY-YY" label. */
|
|
||||||
private String financialYearOf(final LocalDate date) {
|
|
||||||
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
|
|
||||||
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Same income-less-expense computation as {@link ProfitAndLossApiResource}, over posted, non-reversed journal
|
|
||||||
* entries only. Used only for the interim case — a period inside a year that has not been closed yet.
|
|
||||||
*/
|
|
||||||
private BigDecimal netProfitForPeriod(final Date from, final Date to) {
|
|
||||||
final Map<String, Object> row = jdbcTemplate.queryForMap("""
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
|
|
||||||
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
|
|
||||||
ELSE 0 END), 0) AS total_income,
|
|
||||||
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
|
|
||||||
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
|
|
||||||
ELSE 0 END), 0) AS total_expense
|
|
||||||
FROM acc_gl_journal_entry je
|
|
||||||
JOIN acc_gl_account a ON a.id = je.account_id
|
|
||||||
WHERE je.reversed = false
|
|
||||||
AND je.entry_date BETWEEN ? AND ?
|
|
||||||
""", from, to);
|
|
||||||
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
|
|
||||||
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
|
|
||||||
return totalIncome.subtract(totalExpense);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every dividend already declared against a period overlapping this one, across ALL share products — the profit is
|
|
||||||
* society-wide, so a second product's declaration competes for the same balance as the first.
|
|
||||||
*
|
|
||||||
* excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping
|
|
||||||
* declaration is separately blocked outright.
|
|
||||||
*/
|
|
||||||
private List<Map<String, Object>> declarationsOverlapping(final Date from, final Date to, final Long excludeProductId) {
|
|
||||||
final StringBuilder sql = new StringBuilder("""
|
|
||||||
SELECT d.id AS declaration_id, d.product_id, p.name AS product_name, d.amount,
|
|
||||||
d.dividend_period_start_date, d.dividend_period_end_date, d.status
|
|
||||||
FROM m_share_product_dividend_pay_out d
|
|
||||||
JOIN m_share_product p ON p.id = d.product_id
|
|
||||||
WHERE d.dividend_period_start_date <= ? AND d.dividend_period_end_date >= ?
|
|
||||||
""");
|
|
||||||
final List<Object> params = new ArrayList<>(List.of(to, from));
|
|
||||||
if (excludeProductId != null) {
|
|
||||||
sql.append(" AND d.product_id <> ? ");
|
|
||||||
params.add(excludeProductId);
|
|
||||||
}
|
|
||||||
sql.append(" ORDER BY d.dividend_period_start_date, d.id");
|
|
||||||
|
|
||||||
final List<Map<String, Object>> declarations = new ArrayList<>();
|
|
||||||
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
|
|
||||||
final Map<String, Object> declaration = new LinkedHashMap<>();
|
|
||||||
declaration.put("declarationId", row.get("declaration_id"));
|
|
||||||
declaration.put("productId", row.get("product_id"));
|
|
||||||
declaration.put("productName", row.get("product_name"));
|
|
||||||
declaration.put("amount", row.get("amount"));
|
|
||||||
declaration.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
|
|
||||||
declaration.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date")));
|
|
||||||
declaration.put("status", row.get("status"));
|
|
||||||
declarations.add(declaration);
|
|
||||||
}
|
|
||||||
return declarations;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A missing or disabled configuration row means the society does not make that appropriation (or has no ceiling),
|
|
||||||
* which is 0 — not a failure. Only an enabled row with a value applies.
|
|
||||||
*/
|
|
||||||
private BigDecimal configuredPercentage(final String configurationName) {
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate
|
|
||||||
.queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
|
|
||||||
if (rows.isEmpty() || rows.get(0).get("value") == null) {
|
|
||||||
return BigDecimal.ZERO;
|
|
||||||
}
|
|
||||||
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
|
||||||
return new WebApplicationException(
|
|
||||||
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-180
@@ -1,180 +0,0 @@
|
|||||||
package org.apache.fineract.accounting.yearend.api;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.google.gson.JsonParser;
|
|
||||||
import jakarta.ws.rs.Consumes;
|
|
||||||
import jakarta.ws.rs.GET;
|
|
||||||
import jakarta.ws.rs.POST;
|
|
||||||
import jakarta.ws.rs.Path;
|
|
||||||
import jakarta.ws.rs.Produces;
|
|
||||||
import jakarta.ws.rs.QueryParam;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import jakarta.ws.rs.core.Response;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Financial year-end closing of the share entry fee into the Bank Profit equity GL.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* {@code GET} reports how much is waiting to be posted for the year and what has been posted before; {@code POST}
|
|
||||||
* performs the closing. The scheduled job {@code Post Share Entry Fee To Profit} runs the same service on 31 March, so
|
|
||||||
* this endpoint is the manual equivalent — useful when the job did not run, or the accountant wants to close early.
|
|
||||||
*/
|
|
||||||
@Path("/v1/accounting/incomeprofittransfer")
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
public class IncomeProfitTransferApiResource {
|
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final IncomeProfitTransferService service;
|
|
||||||
private final Gson gson = new Gson();
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// GET — how much is waiting to be posted, and what has been posted before
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
@GET
|
|
||||||
public Response preview(@QueryParam("incomeGlCode") final String incomeGlCodeParam,
|
|
||||||
@QueryParam("financialYear") final String financialYearParam) {
|
|
||||||
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
|
|
||||||
final String incomeGlCode = incomeGlCodeParam == null || incomeGlCodeParam.isBlank()
|
|
||||||
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
|
||||||
: incomeGlCodeParam.trim();
|
|
||||||
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
|
|
||||||
final Map<String, Object> profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE);
|
|
||||||
|
|
||||||
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
|
|
||||||
: financialYearParam.trim();
|
|
||||||
final LocalDate[] range = service.financialYearRange(financialYear);
|
|
||||||
|
|
||||||
final Long incomeGlId = toLong(incomeGl.get("id"));
|
|
||||||
final BigDecimal pending = service.balanceOf(incomeGlId, range);
|
|
||||||
final int entryCount = service.entryCountOf(incomeGlId, range);
|
|
||||||
|
|
||||||
final List<Map<String, Object>> history = jdbcTemplate
|
|
||||||
.queryForList("SELECT t.id, t.financial_year, t.amount, t.entry_count, t.posted_on_date, t.posted_by_name, "
|
|
||||||
+ " t.gl_transaction_id, g.gl_code AS income_gl_code, g.name AS income_gl_name "
|
|
||||||
+ " FROM m_income_profit_transfer t JOIN acc_gl_account g ON g.id = t.income_gl_id "
|
|
||||||
+ " WHERE t.income_gl_id = ? ORDER BY t.financial_year DESC, t.id DESC", incomeGlId);
|
|
||||||
|
|
||||||
final boolean alreadyPosted = history.stream().anyMatch(h -> financialYear.equals(h.get("financial_year")));
|
|
||||||
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("financialYear", financialYear);
|
|
||||||
out.put("fromDate", range[0].toString());
|
|
||||||
out.put("toDate", range[1].toString());
|
|
||||||
out.put("incomeGlCode", incomeGl.get("gl_code"));
|
|
||||||
out.put("incomeGlName", incomeGl.get("name"));
|
|
||||||
out.put("profitGlCode", profitGl.get("gl_code"));
|
|
||||||
out.put("profitGlName", profitGl.get("name"));
|
|
||||||
out.put("pendingAmount", pending);
|
|
||||||
out.put("entryCount", entryCount);
|
|
||||||
out.put("alreadyPosted", alreadyPosted);
|
|
||||||
out.put("postedHistory", history);
|
|
||||||
out.put("totalPostedToProfit", history.stream().map(h -> (BigDecimal) h.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
|
|
||||||
return Response.ok(gson.toJson(out)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// POST — close the income GL into Bank Profit
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
@POST
|
|
||||||
@Transactional
|
|
||||||
public Response post(final String body) {
|
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
|
||||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
|
||||||
|
|
||||||
final JsonObject json = parseBody(body);
|
|
||||||
final String incomeGlCode = getString(json, "incomeGlCode") == null ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
|
|
||||||
: getString(json, "incomeGlCode").trim();
|
|
||||||
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
|
|
||||||
: getString(json, "financialYear").trim();
|
|
||||||
|
|
||||||
// strict = true: a manual posting reports "already closed" or "nothing to post" as an
|
|
||||||
// error, so the user is told why nothing happened.
|
|
||||||
final Map<String, Object> result = service.postToProfit(incomeGlCode, financialYear, currentUser.getId(), currentUser.getUsername(),
|
|
||||||
sanitiseNote(getString(json, "note")), true);
|
|
||||||
|
|
||||||
return Response.ok(gson.toJson(result)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// POST /close-year — close the WHOLE P&L into Bank Profit
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closes every income and expense GL for the year, so Bank Profit ends up holding the year's real net profit.
|
|
||||||
*
|
|
||||||
* This is the full year-end close; the endpoint above closes one named income GL and remains for the narrower
|
|
||||||
* share-entry-fee case. The share dividend declaration reads Bank Profit as the year's profit, so it is this that
|
|
||||||
* has to have run — after a single-GL posting the rest of the profit is still sitting in the income accounts.
|
|
||||||
*/
|
|
||||||
@POST
|
|
||||||
@Path("/close-year")
|
|
||||||
@Transactional
|
|
||||||
public Response closeYear(final String body) {
|
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
|
||||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
|
||||||
|
|
||||||
final JsonObject json = parseBody(body);
|
|
||||||
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
|
|
||||||
: getString(json, "financialYear").trim();
|
|
||||||
|
|
||||||
// strict = true: a manual close reports "nothing left to close" as an error, so the user is
|
|
||||||
// told why nothing happened rather than getting a silent success.
|
|
||||||
final Map<String, Object> result = service.closeAllToProfit(financialYear, currentUser.getId(), currentUser.getUsername(),
|
|
||||||
sanitiseNote(getString(json, "note")), true);
|
|
||||||
|
|
||||||
return Response.ok(gson.toJson(result)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// Helpers
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
private JsonObject parseBody(final String body) {
|
|
||||||
if (body == null || body.isBlank()) {
|
|
||||||
return new JsonObject();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JsonParser.parseString(body).getAsJsonObject();
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.incomeprofit.request.invalid", "Request body must be valid JSON.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String sanitiseNote(final String raw) {
|
|
||||||
if (raw == null || raw.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final String trimmed = raw.trim();
|
|
||||||
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getString(final JsonObject json, final String field) {
|
|
||||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long toLong(final Object o) {
|
|
||||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
|
|
||||||
|
|
||||||
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
|
||||||
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
|
||||||
import org.springframework.batch.core.Job;
|
|
||||||
import org.springframework.batch.core.Step;
|
|
||||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
|
||||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
|
||||||
import org.springframework.batch.core.repository.JobRepository;
|
|
||||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
public class PostShareEntryFeeToProfitConfig {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private JobRepository jobRepository;
|
|
||||||
@Autowired
|
|
||||||
private PlatformTransactionManager transactionManager;
|
|
||||||
@Autowired
|
|
||||||
private IncomeProfitTransferService incomeProfitTransferService;
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
protected Step postShareEntryFeeToProfitStep() {
|
|
||||||
return new StepBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository)
|
|
||||||
.tasklet(postShareEntryFeeToProfitTasklet(), transactionManager).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public Job postShareEntryFeeToProfitJob() {
|
|
||||||
return new JobBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository).start(postShareEntryFeeToProfitStep())
|
|
||||||
.incrementer(new RunIdIncrementer()).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public PostShareEntryFeeToProfitTasklet postShareEntryFeeToProfitTasklet() {
|
|
||||||
return new PostShareEntryFeeToProfitTasklet(incomeProfitTransferService);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
|
|
||||||
import org.springframework.batch.core.StepContribution;
|
|
||||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
|
||||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
|
||||||
import org.springframework.batch.repeat.RepeatStatus;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Year-end closing of the share entry fee: on 31 March the balance of Share Entry Fee Income is posted to the Bank
|
|
||||||
* Profit equity GL, leaving the income account at nil for the new year.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Runs unattended, so a no-op (already closed for the year, or no fee collected) is a normal outcome and is logged
|
|
||||||
* rather than failing the job. Only a genuine posting failure is raised.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class PostShareEntryFeeToProfitTasklet implements Tasklet {
|
|
||||||
|
|
||||||
/** Fineract's system user id, used because a scheduled run has no logged-in user. */
|
|
||||||
private static final long SYSTEM_USER_ID = 1L;
|
|
||||||
|
|
||||||
private final IncomeProfitTransferService incomeProfitTransferService;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
|
||||||
final String financialYear = incomeProfitTransferService.currentFinancialYear();
|
|
||||||
|
|
||||||
// Closes the WHOLE P&L, not just the share entry fee it was originally written for. Bank Profit
|
|
||||||
// is now what the share dividend declaration reads as "the year's profit", and a partial close
|
|
||||||
// would leave most of the profit still sitting in the income GLs — understating what may be
|
|
||||||
// distributed. Per-GL guards inside closeAllToProfit make a re-run safe.
|
|
||||||
final Map<String, Object> result = incomeProfitTransferService.closeAllToProfit(financialYear, SYSTEM_USER_ID, "system",
|
|
||||||
"Automatic year-end closing", false);
|
|
||||||
|
|
||||||
log.info("Year-end closing for {}: {}", financialYear, result.get("message"));
|
|
||||||
return RepeatStatus.FINISHED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-377
@@ -1,377 +0,0 @@
|
|||||||
package org.apache.fineract.accounting.yearend.service;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.fineract.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 Bank Profit (BANKPROFIT01)
|
|
||||||
* </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;
|
|
||||||
|
|
||||||
/** Equity GL that accumulates profit. */
|
|
||||||
/**
|
|
||||||
* The year's profit is closed into the Non-Distributable account, not plain Bank Profit.
|
|
||||||
*
|
|
||||||
* A dividend is declared against this balance; whatever the members do not receive moves across to Bank Profit once
|
|
||||||
* the declaration is approved. Closing into Bank Profit 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.
|
|
||||||
*/
|
|
||||||
public static final String PROFIT_GL_CODE = "BANKPROFITND01";
|
|
||||||
|
|
||||||
/** 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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = loadGlByCode(PROFIT_GL_CODE);
|
|
||||||
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 = loadGlByCode(PROFIT_GL_CODE);
|
|
||||||
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 = loadGlByCode(PROFIT_GL_CODE);
|
|
||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-5
@@ -61,11 +61,6 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isMakerCheckerEnabled() {
|
|
||||||
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
||||||
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* 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
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.organisation.agent.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);
|
||||||
|
}
|
||||||
+12
-8
@@ -16,27 +16,31 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.savings.handler;
|
package org.apache.fineract.organisation.agent.handler;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
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.savings.service.FdBaseInterestRateWritePlatformService;
|
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
|
||||||
|
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 = "CBSBASEINTERESTRATE", action = "CREATE")
|
@CommandType(entity = "AGENT", action = "CREATE")
|
||||||
@RequiredArgsConstructor
|
public class CreateAgentCommandHandler implements NewCommandSourceHandler {
|
||||||
public class CreateCbsBaseInterestRateCommandHandler implements NewCommandSourceHandler {
|
|
||||||
|
|
||||||
private final FdBaseInterestRateWritePlatformService writePlatformService;
|
private final AgentWritePlatformService writePlatformService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CreateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
|
||||||
|
this.writePlatformService = writePlatformService;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
return this.writePlatformService.saveInterestRates(command);
|
return this.writePlatformService.createAgent(command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.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
@@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 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.util.Collection;
|
||||||
|
import org.apache.fineract.organisation.agent.data.AgentData;
|
||||||
|
|
||||||
|
public interface AgentReadPlatformService {
|
||||||
|
|
||||||
|
Collection<AgentData> retrieveAll();
|
||||||
|
|
||||||
|
AgentData retrieveOne(Long agentId);
|
||||||
|
}
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 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 org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
|
|
||||||
|
public interface AgentWritePlatformService {
|
||||||
|
|
||||||
|
CommandProcessingResult createAgent(JsonCommand command);
|
||||||
|
|
||||||
|
CommandProcessingResult updateAgent(Long agentId, JsonCommand command);
|
||||||
|
}
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* 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
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.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
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-7
@@ -16,15 +16,16 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.useradministration.domain;
|
package org.apache.fineract.organisation.commissionslab.domain;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
public interface UserPendingRequestRepository extends JpaRepository<UserPendingRequest, Long>, JpaSpecificationExecutor<UserPendingRequest> {
|
public interface CommissionSlabRepository extends JpaRepository<CommissionSlab, Long> {
|
||||||
|
|
||||||
Optional<UserPendingRequest> findByPendingLockKey(String pendingLockKey);
|
@Query("select s from CommissionSlab s where s.slabType = :slabType and :amount between s.slabFrom and s.slabTo")
|
||||||
|
List<CommissionSlab> findMatchingSlabs(@Param("slabType") String slabType, @Param("amount") BigDecimal amount);
|
||||||
boolean existsByPendingLockKey(String pendingLockKey);
|
|
||||||
}
|
}
|
||||||
+8
-8
@@ -16,31 +16,31 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.savings.handler;
|
package org.apache.fineract.organisation.commissionslab.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.savings.service.DepositAccountWritePlatformService;
|
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
|
||||||
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 = "FIXEDDEPOSITACCOUNT", action = "PAYMENT")
|
@CommandType(entity = "COMMISSIONSLAB", action = "CREATE")
|
||||||
public class PaymentFixedDepositAccountCommandHandler implements NewCommandSourceHandler {
|
public class CreateCommissionSlabsCommandHandler implements NewCommandSourceHandler {
|
||||||
|
|
||||||
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
private final CommissionSlabWritePlatformService writePlatformService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public PaymentFixedDepositAccountCommandHandler(final DepositAccountWritePlatformService depositAccountWritePlatformService) {
|
public CreateCommissionSlabsCommandHandler(final CommissionSlabWritePlatformService writePlatformService) {
|
||||||
this.depositAccountWritePlatformService = depositAccountWritePlatformService;
|
this.writePlatformService = writePlatformService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||||
return this.depositAccountWritePlatformService.processFdPayment(command.entityId(), command);
|
return this.writePlatformService.createCommissionSlabs(command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 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.util.Collection;
|
||||||
|
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
|
||||||
|
|
||||||
|
public interface CommissionSlabReadPlatformService {
|
||||||
|
|
||||||
|
Collection<CommissionSlabData> retrieveAll();
|
||||||
|
}
|
||||||
+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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -16,12 +16,12 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.savings.service;
|
package org.apache.fineract.organisation.commissionslab.service;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
public interface FdBaseInterestRateWritePlatformService {
|
public interface CommissionSlabWritePlatformService {
|
||||||
|
|
||||||
CommandProcessingResult saveInterestRates(JsonCommand command);
|
CommandProcessingResult createCommissionSlabs(JsonCommand command);
|
||||||
}
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 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
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-16
@@ -357,14 +357,9 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
|
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
|
||||||
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
|
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
|
||||||
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
|
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
|
||||||
accountsSummary.append("sa.deposit_type_enum as depositType, ");
|
accountsSummary.append("sa.deposit_type_enum as depositType ");
|
||||||
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
|
|
||||||
// this row's cycle within the chain, so a client's account list can collapse a renewed
|
|
||||||
// FD into one entry rather than listing every term as a separate account.
|
|
||||||
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
|
|
||||||
accountsSummary.append("from m_savings_account sa ");
|
accountsSummary.append("from m_savings_account sa ");
|
||||||
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
||||||
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
|
|
||||||
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
|
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
|
||||||
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
||||||
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
|
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
|
||||||
@@ -458,15 +453,9 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
|
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
|
||||||
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
|
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
|
||||||
|
|
||||||
// Null for a savings/current account and for an FD opened before this column existed;
|
|
||||||
// callers fall back to accountNo, so an FD that has never been renewed still reads as
|
|
||||||
// its own FD number.
|
|
||||||
final String fdAccountNumber = rs.getString("fdAccountNumber");
|
|
||||||
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
|
|
||||||
|
|
||||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||||
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
||||||
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
|
lastActiveTransactionDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,10 +567,8 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
|
|
||||||
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
|
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
|
||||||
|
|
||||||
// Current accounts have no deposit term, so no FD renewal chain to report.
|
|
||||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||||
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
|
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate);
|
||||||
null, null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-299
@@ -1,299 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.accountregister.api;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import jakarta.ws.rs.GET;
|
|
||||||
import jakarta.ws.rs.Path;
|
|
||||||
import jakarta.ws.rs.Produces;
|
|
||||||
import jakarta.ws.rs.QueryParam;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.sql.Date;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Account register for one date: which accounts were opened and which were closed that day, with
|
|
||||||
* each account's balance as it stood on that date alongside what it holds now.
|
|
||||||
*
|
|
||||||
* The dated balance is read from the running balance stamped on the last transaction up to that
|
|
||||||
* date, rather than recomputed from the account's current figure — a current balance tells you
|
|
||||||
* nothing about what the account held three weeks ago.
|
|
||||||
*/
|
|
||||||
@Path("/v1/accountregister")
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
public class AccountRegisterApiResource {
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final Gson gson = new Gson();
|
|
||||||
|
|
||||||
@GET
|
|
||||||
public String register(@QueryParam("date") final String dateParam, @QueryParam("section") final String section) {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
|
|
||||||
final LocalDate asOn;
|
|
||||||
if (dateParam != null && !dateParam.isBlank()) {
|
|
||||||
try {
|
|
||||||
asOn = LocalDate.parse(dateParam);
|
|
||||||
} catch (final java.time.format.DateTimeParseException e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.accountregister.date.invalid",
|
|
||||||
"Date must be in yyyy-MM-dd format.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
asOn = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
}
|
|
||||||
|
|
||||||
final String want = section == null || section.isBlank() ? "ALL" : section.toUpperCase();
|
|
||||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
|
||||||
|
|
||||||
if (matches(want, "SAVINGS") || matches(want, "FD") || matches(want, "RD")) {
|
|
||||||
rows.addAll(depositAccounts(asOn, want));
|
|
||||||
}
|
|
||||||
if (matches(want, "SHARE")) {
|
|
||||||
rows.addAll(shareAccounts(asOn));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opened and closed counts come from the rows themselves rather than separate COUNT queries,
|
|
||||||
// so the totals can never disagree with the list they summarise.
|
|
||||||
int opened = 0;
|
|
||||||
int closed = 0;
|
|
||||||
BigDecimal balanceOnDate = BigDecimal.ZERO;
|
|
||||||
BigDecimal currentBalance = BigDecimal.ZERO;
|
|
||||||
for (final Map<String, Object> r : rows) {
|
|
||||||
if (Boolean.TRUE.equals(r.get("openedOnDate"))) {
|
|
||||||
opened++;
|
|
||||||
}
|
|
||||||
if (Boolean.TRUE.equals(r.get("closedOnDate"))) {
|
|
||||||
closed++;
|
|
||||||
}
|
|
||||||
balanceOnDate = balanceOnDate.add(dec(r.get("balanceOnDate")));
|
|
||||||
currentBalance = currentBalance.add(dec(r.get("currentBalance")));
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("date", asOn.toString());
|
|
||||||
out.put("section", want);
|
|
||||||
out.put("accounts", rows);
|
|
||||||
out.put("count", rows.size());
|
|
||||||
out.put("openedCount", opened);
|
|
||||||
out.put("closedCount", closed);
|
|
||||||
out.put("totalBalanceOnDate", balanceOnDate);
|
|
||||||
out.put("totalCurrentBalance", currentBalance);
|
|
||||||
return gson.toJson(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean matches(final String want, final String section) {
|
|
||||||
return "ALL".equals(want) || want.equals(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BigDecimal dec(final Object o) {
|
|
||||||
return o instanceof BigDecimal b ? b : BigDecimal.ZERO;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Savings, fixed and recurring deposit accounts. All three live in m_savings_account, told
|
|
||||||
* apart by deposit_type_enum: 100 savings, 200 fixed, 300 recurring.
|
|
||||||
*
|
|
||||||
* Accounts submitted after the date are excluded — on that date they did not exist, and listing
|
|
||||||
* them would make a register of a past day describe a present one.
|
|
||||||
*/
|
|
||||||
private List<Map<String, Object>> depositAccounts(final LocalDate asOn, final String want) {
|
|
||||||
final Date date = Date.valueOf(asOn);
|
|
||||||
|
|
||||||
final StringBuilder typeFilter = new StringBuilder();
|
|
||||||
if (!"ALL".equals(want)) {
|
|
||||||
final int type = switch (want) {
|
|
||||||
case "SAVINGS" -> 100;
|
|
||||||
case "FD" -> 200;
|
|
||||||
case "RD" -> 300;
|
|
||||||
default -> 0;
|
|
||||||
};
|
|
||||||
if (type > 0) {
|
|
||||||
typeFilter.append(" AND sa.deposit_type_enum = ").append(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
|
||||||
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.status_enum,
|
|
||||||
sa.activatedon_date, sa.closedon_date, sa.submittedon_date,
|
|
||||||
sa.account_balance_derived,
|
|
||||||
c.display_name AS client_name,
|
|
||||||
p.name AS product_name,
|
|
||||||
(SELECT t.running_balance_derived
|
|
||||||
FROM m_savings_account_transaction t
|
|
||||||
WHERE t.savings_account_id = sa.id
|
|
||||||
AND COALESCE(t.is_reversed, false) = false
|
|
||||||
AND t.transaction_date <= ?
|
|
||||||
ORDER BY t.transaction_date DESC, t.id DESC
|
|
||||||
LIMIT 1) AS balance_on_date
|
|
||||||
FROM m_savings_account sa
|
|
||||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
|
||||||
LEFT JOIN m_savings_product p ON p.id = sa.product_id
|
|
||||||
WHERE (sa.submittedon_date IS NULL OR sa.submittedon_date <= ?)
|
|
||||||
""" + typeFilter + """
|
|
||||||
ORDER BY sa.deposit_type_enum, sa.account_no
|
|
||||||
""", date, date);
|
|
||||||
|
|
||||||
final List<Map<String, Object>> out = new ArrayList<>();
|
|
||||||
for (final Map<String, Object> r : rows) {
|
|
||||||
final Map<String, Object> m = new LinkedHashMap<>();
|
|
||||||
final int depositType = num(r.get("deposit_type_enum"));
|
|
||||||
m.put("accountId", r.get("id"));
|
|
||||||
m.put("accountNo", r.get("account_no"));
|
|
||||||
m.put("section", depositType == 200 ? "FD" : depositType == 300 ? "RD" : "SAVINGS");
|
|
||||||
m.put("clientName", r.get("client_name"));
|
|
||||||
m.put("productName", r.get("product_name"));
|
|
||||||
m.put("status", savingsStatus(num(r.get("status_enum"))));
|
|
||||||
m.put("openedOn", str(r.get("activatedon_date")));
|
|
||||||
m.put("closedOn", str(r.get("closedon_date")));
|
|
||||||
m.put("openedOnDate", sameDay(r.get("activatedon_date"), asOn));
|
|
||||||
m.put("closedOnDate", sameDay(r.get("closedon_date"), asOn));
|
|
||||||
// No transaction up to that date means the account had nothing in it yet, not that the
|
|
||||||
// balance is unknown.
|
|
||||||
m.put("balanceOnDate", r.get("balance_on_date") == null ? BigDecimal.ZERO : r.get("balance_on_date"));
|
|
||||||
m.put("currentBalance", r.get("account_balance_derived") == null
|
|
||||||
? BigDecimal.ZERO : r.get("account_balance_derived"));
|
|
||||||
out.add(m);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Share accounts. Their transactions carry no running balance the way savings ones do, but they
|
|
||||||
* are individually dated, so the holding on a given date is the shares purchased up to that
|
|
||||||
* date less those redeemed — which is a real dated figure, not the current one relabelled.
|
|
||||||
*/
|
|
||||||
private List<Map<String, Object>> shareAccounts(final LocalDate asOn) {
|
|
||||||
final Date date = Date.valueOf(asOn);
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
|
||||||
SELECT sa.id, sa.account_no, sa.status_enum, sa.activated_date, sa.closed_date,
|
|
||||||
sa.total_approved_shares,
|
|
||||||
c.display_name AS client_name,
|
|
||||||
p.name AS product_name,
|
|
||||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
|
|
||||||
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
|
|
||||||
ELSE 0 END), 0)
|
|
||||||
FROM m_share_account_transactions t
|
|
||||||
WHERE t.account_id = sa.id AND t.is_active = true
|
|
||||||
AND t.status_enum = 300
|
|
||||||
AND t.type_enum IN (500, 600)) AS share_value,
|
|
||||||
-- Purchases less redemptions dated on or before the register's date. Charge
|
|
||||||
-- rows (type 700) are excluded: an entry fee is not share capital. Only
|
|
||||||
-- approved purchases count (status 300) — a rejected application leaves its
|
|
||||||
-- row behind still flagged active, and counting it credited a member with
|
|
||||||
-- shares they were never issued.
|
|
||||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
|
|
||||||
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
|
|
||||||
ELSE 0 END), 0)
|
|
||||||
FROM m_share_account_transactions t
|
|
||||||
WHERE t.account_id = sa.id AND t.is_active = true
|
|
||||||
AND t.status_enum = 300
|
|
||||||
AND t.type_enum IN (500, 600)
|
|
||||||
AND t.transaction_date <= ?) AS value_on_date,
|
|
||||||
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares
|
|
||||||
WHEN t.type_enum = 600 THEN -t.total_shares
|
|
||||||
ELSE 0 END), 0)
|
|
||||||
FROM m_share_account_transactions t
|
|
||||||
WHERE t.account_id = sa.id AND t.is_active = true
|
|
||||||
AND t.status_enum = 300
|
|
||||||
AND t.type_enum IN (500, 600)
|
|
||||||
AND t.transaction_date <= ?) AS shares_on_date
|
|
||||||
FROM m_share_account sa
|
|
||||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
|
||||||
LEFT JOIN m_share_product p ON p.id = sa.product_id
|
|
||||||
WHERE (sa.submitted_date IS NULL OR sa.submitted_date <= ?)
|
|
||||||
ORDER BY sa.account_no
|
|
||||||
""", date, date, date);
|
|
||||||
|
|
||||||
final List<Map<String, Object>> out = new ArrayList<>();
|
|
||||||
for (final Map<String, Object> r : rows) {
|
|
||||||
final Map<String, Object> m = new LinkedHashMap<>();
|
|
||||||
m.put("accountId", r.get("id"));
|
|
||||||
m.put("accountNo", r.get("account_no"));
|
|
||||||
m.put("section", "SHARE");
|
|
||||||
m.put("clientName", r.get("client_name"));
|
|
||||||
m.put("productName", r.get("product_name"));
|
|
||||||
m.put("status", shareStatus(num(r.get("status_enum"))));
|
|
||||||
m.put("openedOn", str(r.get("activated_date")));
|
|
||||||
m.put("closedOn", str(r.get("closed_date")));
|
|
||||||
m.put("openedOnDate", sameDay(r.get("activated_date"), asOn));
|
|
||||||
m.put("closedOnDate", sameDay(r.get("closed_date"), asOn));
|
|
||||||
m.put("balanceOnDate", r.get("value_on_date") == null ? BigDecimal.ZERO : r.get("value_on_date"));
|
|
||||||
m.put("currentBalance", r.get("share_value") == null ? BigDecimal.ZERO : r.get("share_value"));
|
|
||||||
// Shares as at the date, not the approved figure — an account can hold fewer than were
|
|
||||||
// approved once some have been redeemed.
|
|
||||||
m.put("sharesHeld", r.get("shares_on_date"));
|
|
||||||
out.add(m);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean sameDay(final Object dbDate, final LocalDate asOn) {
|
|
||||||
return dbDate != null && asOn.toString().equals(dbDate.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String str(final Object o) {
|
|
||||||
return o == null ? null : o.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int num(final Object o) {
|
|
||||||
return o instanceof Number n ? n.intValue() : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String savingsStatus(final int status) {
|
|
||||||
return switch (status) {
|
|
||||||
case 100 -> "SUBMITTED";
|
|
||||||
case 200 -> "APPROVED";
|
|
||||||
case 300 -> "ACTIVE";
|
|
||||||
case 400 -> "WITHDRAWN";
|
|
||||||
case 500 -> "REJECTED";
|
|
||||||
case 600 -> "CLOSED";
|
|
||||||
case 700 -> "TRANSFER IN PROGRESS";
|
|
||||||
case 800 -> "TRANSFER ON HOLD";
|
|
||||||
case 900 -> "PREMATURELY CLOSED";
|
|
||||||
default -> "UNKNOWN";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String shareStatus(final int status) {
|
|
||||||
return switch (status) {
|
|
||||||
case 100 -> "SUBMITTED";
|
|
||||||
case 200 -> "APPROVED";
|
|
||||||
case 300 -> "ACTIVE";
|
|
||||||
case 400 -> "WITHDRAWN";
|
|
||||||
case 500 -> "REJECTED";
|
|
||||||
case 600 -> "CLOSED";
|
|
||||||
default -> "UNKNOWN";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-31
@@ -113,37 +113,6 @@ public interface ShareAccountApiConstants {
|
|||||||
* outstanding balance. */
|
* outstanding balance. */
|
||||||
String linkedloanid_paramname = "linkedLoanId";
|
String linkedloanid_paramname = "linkedLoanId";
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the money for an additional-share tranche comes from — {@code CASH} or {@code SAVINGS}.
|
|
||||||
*
|
|
||||||
* Both routes leave the money in the Share Sundry (SHARES_SUSPENSE) holding account, so
|
|
||||||
* approving the tranche posts the same Dr Sundry / Cr Share Capital pair either way. They
|
|
||||||
* differ only in how it got there: CASH means the cashier already took it at the counter
|
|
||||||
* against a voucher, SAVINGS means the member's own savings account is debited on submit.
|
|
||||||
*/
|
|
||||||
String fundingsource_paramname = "fundingSource";
|
|
||||||
|
|
||||||
String FUNDING_SOURCE_CASH = "CASH";
|
|
||||||
|
|
||||||
String FUNDING_SOURCE_SAVINGS = "SAVINGS";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Share Sundry receipt funding a CASH tranche, from {@code m_share_advance_collection}.
|
|
||||||
*
|
|
||||||
* Recorded against the tranche so one voucher cannot be spent twice, and so the approver can
|
|
||||||
* see which receipt the money they are releasing came in on.
|
|
||||||
*/
|
|
||||||
String advancecollectionid_paramname = "advanceCollectionId";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The remaining vouchers funding a CASH tranche, when one receipt does not cover it.
|
|
||||||
*
|
|
||||||
* A member paying in instalments — ₹600 today, the ₹400 balance tomorrow — has several receipts
|
|
||||||
* against one request. The first leads in {@link #advancecollectionid_paramname}; these are the
|
|
||||||
* rest, and their combined total is what has to cover the shares.
|
|
||||||
*/
|
|
||||||
String additionalcollectionids_paramname = "additionalCollectionIds";
|
|
||||||
|
|
||||||
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
|
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
|
||||||
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
||||||
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
||||||
|
|||||||
+1
-4
@@ -30,7 +30,6 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
|||||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,9 +42,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
// REQUIRES_NEW for the same reason as AmlRuleEvaluationServiceImpl: a failed alert write must not
|
@Transactional
|
||||||
// be able to mark the caller's transfer transaction rollback-only and undo a settled transfer.
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
public class AmlFundTransferEvaluationService {
|
public class AmlFundTransferEvaluationService {
|
||||||
|
|
||||||
private static final int STRUCTURING_WINDOW_DAYS = 30;
|
private static final int STRUCTURING_WINDOW_DAYS = 30;
|
||||||
|
|||||||
+1
-10
@@ -31,20 +31,11 @@ import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
|||||||
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
|
||||||
* REQUIRES_NEW rather than the default REQUIRED: AML monitoring is a passive observer of a
|
|
||||||
* transaction that has already succeeded. Joining the caller's transaction would let a failed
|
|
||||||
* alert insert mark it rollback-only, destroying the customer's deposit/withdrawal/transfer at
|
|
||||||
* commit time - and the callers' catch blocks cannot rescue that, because the damage is done to
|
|
||||||
* the shared transaction rather than thrown to them. A private transaction keeps AML failure
|
|
||||||
* contained to AML.
|
|
||||||
*/
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@Transactional
|
||||||
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||||
|
|
||||||
private static final int MEDIUM_RISK = 2;
|
private static final int MEDIUM_RISK = 2;
|
||||||
|
|||||||
-4
@@ -64,13 +64,9 @@ public class ClientRiskEscalationServiceImpl implements ClientRiskEscalationServ
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moves the re-KYC due date onto the new category's cycle as well, so the customer is
|
|
||||||
// called back in on the schedule the escalated rating requires.
|
|
||||||
kyc.escalateRiskCategory(nextCategory);
|
kyc.escalateRiskCategory(nextCategory);
|
||||||
historyRepository.save(ClientRiskCategoryHistory.autoEscalation(client, previousCategory, nextCategory, strCase,
|
historyRepository.save(ClientRiskCategoryHistory.autoEscalation(client, previousCategory, nextCategory, strCase,
|
||||||
context.authenticatedUser(), LocalDateTime.now()));
|
context.authenticatedUser(), LocalDateTime.now()));
|
||||||
log.info("Client {} auto-escalated from risk category {} to {} on the {}rd cumulative STR case; re-KYC now due {}",
|
|
||||||
client.getId(), previousCategory, nextCategory, cumulativeCases, kyc.getExpiryDate());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Integer nextCategory(Integer currentCategory) {
|
private Integer nextCategory(Integer currentCategory) {
|
||||||
|
|||||||
-1040
File diff suppressed because it is too large
Load Diff
+8
-151
@@ -54,10 +54,7 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -70,13 +67,8 @@ import com.google.common.base.Splitter;
|
|||||||
@Component
|
@Component
|
||||||
@Tag(name = "Cashier Deposit Requests", description = "Maker-checker deposit request workflow: cashier submits, bank manager approves.")
|
@Tag(name = "Cashier Deposit Requests", description = "Maker-checker deposit request workflow: cashier submits, bank manager approves.")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@lombok.extern.slf4j.Slf4j
|
|
||||||
public class CashierDepositRequestApiResource {
|
public class CashierDepositRequestApiResource {
|
||||||
|
|
||||||
// Named LOG rather than declared with @Slf4j: buildActivityLog uses a local "log" for the
|
|
||||||
// activity JsonArray, and a field of the same name reads as that variable at a glance.
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(CashierDepositRequestApiResource.class);
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
@@ -169,7 +161,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
// reject() will delete these records so the vault position is reversed.
|
// reject() will delete these records so the vault position is reversed.
|
||||||
if (denominationData != null && !denominationData.isBlank()) {
|
if (denominationData != null && !denominationData.isBlank()) {
|
||||||
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
|
savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId,
|
||||||
currentUser.getId(), currentUser.getUsername(), paymentTypeName);
|
currentUser.getId(), currentUser.getUsername());
|
||||||
}
|
}
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
||||||
@@ -402,20 +394,6 @@ public class CashierDepositRequestApiResource {
|
|||||||
txnId = isCurrentAccount
|
txnId = isCurrentAccount
|
||||||
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
|
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||||
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||||
if (!isCurrentAccount && txnId != null) {
|
|
||||||
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
|
||||||
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
|
|
||||||
// hit an edge case in the new hold-placement logic would fail entirely, where before
|
|
||||||
// this feature existed it always succeeded. Catching it keeps the Clearing Hold strictly
|
|
||||||
// best-effort: the deposit approval this whole endpoint exists for must never depend on
|
|
||||||
// it, only benefit from it when it works.
|
|
||||||
try {
|
|
||||||
placeClearingHoldIfInstrumentDeposit(savingsAccountId, ptId, amount, txnDateStr);
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
log.warn("Could not place a Clearing Hold for cashier deposit request {} (savings account {}): {}", id,
|
|
||||||
savingsAccountId, e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link voucher to the created savings txn and stamp voucher_no on it.
|
// Link voucher to the created savings txn and stamp voucher_no on it.
|
||||||
@@ -428,10 +406,6 @@ public class CashierDepositRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sides of the slip, recorded now rather than at submission: which accounts the money
|
|
||||||
// actually moved between is only settled once the request is approved and posted.
|
|
||||||
addDepositVoucherLines(record, savingsAccountId, amount);
|
|
||||||
|
|
||||||
final String updatedLog = buildActivityLog(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||||
);
|
);
|
||||||
@@ -566,62 +540,6 @@ public class CashierDepositRequestApiResource {
|
|||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Records the two sides of an approved deposit on its voucher.
|
|
||||||
*
|
|
||||||
* A deposit is money arriving at the counter, so cash is debited and whatever receives it is
|
|
||||||
* credited — a member's savings account, or a GL account when the deposit was raised against
|
|
||||||
* one. The credited side is read from the request, because that is what the cashier actually
|
|
||||||
* filled in and what the member signed for.
|
|
||||||
*
|
|
||||||
* A failure here does not fail the approval. The deposit is already posted and the member's
|
|
||||||
* balance already raised; refusing that over its slip would take a correct transaction back for
|
|
||||||
* a paperwork reason. The problem is logged, and the voucher keeps its number without sides.
|
|
||||||
*/
|
|
||||||
private void addDepositVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
|
|
||||||
final String voucherNo = (String) record.get("voucher_no");
|
|
||||||
if (voucherNo == null || voucherNo.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final boolean isGl = "GL".equalsIgnoreCase((String) record.get("account_type"));
|
|
||||||
final String accountNo = (String) record.get("account_no");
|
|
||||||
|
|
||||||
// Cash reaching the counter. For a GL deposit the contra is the cash/vault account the
|
|
||||||
// request named; for a member deposit it is the cashier's own till, which the deposit
|
|
||||||
// register does not carry a GL id for — the line still names it, without an id.
|
|
||||||
final Object contraObj = record.get("contra_gl_id");
|
|
||||||
final Long contraGlId = contraObj != null ? ((Number) contraObj).longValue() : null;
|
|
||||||
final Map<String, Object> contra = glAccountForVoucher(contraGlId);
|
|
||||||
final VoucherLine debit = VoucherLine.debitGl(contraGlId, (String) contra.getOrDefault("name", "Cash in Hand"),
|
|
||||||
(String) contra.get("gl_code"), amount);
|
|
||||||
|
|
||||||
final VoucherLine credit;
|
|
||||||
if (isGl) {
|
|
||||||
final Object glObj = record.get("gl_account_id");
|
|
||||||
final Long glAccountId = glObj != null ? ((Number) glObj).longValue() : null;
|
|
||||||
final Map<String, Object> gl = glAccountForVoucher(glAccountId);
|
|
||||||
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
|
|
||||||
} else {
|
|
||||||
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
voucherService.addLines(voucherNo, List.of(debit, credit));
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
LOG.error("Could not record voucher lines for deposit request voucher {}", voucherNo, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
|
|
||||||
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
|
||||||
if (glAccountId == null) {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
|
|
||||||
.orElse(Map.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
||||||
@@ -664,64 +582,10 @@ public class CashierDepositRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// The deposit register raised its own voucher at submission and attaches the till and
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
// member sides to it on approval, so the savings command must not raise a second one over
|
|
||||||
// the same money -- exactly as the GL branch of this register already does below. Without
|
|
||||||
// this, one deposit appears twice on the cashier's received report and doubles the day's
|
|
||||||
// cash taken in.
|
|
||||||
final CommandProcessingResult result = voucherService
|
|
||||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
|
|
||||||
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
|
|
||||||
* until the instrument clears rather than let it sit withdrawable, so this places an automatic
|
|
||||||
* Clearing Hold for the full deposited amount right after a Cheque/DD deposit posts. Cash and
|
|
||||||
* electronic-transfer payment types (NEFT/RTGS/IMPS/UPI) are untouched -- cash is available on the
|
|
||||||
* spot, and an electronic transfer has already settled by the time it's entered here.
|
|
||||||
*/
|
|
||||||
private void placeClearingHoldIfInstrumentDeposit(final Long savingsAccountId, final Object paymentTypeId, final BigDecimal amount,
|
|
||||||
final String txnDateStr) {
|
|
||||||
if (paymentTypeId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final Long ptId = ((Number) paymentTypeId).longValue();
|
|
||||||
// m_payment_type's display name column is "value", not "name" (the mistake that broke this
|
|
||||||
// in testing) -- name() collisions with other tables made this easy to get wrong once and not
|
|
||||||
// notice until it actually ran against the real schema.
|
|
||||||
final List<String> names = jdbcTemplate.query("SELECT value FROM m_payment_type WHERE id = ?",
|
|
||||||
(rs, rowNum) -> rs.getString("value"), ptId);
|
|
||||||
if (names.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final String name = names.get(0).toLowerCase();
|
|
||||||
final boolean isInstrumentDeposit = name.contains("cheque") || name.contains("check") || name.equals("dd")
|
|
||||||
|| name.contains("demand draft");
|
|
||||||
if (!isInstrumentDeposit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
|
||||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
|
||||||
|
|
||||||
final JsonObject payload = new JsonObject();
|
|
||||||
payload.addProperty("locale", "en");
|
|
||||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
|
||||||
payload.addProperty("transactionDate", date);
|
|
||||||
payload.addProperty("transactionAmount", amount);
|
|
||||||
payload.addProperty("reasonForBlock", "Pending clearing — " + names.get(0) + " deposit");
|
|
||||||
payload.addProperty("holdCategory", "CLEARING");
|
|
||||||
payload.addProperty("lienAllowed", false);
|
|
||||||
|
|
||||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
|
||||||
.holdAmount(savingsAccountId)
|
|
||||||
.withJson(payload.toString())
|
|
||||||
.build();
|
|
||||||
commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String executeCurrentAccountDeposit(
|
private String executeCurrentAccountDeposit(
|
||||||
final Long currentAccountId, final BigDecimal amount,
|
final Long currentAccountId, final BigDecimal amount,
|
||||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||||
@@ -746,11 +610,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Suppressed for the same reason as the savings path above. Current accounts raise no
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
// voucher of their own today, so this changes nothing now; it is here so that the day one
|
|
||||||
// is added, this register does not silently start double-counting again.
|
|
||||||
final CommandProcessingResult result = voucherService
|
|
||||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,10 +685,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// The deposit register raises its own voucher, naming the member and their account; the
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||||
// journal command must not raise a second one over the same money.
|
|
||||||
final CommandProcessingResult result = voucherService
|
|
||||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
|
||||||
return result.getTransactionId() != null ? result.getTransactionId() : null;
|
return result.getTransactionId() != null ? result.getTransactionId() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -842,7 +699,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
// approve() updates txn_id to real Fineract id; reject() deletes these records.
|
// approve() updates txn_id to real Fineract id; reject() deletes these records.
|
||||||
private void savePendingDenomination(final String denominationData,
|
private void savePendingDenomination(final String denominationData,
|
||||||
final Long requestId, final Long savingsAccountId, final String accountNo,
|
final Long requestId, final Long savingsAccountId, final String accountNo,
|
||||||
final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) {
|
final Long clientId, final Long cashierId, final String cashierUsername) {
|
||||||
final JsonObject denomMap;
|
final JsonObject denomMap;
|
||||||
try {
|
try {
|
||||||
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||||
@@ -872,12 +729,12 @@ public class CashierDepositRequestApiResource {
|
|||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO m_savings_txn_denomination
|
INSERT INTO m_savings_txn_denomination
|
||||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type)
|
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?)
|
||||||
""",
|
""",
|
||||||
requestId, savingsAccountId, accountNo, clientId, denomId,
|
requestId, savingsAccountId, accountNo, clientId, denomId,
|
||||||
value, count, BigDecimal.valueOf((long) value * count),
|
value, count, BigDecimal.valueOf((long) value * count),
|
||||||
cashierId, cashierUsername, paymentTypeName
|
cashierId, cashierUsername
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-459
@@ -13,10 +13,8 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
|
||||||
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -25,7 +23,6 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|||||||
@Path("/v1/cashreports")
|
@Path("/v1/cashreports")
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
public class CashReportApiResource {
|
public class CashReportApiResource {
|
||||||
|
|
||||||
@@ -33,307 +30,13 @@ public class CashReportApiResource {
|
|||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
private final VaultPositionService vaultPositionService;
|
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
/**
|
|
||||||
* Excludes reversed counter transactions from a cash figure.
|
|
||||||
*
|
|
||||||
* m_savings_txn_denomination has no reversal marker of its own -- the notes counted at the
|
|
||||||
* counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its
|
|
||||||
* breakdown behind and went on inflating the drawer for good. Every other leg of the cash
|
|
||||||
* position (vault transfers, settlements, inter-cashier legs, exchanges) already guards against
|
|
||||||
* its own reversal flag; this is the same guard for the counter, read off the transaction the
|
|
||||||
* breakdown belongs to.
|
|
||||||
*
|
|
||||||
* NOT EXISTS rather than a join, so a breakdown whose transaction cannot be found -- the DD
|
|
||||||
* register keys its rows by its own request id, not a savings transaction -- is kept rather than
|
|
||||||
* silently dropped.
|
|
||||||
*/
|
|
||||||
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
|
|
||||||
+ " SELECT 1 FROM m_savings_account_transaction rt "
|
|
||||||
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) ";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Day book — every voucher raised on one date, in one list, newest first.
|
|
||||||
*
|
|
||||||
* Pulls the three registers that carry a voucher: cashier deposits (CR), clerk withdrawals (DR)
|
|
||||||
* and the clerk disbursement register (DR, or CR for a collection). Each row carries its
|
|
||||||
* voucher number, side, amount, account, party and status, so the cashier can reconcile a day
|
|
||||||
* without opening three separate screens.
|
|
||||||
*
|
|
||||||
* Dated on the business date the voucher belongs to rather than the row's creation timestamp:
|
|
||||||
* deposits and withdrawals carry an explicit txn_date, and the disbursement register — which
|
|
||||||
* has no such column — falls back to the date it was submitted on.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("day-book")
|
|
||||||
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
|
|
||||||
@QueryParam("status") final String status) {
|
|
||||||
|
|
||||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
|
||||||
|
|
||||||
// Each register is left-joined to the verification table and to an attachment count, so a
|
|
||||||
// row already carries its physical-check verdict and whether a slip was scanned in — the
|
|
||||||
// screen never has to fire a follow-up call per voucher.
|
|
||||||
final String verifyJoin = """
|
|
||||||
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = r.voucher_no
|
|
||||||
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
|
|
||||||
va ON va.voucher_no = r.voucher_no
|
|
||||||
""";
|
|
||||||
final String verifyCols = """
|
|
||||||
vv.status AS verify_status, vv.slip_amount, vv.remarks AS verify_remarks,
|
|
||||||
vv.verified_by_name, vv.verified_at,
|
|
||||||
vv.resolution_status, vv.resolution_notes, vv.resolved_by_name, vv.resolved_at,
|
|
||||||
COALESCE(va.n, 0) AS attachment_count
|
|
||||||
""";
|
|
||||||
|
|
||||||
// Cashier deposits — money in, so the credit side.
|
|
||||||
rows.addAll(jdbcTemplate.query("""
|
|
||||||
SELECT r.voucher_no,
|
|
||||||
-- Same rule as the withdrawal register below: an approved deposit is cash in
|
|
||||||
-- the drawer, a pending one is a request nobody has taken money for yet.
|
|
||||||
CASE WHEN r.status IN ('APPROVED', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section,
|
|
||||||
'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type,
|
|
||||||
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
|
|
||||||
r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id,
|
|
||||||
r.submitted_at, r.actioned_at,
|
|
||||||
""" + verifyCols + """
|
|
||||||
FROM m_cashier_deposit_request r
|
|
||||||
""" + verifyJoin + """
|
|
||||||
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
|
|
||||||
""", this::mapDayBookRow, txnDate));
|
|
||||||
|
|
||||||
// Clerk withdrawals — money out, so the debit side.
|
|
||||||
rows.addAll(jdbcTemplate.query("""
|
|
||||||
SELECT r.voucher_no,
|
|
||||||
-- Filed by what happened to the money, not by who typed the request. Once the
|
|
||||||
-- notes are handed over it is the cashier's drawer that is short, so the row
|
|
||||||
-- belongs in their section; while it is still pending or was rejected no cash
|
|
||||||
-- has moved and it is the clerk's outstanding request.
|
|
||||||
CASE WHEN r.status IN ('WITHDRAWN', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section,
|
|
||||||
'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type,
|
|
||||||
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
|
|
||||||
COALESCE(paid.officer_username, r.clerk_name) AS handled_by,
|
|
||||||
r.manager_name AS approved_by, r.tok_no AS txn_id,
|
|
||||||
r.submitted_at, r.actioned_at,
|
|
||||||
""" + verifyCols + """
|
|
||||||
FROM m_clerk_withdrawal_request r
|
|
||||||
""" + verifyJoin + """
|
|
||||||
-- A withdrawal is raised by a clerk but the notes leave a cashier's drawer, so it
|
|
||||||
-- is filed under whoever's cash actually moved. Sectioning it by the maker put
|
|
||||||
-- every counter payment in the clerk's column and left the cashier's own section
|
|
||||||
-- short by exactly what they had paid out -- the one figure their drawer is
|
|
||||||
-- checked against. The payer is read from the denomination breakdown, which is
|
|
||||||
-- written when the cash is handed over, and the By column names them too.
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT d.officer_username FROM m_savings_txn_denomination d
|
|
||||||
WHERE r.txn_id ~ '^[0-9]+$' AND d.txn_id = CAST(r.txn_id AS BIGINT)
|
|
||||||
AND d.officer_username IS NOT NULL
|
|
||||||
LIMIT 1) paid ON TRUE
|
|
||||||
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
|
|
||||||
""", this::mapDayBookRow, txnDate));
|
|
||||||
|
|
||||||
// Account closures — a full-balance payout, so the debit side. Its own register rather than
|
|
||||||
// the withdrawal one: a closure carries no clerk-entered amount (the figure is whatever
|
|
||||||
// close() computes at approval) and the cash handed over is the ROUNDED
|
|
||||||
// cashier_disbursed_amount, which is what a cash book has to agree with, not the
|
|
||||||
// paisa-exact closing_amount posted to the member's ledger.
|
|
||||||
//
|
|
||||||
// Only CLOSED rows, unlike the registers above: a closure becomes a cash-book event when the
|
|
||||||
// cashier actually hands the notes over, and not before. A PENDING or APPROVED closure has
|
|
||||||
// taken nothing out of the drawer yet, and a REJECTED one never will — listing those would
|
|
||||||
// put rows with no cash behind them (a rejected closure has no amount at all) into the
|
|
||||||
// teller's day book. And the day they belong to is the day the cash left, so this filters on
|
|
||||||
// disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier
|
|
||||||
// step to reach.
|
|
||||||
rows.addAll(jdbcTemplate.query("""
|
|
||||||
SELECT r.voucher_no, 'DR' AS side, 'CLOSURE' AS kind, r.account_no,
|
|
||||||
'SAVINGS' AS account_type, r.client_name AS party,
|
|
||||||
COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount,
|
|
||||||
r.payment_type_name, r.description, r.status,
|
|
||||||
COALESCE(r.disbursed_by, r.clerk_name) AS handled_by,
|
|
||||||
r.manager_name AS approved_by, r.tok_no AS txn_id,
|
|
||||||
r.submitted_at, r.actioned_at,
|
|
||||||
""" + verifyCols + """
|
|
||||||
FROM m_clerk_closure_request r
|
|
||||||
""" + verifyJoin + """
|
|
||||||
WHERE r.status = 'CLOSED'
|
|
||||||
AND COALESCE(CAST(r.disbursed_at AS DATE), r.closure_date) = CAST(? AS DATE)
|
|
||||||
""", this::mapDayBookRow, txnDate));
|
|
||||||
|
|
||||||
// Disbursement register: a collection pulls money into the GL (CR); a disbursement or a
|
|
||||||
// bank transfer moves it out (DR) — matching how the voucher itself was numbered.
|
|
||||||
rows.addAll(jdbcTemplate.query("""
|
|
||||||
SELECT r.voucher_no, 'CLERK' AS section,
|
|
||||||
CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side,
|
|
||||||
r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type,
|
|
||||||
r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name,
|
|
||||||
r.description, r.status, r.clerk_name AS handled_by,
|
|
||||||
r.manager_name AS approved_by, NULL AS txn_id, r.submitted_at, r.actioned_at,
|
|
||||||
""" + verifyCols + """
|
|
||||||
FROM m_clerk_disbursement_request r
|
|
||||||
""" + verifyJoin + """
|
|
||||||
WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
|
|
||||||
""", this::mapDayBookRow, txnDate));
|
|
||||||
|
|
||||||
// Cash taken at the counter for a Demand Draft — a receipt, so the credit side.
|
|
||||||
//
|
|
||||||
// The Sponsor DD register is not one of the three above and has no voucher_no of its own, so
|
|
||||||
// the day book showed nothing for it: a cashier who took ₹111.80 in notes for a draft saw a
|
|
||||||
// day book with an empty CR column and no sign of the money. The voucher is joined the other
|
|
||||||
// way round, from m_voucher.reference_id, which is where the link actually lives.
|
|
||||||
//
|
|
||||||
// Only the cash-funded drafts appear, and only those the cashier took the notes for
|
|
||||||
// directly: an account-debit or cheque draft never crosses the counter, and one funded from
|
|
||||||
// a cashier deposit request is already listed above under that register's own row.
|
|
||||||
rows.addAll(jdbcTemplate.query("""
|
|
||||||
SELECT v.voucher_no, 'CASHIER' AS section, 'CR' AS side, 'DD CASH' AS kind,
|
|
||||||
NULL AS account_no, 'GL' AS account_type,
|
|
||||||
COALESCE(r.walkin_purchaser_name, r.beneficiary_name) AS party,
|
|
||||||
r.face_value + COALESCE(r.charges_amount, 0) + COALESCE(r.gst_amount, 0) AS amount,
|
|
||||||
'CASH' AS payment_type_name,
|
|
||||||
CONCAT('DD for ', r.beneficiary_name) AS description,
|
|
||||||
r.status, mk.username AS handled_by, ck.username AS approved_by,
|
|
||||||
CAST(r.id AS VARCHAR) AS txn_id,
|
|
||||||
r.created_date AS submitted_at, r.checker_date AS actioned_at,
|
|
||||||
""" + verifyCols + """
|
|
||||||
FROM m_sponsor_dd_issue_request r
|
|
||||||
LEFT JOIN m_appuser mk ON mk.id = r.maker_id
|
|
||||||
LEFT JOIN m_appuser ck ON ck.id = r.checker_id
|
|
||||||
LEFT JOIN m_voucher v ON v.reference_type = 'SPONSOR_DD_ISSUE_REQUEST'
|
|
||||||
AND v.reference_id = r.id AND v.voucher_no LIKE 'CR/%'
|
|
||||||
""" + verifyJoin.replace("r.voucher_no", "v.voucher_no") + """
|
|
||||||
WHERE r.payment_method = 'CASH' AND r.cashier_deposit_request_id IS NULL
|
|
||||||
AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE)
|
|
||||||
""", this::mapDayBookRow, txnDate));
|
|
||||||
|
|
||||||
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
|
|
||||||
// ordered as one result set, and the row count for a single day is small.
|
|
||||||
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
|
|
||||||
final String statusFilter = status == null || status.isBlank() || "ALL".equalsIgnoreCase(status) ? null : status.toUpperCase();
|
|
||||||
|
|
||||||
final List<Map<String, Object>> filtered = new ArrayList<>();
|
|
||||||
BigDecimal creditTotal = BigDecimal.ZERO;
|
|
||||||
BigDecimal debitTotal = BigDecimal.ZERO;
|
|
||||||
|
|
||||||
for (final Map<String, Object> row : rows) {
|
|
||||||
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (statusFilter != null && !statusFilter.equalsIgnoreCase((String) row.get("status"))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
filtered.add(row);
|
|
||||||
|
|
||||||
// Only approved money is real money: a pending or rejected voucher must not inflate the
|
|
||||||
// day's totals, even though it still belongs in the listing. The source registers don't
|
|
||||||
// share one terminal status string: deposits and disbursements land on APPROVED, a clerk
|
|
||||||
// withdrawal's finished state is WITHDRAWN, and an account closure's is CLOSED -- missing
|
|
||||||
// any of them meant a completed transaction moved real money out yet never showed up in
|
|
||||||
// the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting
|
|
||||||
// both states cannot double-count it; only 'CLOSED' is unique to that register.
|
|
||||||
final String rowStatus = (String) row.get("status");
|
|
||||||
if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
|
||||||
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) {
|
|
||||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
|
||||||
if (amount != null) {
|
|
||||||
if ("CR".equals(row.get("side"))) {
|
|
||||||
creditTotal = creditTotal.add(amount);
|
|
||||||
} else {
|
|
||||||
debitTotal = debitTotal.add(amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered.sort((a, b) -> {
|
|
||||||
final Object left = b.get("submittedAt");
|
|
||||||
final Object right = a.get("submittedAt");
|
|
||||||
if (left == null && right == null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (left == null) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (right == null) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return left.toString().compareTo(right.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
final Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
result.put("date", txnDate);
|
|
||||||
result.put("rows", filtered);
|
|
||||||
result.put("count", filtered.size());
|
|
||||||
// Counter figures: the credit and debit the customer side of the day adds up to.
|
|
||||||
result.put("creditTotal", creditTotal);
|
|
||||||
result.put("debitTotal", debitTotal);
|
|
||||||
result.put("netTotal", creditTotal.subtract(debitTotal));
|
|
||||||
return gson.toJson(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
|
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
|
||||||
row.put("voucherNo", rs.getString("voucher_no"));
|
|
||||||
// Which part of the day's work this row belongs to. The registers do not divide along
|
|
||||||
// maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says
|
|
||||||
// where the entry came from, not who typed it.
|
|
||||||
row.put("section", rs.getString("section"));
|
|
||||||
row.put("side", rs.getString("side"));
|
|
||||||
row.put("kind", rs.getString("kind"));
|
|
||||||
row.put("accountNo", rs.getString("account_no"));
|
|
||||||
row.put("accountType", rs.getString("account_type"));
|
|
||||||
row.put("party", rs.getString("party"));
|
|
||||||
row.put("amount", rs.getBigDecimal("amount"));
|
|
||||||
row.put("paymentTypeName", rs.getString("payment_type_name"));
|
|
||||||
row.put("description", rs.getString("description"));
|
|
||||||
row.put("status", rs.getString("status"));
|
|
||||||
row.put("handledBy", rs.getString("handled_by"));
|
|
||||||
row.put("approvedBy", rs.getString("approved_by"));
|
|
||||||
row.put("txnId", rs.getString("txn_id"));
|
|
||||||
|
|
||||||
// Physical-slip check. verifyStatus is null until someone actually looks at the paper.
|
|
||||||
row.put("verifyStatus", rs.getString("verify_status"));
|
|
||||||
row.put("slipAmount", rs.getBigDecimal("slip_amount"));
|
|
||||||
row.put("verifyRemarks", rs.getString("verify_remarks"));
|
|
||||||
row.put("verifiedByName", rs.getString("verified_by_name"));
|
|
||||||
final java.sql.Timestamp verifiedAt = rs.getTimestamp("verified_at");
|
|
||||||
row.put("verifiedAt", verifiedAt == null ? null : verifiedAt.toLocalDateTime().toString());
|
|
||||||
|
|
||||||
// How the exception ended, if it has. Without this the day book keeps showing a closed
|
|
||||||
// exception as though it were still open — the flag never clears on screen.
|
|
||||||
row.put("resolutionStatus", rs.getString("resolution_status"));
|
|
||||||
row.put("resolutionNotes", rs.getString("resolution_notes"));
|
|
||||||
row.put("resolvedByName", rs.getString("resolved_by_name"));
|
|
||||||
final java.sql.Timestamp resolvedAt = rs.getTimestamp("resolved_at");
|
|
||||||
row.put("resolvedAt", resolvedAt == null ? null : resolvedAt.toLocalDateTime().toString());
|
|
||||||
row.put("attachmentCount", rs.getInt("attachment_count"));
|
|
||||||
// Emitted as plain ISO strings: Gson serialises java.sql.Timestamp into a locale-shaped
|
|
||||||
// form the browser cannot parse reliably.
|
|
||||||
final java.sql.Timestamp submitted = rs.getTimestamp("submitted_at");
|
|
||||||
final java.sql.Timestamp actioned = rs.getTimestamp("actioned_at");
|
|
||||||
row.put("submittedAt", submitted == null ? null : submitted.toLocalDateTime().toString());
|
|
||||||
row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
@GET
|
@GET
|
||||||
@Path("closing")
|
@Path("closing")
|
||||||
public String closing(@QueryParam("date") String date) {
|
public String closing(@QueryParam("date") String date) {
|
||||||
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
||||||
|
|
||||||
// A closing report for a cashier is that one drawer, so it is scoped the way `excess` and
|
|
||||||
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
|
|
||||||
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
|
|
||||||
|
|
||||||
// The predicate goes through m_appuser because m_savings_txn_denomination records the acting
|
|
||||||
// user in officer_id, not the m_cashiers row -- the same join the position query uses.
|
|
||||||
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
|
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
|
||||||
|
|
||||||
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
|
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
|
||||||
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
||||||
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, "
|
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, "
|
||||||
@@ -342,16 +45,10 @@ public class CashReportApiResource {
|
|||||||
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
|
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
|
||||||
+ "FROM m_savings_txn_denomination std "
|
+ "FROM m_savings_txn_denomination std "
|
||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
|
||||||
+ "WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
|
+ "WHERE std.txn_date = CAST(? AS DATE) "
|
||||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
|
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
|
||||||
+ "ORDER BY std.denomination_value DESC, dm.kind";
|
+ "ORDER BY std.denomination_value DESC, dm.kind";
|
||||||
|
|
||||||
final List<Object> args = new ArrayList<>();
|
|
||||||
args.add(txnDate);
|
|
||||||
if (scopedCashierId != null) {
|
|
||||||
args.add(scopedCashierId);
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
|
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||||
long depositCount = rs.getLong("deposit_count");
|
long depositCount = rs.getLong("deposit_count");
|
||||||
long withdrawalCount = rs.getLong("withdrawal_count");
|
long withdrawalCount = rs.getLong("withdrawal_count");
|
||||||
@@ -368,7 +65,7 @@ public class CashReportApiResource {
|
|||||||
row.put("netCount", depositCount - withdrawalCount);
|
row.put("netCount", depositCount - withdrawalCount);
|
||||||
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
|
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
|
||||||
return row;
|
return row;
|
||||||
}, args.toArray());
|
}, txnDate);
|
||||||
|
|
||||||
BigDecimal depositTotal = BigDecimal.ZERO;
|
BigDecimal depositTotal = BigDecimal.ZERO;
|
||||||
BigDecimal withdrawalTotal = BigDecimal.ZERO;
|
BigDecimal withdrawalTotal = BigDecimal.ZERO;
|
||||||
@@ -378,7 +75,6 @@ public class CashReportApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cash exchange rows — separate section (denomination swap, net monetary = 0)
|
// Cash exchange rows — separate section (denomination swap, net monetary = 0)
|
||||||
final String exchangeCashierFilter = scopedCashierId != null ? "AND ce.cashier_id = ? " : "";
|
|
||||||
final String exchangeSql = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
final String exchangeSql = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
|
||||||
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE 0 END) AS received_count, "
|
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE 0 END) AS received_count, "
|
||||||
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.total_amount ELSE 0 END) AS received_amount, "
|
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.total_amount ELSE 0 END) AS received_amount, "
|
||||||
@@ -388,16 +84,10 @@ public class CashReportApiResource {
|
|||||||
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
|
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
|
||||||
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
|
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
|
||||||
+ "WHERE ce.exchange_date = CAST(? AS DATE) "
|
+ "WHERE ce.exchange_date = CAST(? AS DATE) "
|
||||||
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) " + exchangeCashierFilter
|
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
|
||||||
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind "
|
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind "
|
||||||
+ "ORDER BY ced.denomination_value DESC, dm.kind";
|
+ "ORDER BY ced.denomination_value DESC, dm.kind";
|
||||||
|
|
||||||
final List<Object> exArgs = new ArrayList<>();
|
|
||||||
exArgs.add(txnDate);
|
|
||||||
if (scopedCashierId != null) {
|
|
||||||
exArgs.add(scopedCashierId);
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> {
|
final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> {
|
||||||
long receivedCount = rs.getLong("received_count");
|
long receivedCount = rs.getLong("received_count");
|
||||||
long givenCount = rs.getLong("given_count");
|
long givenCount = rs.getLong("given_count");
|
||||||
@@ -414,96 +104,18 @@ public class CashReportApiResource {
|
|||||||
row.put("netCount", receivedCount - givenCount);
|
row.put("netCount", receivedCount - givenCount);
|
||||||
row.put("netAmount", receivedAmount.subtract(givenAmount));
|
row.put("netAmount", receivedAmount.subtract(givenAmount));
|
||||||
return row;
|
return row;
|
||||||
}, exArgs.toArray());
|
}, txnDate);
|
||||||
|
|
||||||
// Cash taken over the counter for a Demand Draft.
|
|
||||||
//
|
|
||||||
// A cash DD asks for a denomination breakdown but does not insist on one, so most are
|
|
||||||
// recorded with none at all -- and the whole of this report is built from breakdowns. The
|
|
||||||
// money is real: the ledger holds it (Cash at Vault is debited when the notes are taken)
|
|
||||||
// and the cashier is holding the notes, yet the drawer figure omitted every rupee of it.
|
|
||||||
//
|
|
||||||
// Counted from the request itself rather than from a breakdown, so it appears whether or not
|
|
||||||
// the notes were itemised. Excluded are DDs funded through a cashier deposit request: that
|
|
||||||
// register counted the same notes already, and adding them here would count them twice.
|
|
||||||
final String ddCashierFilter = scopedCashierId != null ? "AND dd.maker_id IN ("
|
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
|
|
||||||
final String ddSql = "SELECT COALESCE(SUM(dd.face_value + COALESCE(dd.charges_amount, 0) "
|
|
||||||
+ "+ COALESCE(dd.gst_amount, 0)), 0) AS cash_total, COUNT(*) AS cash_count "
|
|
||||||
+ "FROM m_sponsor_dd_issue_request dd "
|
|
||||||
+ "WHERE dd.payment_method = 'CASH' AND dd.cashier_deposit_request_id IS NULL "
|
|
||||||
+ "AND dd.status <> 'REJECTED' "
|
|
||||||
+ "AND COALESCE(dd.issued_date, CAST(dd.created_date AS DATE)) = CAST(? AS DATE) " + ddCashierFilter;
|
|
||||||
|
|
||||||
final List<Object> ddArgs = new ArrayList<>();
|
|
||||||
ddArgs.add(txnDate);
|
|
||||||
if (scopedCashierId != null) {
|
|
||||||
ddArgs.add(scopedCashierId);
|
|
||||||
}
|
|
||||||
|
|
||||||
BigDecimal ddCashTotal = BigDecimal.ZERO;
|
|
||||||
long ddCashCount = 0;
|
|
||||||
try {
|
|
||||||
final Map<String, Object> ddRow = jdbcTemplate.queryForMap(ddSql, ddArgs.toArray());
|
|
||||||
ddCashTotal = (BigDecimal) ddRow.get("cash_total");
|
|
||||||
ddCashCount = ((Number) ddRow.get("cash_count")).longValue();
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
// A tenant without the Sponsor DD tables still gets its closing report.
|
|
||||||
log.warn("Could not read cash Demand Draft receipts for {}", txnDate, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.put("date", txnDate);
|
result.put("date", txnDate);
|
||||||
result.put("rows", rows);
|
result.put("rows", rows);
|
||||||
// The denomination-wise total, which is what the rows below add up to.
|
|
||||||
result.put("depositTotal", depositTotal);
|
result.put("depositTotal", depositTotal);
|
||||||
result.put("withdrawalTotal", withdrawalTotal);
|
result.put("withdrawalTotal", withdrawalTotal);
|
||||||
// Reported separately as well, so a screen can show where the difference comes from rather
|
result.put("netTotal", depositTotal.subtract(withdrawalTotal));
|
||||||
// than presenting a deposit total its own rows do not explain.
|
|
||||||
result.put("ddCashTotal", ddCashTotal);
|
|
||||||
result.put("ddCashCount", ddCashCount);
|
|
||||||
result.put("depositTotalWithDd", depositTotal.add(ddCashTotal));
|
|
||||||
result.put("netTotal", depositTotal.add(ddCashTotal).subtract(withdrawalTotal));
|
|
||||||
result.put("exchangeRows", exchangeRows);
|
result.put("exchangeRows", exchangeRows);
|
||||||
result.put("cashierId", scopedCashierId);
|
|
||||||
result.put("scopedToCashier", scopedCashierId != null);
|
|
||||||
|
|
||||||
// Opening and closing cash in the drawer.
|
|
||||||
//
|
|
||||||
// netTotal above is only the day's counter movement, which is not what the cashier is
|
|
||||||
// holding. A closing figure has to carry the previous balance forward, so it is derived from
|
|
||||||
// the cumulative position -- vault allocations, settlements, inter-cashier legs and
|
|
||||||
// exchanges as well as the counter work -- read once as of the day before and once as of the
|
|
||||||
// day itself. The gap between the two is the day's entire cash movement, a wider figure than
|
|
||||||
// deposits minus withdrawals, which is why both are reported rather than one derived from
|
|
||||||
// the other: when they differ, the difference is vault and inter-cashier traffic.
|
|
||||||
//
|
|
||||||
// Only a cashier-scoped call can answer this. A branch-wide opening balance belongs to the
|
|
||||||
// branch day register, not to this report.
|
|
||||||
if (scopedCashierId != null) {
|
|
||||||
final BigDecimal openingBalance = positionTotalAsOf(scopedCashierId,
|
|
||||||
LocalDate.parse(txnDate).minusDays(1).toString());
|
|
||||||
final BigDecimal closingBalance = positionTotalAsOf(scopedCashierId, txnDate);
|
|
||||||
result.put("openingBalance", openingBalance);
|
|
||||||
result.put("closingBalance", closingBalance);
|
|
||||||
result.put("cashMovement", closingBalance.subtract(openingBalance));
|
|
||||||
}
|
|
||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* What one cashier is holding at the end of a given date, in rupees.
|
|
||||||
*
|
|
||||||
* Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing
|
|
||||||
* report and the position screen can never disagree about the same drawer.
|
|
||||||
*/
|
|
||||||
private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) {
|
|
||||||
final Map<String, Object> position = buildPositionForCashier(cashierId, asOfDate);
|
|
||||||
final Object total = position.get("totalAmount");
|
|
||||||
return total instanceof BigDecimal ? (BigDecimal) total : BigDecimal.ZERO;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("excess")
|
@Path("excess")
|
||||||
public String excess(@QueryParam("threshold") BigDecimal threshold,
|
public String excess(@QueryParam("threshold") BigDecimal threshold,
|
||||||
@@ -748,30 +360,6 @@ public class CashReportApiResource {
|
|||||||
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
|
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /cashreports/vault-position?tellerId=X&asOfDate=yyyy-MM-dd
|
|
||||||
* Denomination-wise stock a vault (teller) is currently holding — "how much of what does the
|
|
||||||
* bank have", as opposed to cashier-position which answers the same question for a cashier.
|
|
||||||
* See {@link VaultPositionService} for how the anchor is chosen.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("vault-position")
|
|
||||||
public String vaultPosition(@QueryParam("tellerId") final Long tellerId,
|
|
||||||
@QueryParam("asOfDate") final String asOfDate) {
|
|
||||||
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate
|
|
||||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
|
|
||||||
|
|
||||||
if (tellerId == null) {
|
|
||||||
final List<Long> tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class);
|
|
||||||
final List<Map<String, Object>> all = new ArrayList<>();
|
|
||||||
for (final Long id : tellerIds) {
|
|
||||||
all.add(vaultPositionService.buildPositionForVault(id, dateFilter));
|
|
||||||
}
|
|
||||||
return gson.toJson(all);
|
|
||||||
}
|
|
||||||
return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
|
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
|
||||||
* Returns denomination-wise cash received by the cashier from vault on the given date
|
* Returns denomination-wise cash received by the cashier from vault on the given date
|
||||||
@@ -850,7 +438,7 @@ public class CashReportApiResource {
|
|||||||
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
|
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||||
|
|
||||||
// Settlements back to vault
|
// Settlements back to vault
|
||||||
@@ -876,7 +464,7 @@ public class CashReportApiResource {
|
|||||||
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
|
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
|
||||||
+ "AND std.officer_id IN ("
|
+ "AND std.officer_id IN ("
|
||||||
+ " SELECT au.id FROM m_appuser au "
|
+ " SELECT au.id FROM m_appuser au "
|
||||||
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
|
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
|
||||||
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
|
||||||
|
|
||||||
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
|
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
|
||||||
@@ -1009,44 +597,9 @@ public class CashReportApiResource {
|
|||||||
baseParams = new Object[] { txnDate };
|
baseParams = new Object[] { txnDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base grouped query (reused for count, totals, and paged data). The two LEFT JOINs resolve
|
// Base grouped query (reused for count, totals, and paged data)
|
||||||
// the real, formatted voucher number (e.g. "CR/001/2908/0001") stamped on the deposit/
|
|
||||||
// withdrawal request at approval time -- std.txn_id on its own is only the internal savings
|
|
||||||
// transaction id (e.g. "4"), never what was actually printed on the voucher.
|
|
||||||
//
|
|
||||||
// A GL-type deposit's own txn_id is a hex string (e.g. "a29ff54dc88f", from a journal-entry
|
|
||||||
// reference) rather than a plain decimal savings-transaction id, but std.txn_id is always
|
|
||||||
// stored as the DECIMAL parse of whatever id it was given -- so for a GL row it holds the
|
|
||||||
// hex string's numeric value (178807899015311), not the hex string itself, and a plain
|
|
||||||
// string-equality join against cdr.txn_id can never match it. The second half of each OR
|
|
||||||
// below re-derives that same decimal value from the hex string to match it -- guarded to
|
|
||||||
// only run on strings that are actually hex (so an ordinary decimal txn_id, which never
|
|
||||||
// contains a-f, is never sent through the hex cast at all).
|
|
||||||
final String baseGroupSql = "FROM m_savings_txn_denomination std "
|
final String baseGroupSql = "FROM m_savings_txn_denomination std "
|
||||||
+ "LEFT JOIN m_client mc ON mc.id = std.client_id "
|
+ "LEFT JOIN m_client mc ON mc.id = std.client_id "
|
||||||
// A GL-type row (no client_id) carries the GL account's own code in account_no
|
|
||||||
// rather than a savings/current account number, so its "name" has to come from the
|
|
||||||
// chart of accounts, not from a client that was never party to the transaction.
|
|
||||||
+ "LEFT JOIN acc_gl_account gla ON std.client_id IS NULL AND gla.gl_code = std.account_no "
|
|
||||||
// Scoped to std.txn_type: without it, a deposit's hex txn_id and a withdrawal's
|
|
||||||
// decimal txn_id can coincidentally hex-decode to the exact same number (confirmed
|
|
||||||
// live -- a2a0888f2bcf and a genuine withdrawal reference both resolved to
|
|
||||||
// 178810369551311), and an unscoped join matched the withdrawal row to that unrelated
|
|
||||||
// deposit's voucher, which COALESCE then preferred over the withdrawal's own real one.
|
|
||||||
+ "LEFT JOIN m_cashier_deposit_request cdr ON std.txn_type = 'DEPOSIT' AND "
|
|
||||||
+ " (cdr.txn_id = CAST(std.txn_id AS VARCHAR) "
|
|
||||||
+ " OR (cdr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cdr.txn_id ~ '[a-fA-F]' "
|
|
||||||
+ " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
|
|
||||||
+ "LEFT JOIN m_clerk_withdrawal_request cwr ON std.txn_type = 'WITHDRAWAL' AND "
|
|
||||||
+ " (cwr.txn_id = CAST(std.txn_id AS VARCHAR) "
|
|
||||||
+ " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' "
|
|
||||||
+ " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) "
|
|
||||||
// An account closure also writes its hand-over as a 'WITHDRAWAL' denomination row
|
|
||||||
// (the column records which way cash moved), but its request lives in its own
|
|
||||||
// register -- without this join the closure's real voucher number is never found and
|
|
||||||
// the report falls back to showing the bare savings-transaction id instead.
|
|
||||||
+ "LEFT JOIN m_clerk_closure_request ccr ON std.txn_type = 'WITHDRAWAL' AND "
|
|
||||||
+ " ccr.txn_id = CAST(std.txn_id AS VARCHAR) "
|
|
||||||
+ "WHERE std.txn_date = CAST(? AS DATE) "
|
+ "WHERE std.txn_date = CAST(? AS DATE) "
|
||||||
+ officerFilter
|
+ officerFilter
|
||||||
+ " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no ";
|
+ " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no ";
|
||||||
@@ -1067,10 +620,9 @@ public class CashReportApiResource {
|
|||||||
|
|
||||||
// 2. Paged data query
|
// 2. Paged data query
|
||||||
final String dataSql = "SELECT "
|
final String dataSql = "SELECT "
|
||||||
+ " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), "
|
+ " std.txn_id AS voucher_no, "
|
||||||
+ " CAST(std.txn_id AS VARCHAR)) AS voucher_no, "
|
|
||||||
+ " std.account_no AS ac_no, "
|
+ " std.account_no AS ac_no, "
|
||||||
+ " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, "
|
+ " COALESCE(mc.display_name, std.cif_no, '') AS ac_name, "
|
||||||
+ " MAX(std.txn_date) AS txn_date, "
|
+ " MAX(std.txn_date) AS txn_date, "
|
||||||
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
+ " TO_CHAR(MAX(std.created_at), 'HH24:MI:SS') AS txn_time, "
|
||||||
+ " MAX(std.officer_username) AS teller_name, "
|
+ " MAX(std.officer_username) AS teller_name, "
|
||||||
@@ -1090,7 +642,7 @@ public class CashReportApiResource {
|
|||||||
|
|
||||||
final List<Map<String, Object>> pageItems = jdbcTemplate.query(dataSql, (rs, rowNum) -> {
|
final List<Map<String, Object>> pageItems = jdbcTemplate.query(dataSql, (rs, rowNum) -> {
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("voucherNo", rs.getString("voucher_no"));
|
row.put("voucherNo", rs.getLong("voucher_no"));
|
||||||
row.put("acNo", rs.getString("ac_no"));
|
row.put("acNo", rs.getString("ac_no"));
|
||||||
row.put("acName", rs.getString("ac_name"));
|
row.put("acName", rs.getString("ac_name"));
|
||||||
row.put("txnDate", rs.getString("txn_date"));
|
row.put("txnDate", rs.getString("txn_date"));
|
||||||
|
|||||||
-169
@@ -1,169 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.cashreport.service;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Denomination-wise cash a vault (teller) is currently holding.
|
|
||||||
*
|
|
||||||
* Anchored on the most recent business day (on or before the target date) whose branch day
|
|
||||||
* register recorded a vault-only opening breakdown ({@code OPENING_VAULT}, tagged with this exact
|
|
||||||
* teller), then walked forward by everything settled into this vault and handed out of it since.
|
|
||||||
* Vault-only rows exist separately from the branch-total {@code OPENING}/{@code CLOSING} rows
|
|
||||||
* (which mix the vault with whatever every cashier under it is holding) precisely so this anchor
|
|
||||||
* never includes cash that still physically sits with a cashier — mixing the two double-counts
|
|
||||||
* whatever a cashier settles back later, once already folded into the branch total, again as the
|
|
||||||
* settlement itself. A day recorded before vault-only tracking existed has no {@code OPENING_VAULT}
|
|
||||||
* row at all; the branch-total {@code OPENING} is used for it as a last resort so an old anchor
|
|
||||||
* still returns something rather than zero.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class VaultPositionService {
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
// Row mapper: returns Object[]{Long denomId, Integer denomValue, String kind, Long count}
|
|
||||||
private static final RowMapper<Object[]> DENOM_ROW = (rs, r) -> new Object[] { rs.getLong("denomination_id"),
|
|
||||||
rs.getInt("denomination_value"), rs.getString("kind"), rs.getLong("total_count") };
|
|
||||||
|
|
||||||
public Map<String, Object> buildPositionForVault(final Long tellerId, final String dateFilter) {
|
|
||||||
final Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
result.put("tellerId", tellerId);
|
|
||||||
result.put("asOfDate", dateFilter);
|
|
||||||
|
|
||||||
String tellerName = null;
|
|
||||||
Long officeId = null;
|
|
||||||
try {
|
|
||||||
final Map<String, Object> teller = jdbcTemplate.queryForMap("SELECT name, office_id FROM m_tellers WHERE id = ?",
|
|
||||||
tellerId);
|
|
||||||
tellerName = (String) teller.get("name");
|
|
||||||
officeId = ((Number) teller.get("office_id")).longValue();
|
|
||||||
} catch (final Exception ignored) {
|
|
||||||
// intentionally ignored
|
|
||||||
}
|
|
||||||
result.put("tellerName", tellerName);
|
|
||||||
|
|
||||||
if (officeId == null) {
|
|
||||||
result.put("hasBreakdown", false);
|
|
||||||
result.put("totalAmount", BigDecimal.ZERO);
|
|
||||||
result.put("denominations", new ArrayList<>());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer a vault-scoped anchor: it already reflects only this vault's own cash, carried
|
|
||||||
// forward day to day, with no cashier's held cash folded in. Fall back to the old
|
|
||||||
// branch-combined OPENING leg only when this office has no vault-scoped row at all on or
|
|
||||||
// before the target date (a day closed before vault-only tracking existed).
|
|
||||||
List<Map<String, Object>> anchorRows = jdbcTemplate.queryForList("""
|
|
||||||
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
|
|
||||||
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE)
|
|
||||||
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
|
|
||||||
WHERE d.register_id = r.id AND d.leg = 'OPENING_VAULT' AND d.teller_id = ?)
|
|
||||||
ORDER BY r.business_date DESC LIMIT 1
|
|
||||||
""", officeId, dateFilter, tellerId);
|
|
||||||
String anchorLeg = "OPENING_VAULT";
|
|
||||||
if (anchorRows.isEmpty()) {
|
|
||||||
anchorLeg = "OPENING";
|
|
||||||
anchorRows = jdbcTemplate.queryForList("""
|
|
||||||
SELECT r.id AS register_id, r.business_date FROM m_branch_day_register r
|
|
||||||
WHERE r.office_id = ? AND r.business_date <= CAST(? AS DATE)
|
|
||||||
AND EXISTS (SELECT 1 FROM m_branch_day_register_detail d
|
|
||||||
WHERE d.register_id = r.id AND d.leg = 'OPENING')
|
|
||||||
ORDER BY r.business_date DESC LIMIT 1
|
|
||||||
""", officeId, dateFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anchorRows.isEmpty()) {
|
|
||||||
result.put("hasBreakdown", false);
|
|
||||||
result.put("totalAmount", BigDecimal.ZERO);
|
|
||||||
result.put("denominations", new ArrayList<>());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long registerId = ((Number) anchorRows.get(0).get("register_id")).longValue();
|
|
||||||
final String anchorDate = anchorRows.get(0).get("business_date").toString();
|
|
||||||
|
|
||||||
final Map<Long, long[]> balance = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
addToBalance(balance, jdbcTemplate.query("""
|
|
||||||
SELECT denomination_id, denomination_value, COALESCE(dm.kind, 'note') AS kind, count AS total_count
|
|
||||||
FROM m_branch_day_register_detail d LEFT JOIN m_denomination_master dm ON dm.id = d.denomination_id
|
|
||||||
WHERE d.register_id = ? AND d.leg = ? AND (d.teller_id IS NULL OR d.teller_id = ?)
|
|
||||||
""", DENOM_ROW, registerId, anchorLeg, tellerId), 1);
|
|
||||||
|
|
||||||
addToBalance(balance, jdbcTemplate.query("""
|
|
||||||
SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count
|
|
||||||
FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
|
|
||||||
LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id
|
|
||||||
WHERE cs.to_teller_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false)
|
|
||||||
AND cs.settlement_date >= CAST(? AS DATE) AND cs.settlement_date <= CAST(? AS DATE)
|
|
||||||
GROUP BY csd.denomination_id, csd.denomination_value, dm.kind
|
|
||||||
""", DENOM_ROW, tellerId, anchorDate, dateFilter), 1);
|
|
||||||
|
|
||||||
addToBalance(balance, jdbcTemplate.query("""
|
|
||||||
SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count
|
|
||||||
FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id
|
|
||||||
LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id
|
|
||||||
WHERE ct.from_teller_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false)
|
|
||||||
AND ct.transfer_date >= CAST(? AS DATE) AND ct.transfer_date <= CAST(? AS DATE)
|
|
||||||
GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind
|
|
||||||
""", DENOM_ROW, tellerId, anchorDate, dateFilter), -1);
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
|
||||||
balance.entrySet().stream().sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])).forEach(e -> {
|
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
|
||||||
row.put("denominationId", e.getKey());
|
|
||||||
row.put("denominationValue", (int) e.getValue()[0]);
|
|
||||||
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
|
|
||||||
row.put("count", e.getValue()[2]);
|
|
||||||
row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
|
|
||||||
rows.add(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
final BigDecimal totalAmount = rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO,
|
|
||||||
BigDecimal::add);
|
|
||||||
|
|
||||||
result.put("hasBreakdown", true);
|
|
||||||
result.put("anchorDate", anchorDate);
|
|
||||||
result.put("totalAmount", totalAmount);
|
|
||||||
result.put("denominations", rows);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addToBalance(final Map<Long, long[]> balance, final List<Object[]> rows, final int sign) {
|
|
||||||
for (final Object[] row : rows) {
|
|
||||||
final Long denomId = (Long) row[0];
|
|
||||||
final int denomValue = (Integer) row[1];
|
|
||||||
final long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
|
|
||||||
final long count = (Long) row[3];
|
|
||||||
final long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
|
|
||||||
entry[2] += count * sign;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-5
@@ -22,7 +22,6 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -32,8 +31,6 @@ import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
|
|||||||
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
|
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
|
||||||
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
|
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
|
||||||
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
@@ -44,7 +41,6 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|||||||
@Path("/v1/cashsettlement")
|
@Path("/v1/cashsettlement")
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
public class CashSettlementApiResource {
|
public class CashSettlementApiResource {
|
||||||
@@ -56,7 +52,6 @@ public class CashSettlementApiResource {
|
|||||||
private final PlatformTransactionManager transactionManager;
|
private final PlatformTransactionManager transactionManager;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final CashierRoleHelper cashierRoleHelper;
|
private final CashierRoleHelper cashierRoleHelper;
|
||||||
private final VoucherService voucherService;
|
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
|
|||||||
-123
@@ -98,8 +98,6 @@ public class CashTransferApiResource {
|
|||||||
"At least one denomination with a positive count is required.");
|
"At least one denomination with a positive count is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
|
|
||||||
|
|
||||||
CashTransfer header = new CashTransfer();
|
CashTransfer header = new CashTransfer();
|
||||||
header.setTransferDate(transferDate);
|
header.setTransferDate(transferDate);
|
||||||
header.setFromParty(fromParty);
|
header.setFromParty(fromParty);
|
||||||
@@ -266,127 +264,6 @@ public class CashTransferApiResource {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A vault can only hand out cash it is actually holding — in total, and note-by-note. The
|
|
||||||
* branch's business day carries an opening_cash figure forward each day (see
|
|
||||||
* BranchDayRegisterApiResource#lastClosingCash), so the vault's available balance for "today" is
|
|
||||||
* that opening figure, plus anything a cashier has already settled back into this same vault
|
|
||||||
* today, minus whatever this vault has already handed out to any cashier today. Reversed
|
|
||||||
* transfers/settlements don't count.
|
|
||||||
*
|
|
||||||
* No open business day for the vault's office means there is no opening_cash to check against, so
|
|
||||||
* the transfer is blocked outright rather than silently allowed against an unknown balance.
|
|
||||||
*/
|
|
||||||
private void validateVaultHasSufficientCash(final Long fromTellerId, final BigDecimal requestedAmount,
|
|
||||||
final JsonArray denominations, final LocalDate transferDate) {
|
|
||||||
final Long officeId;
|
|
||||||
try {
|
|
||||||
officeId = jdbcTemplate.queryForObject("SELECT office_id FROM m_tellers WHERE id = ?", Long.class, fromTellerId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.teller.not.found",
|
|
||||||
"Vault/teller " + fromTellerId + " was not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate);
|
|
||||||
|
|
||||||
final List<Long> registerIds = jdbcTemplate.query("""
|
|
||||||
SELECT id FROM m_branch_day_register
|
|
||||||
WHERE office_id = ? AND business_date = ? AND closed_at IS NULL
|
|
||||||
""", (rs, n) -> rs.getLong(1), officeId, sqlBusinessDate);
|
|
||||||
if (registerIds.isEmpty()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.branchday.not.open",
|
|
||||||
"The business day for this branch is not open for " + businessDate
|
|
||||||
+ ". Open the business day before allocating cash to a cashier.");
|
|
||||||
}
|
|
||||||
final Long registerId = registerIds.get(0);
|
|
||||||
|
|
||||||
final BigDecimal openingCash = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT COALESCE(opening_cash, 0) FROM m_branch_day_register WHERE id = ?", BigDecimal.class, registerId);
|
|
||||||
|
|
||||||
final BigDecimal handedOutToday = jdbcTemplate.queryForObject("""
|
|
||||||
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
|
|
||||||
WHERE from_teller_id = ? AND transfer_date = ? AND COALESCE(is_reversed, false) = false
|
|
||||||
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
|
|
||||||
|
|
||||||
final BigDecimal settledBackToday = jdbcTemplate.queryForObject("""
|
|
||||||
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
|
|
||||||
WHERE to_teller_id = ? AND settlement_date = ? AND COALESCE(is_reversed, false) = false
|
|
||||||
""", BigDecimal.class, fromTellerId, sqlBusinessDate);
|
|
||||||
|
|
||||||
final BigDecimal available = openingCash.add(settledBackToday).subtract(handedOutToday);
|
|
||||||
if (requestedAmount.compareTo(available) > 0) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.cash",
|
|
||||||
"Insufficient vault cash: available " + available + ", requested " + requestedAmount + ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
validateVaultHasSufficientDenominations(registerId, fromTellerId, denominations, sqlBusinessDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The total-amount check above can pass while a specific note/coin still runs short — a vault
|
|
||||||
* holding ₹1,00,000 entirely in ₹500 notes cannot hand out ₹50,000 in ₹10 notes even though the
|
|
||||||
* total is well within its balance. This checks each requested denomination against what the
|
|
||||||
* vault's own opening breakdown (m_branch_day_register_detail, leg='OPENING') plus today's
|
|
||||||
* settlements-in minus today's transfers-out actually holds of that specific denomination.
|
|
||||||
*
|
|
||||||
* A day opened with a plain lump sum and no breakdown recorded has nothing to check a
|
|
||||||
* denomination against, so this is skipped rather than blocking every transfer on a day that
|
|
||||||
* simply never captured one.
|
|
||||||
*/
|
|
||||||
private void validateVaultHasSufficientDenominations(final Long registerId, final Long fromTellerId,
|
|
||||||
final JsonArray denominations, final java.sql.Date businessDate) {
|
|
||||||
final List<Object[]> openingRows = jdbcTemplate.query("""
|
|
||||||
SELECT denomination_id, denomination_value, count FROM m_branch_day_register_detail
|
|
||||||
WHERE register_id = ? AND leg = 'OPENING'
|
|
||||||
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getInt(2), rs.getInt(3) }, registerId);
|
|
||||||
if (openingRows.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<Long, Integer> available = new java.util.HashMap<>();
|
|
||||||
for (final Object[] row : openingRows) {
|
|
||||||
available.merge((Long) row[0], (Integer) row[2], Integer::sum);
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<Object[]> settledRows = jdbcTemplate.query("""
|
|
||||||
SELECT csd.denomination_id, SUM(csd.count) FROM m_cash_settlement_detail csd
|
|
||||||
JOIN m_cash_settlement cs ON cs.id = csd.settlement_id
|
|
||||||
WHERE cs.to_teller_id = ? AND cs.settlement_date = ? AND COALESCE(cs.is_reversed, false) = false
|
|
||||||
GROUP BY csd.denomination_id
|
|
||||||
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate);
|
|
||||||
for (final Object[] row : settledRows) {
|
|
||||||
available.merge((Long) row[0], ((Long) row[1]).intValue(), Integer::sum);
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<Object[]> handedOutRows = jdbcTemplate.query("""
|
|
||||||
SELECT ctd.denomination_id, SUM(ctd.count) FROM m_cash_transfer_detail ctd
|
|
||||||
JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id
|
|
||||||
WHERE ct.from_teller_id = ? AND ct.transfer_date = ? AND COALESCE(ct.is_reversed, false) = false
|
|
||||||
GROUP BY ctd.denomination_id
|
|
||||||
""", (rs, n) -> new Object[] { rs.getLong(1), rs.getLong(2) }, fromTellerId, businessDate);
|
|
||||||
for (final Object[] row : handedOutRows) {
|
|
||||||
available.merge((Long) row[0], -((Long) row[1]).intValue(), Integer::sum);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final JsonElement el : denominations) {
|
|
||||||
final JsonObject item = el.getAsJsonObject();
|
|
||||||
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
|
|
||||||
if (count <= 0 || !item.has("denominationId") || item.get("denominationId").isJsonNull()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final long denominationId = item.get("denominationId").getAsLong();
|
|
||||||
final int denominationValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
|
|
||||||
? item.get("denominationValue").getAsInt() : 0;
|
|
||||||
final int availableCount = available.getOrDefault(denominationId, 0);
|
|
||||||
if (count > availableCount) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.insufficient.vault.denomination",
|
|
||||||
"Insufficient stock of denomination " + denominationValue + ": vault has " + availableCount
|
|
||||||
+ ", requested " + count + ".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean hasPositiveCount(JsonArray items) {
|
private boolean hasPositiveCount(JsonArray items) {
|
||||||
for (JsonElement el : items) {
|
for (JsonElement el : items) {
|
||||||
JsonObject item = el.getAsJsonObject();
|
JsonObject item = el.getAsJsonObject();
|
||||||
|
|||||||
-883
@@ -1,883 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.cashtransfer.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.PathParam;
|
|
||||||
import jakarta.ws.rs.Produces;
|
|
||||||
import jakarta.ws.rs.QueryParam;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import jakarta.ws.rs.core.Response;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.time.format.DateTimeParseException;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
import org.springframework.dao.EmptyResultDataAccessException;
|
|
||||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Share Advance Collection API — cash taken from a member BEFORE any share account exists.
|
|
||||||
*
|
|
||||||
* The cooperative's counter flow is: money first, share account afterwards. This resource covers both halves of that
|
|
||||||
* flow.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* <b>1. Collect</b> — {@code POST /v1/shareadvancecollection}
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* Dr Cash at Vault (SHARES_REFERENCE) = amount
|
|
||||||
* Cr Share Sundry (SHARES_SUSPENSE) = amount
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* The money sits in the Share Sundry liability against a receipt number naming the member, so the unapplied rows in
|
|
||||||
* {@code m_share_advance_collection} always reconcile to the Sundry balance. No share account is involved yet.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* <b>2. Apply</b> — {@code POST /v1/shareadvancecollection/{id}/apply}
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* Dr Share Sundry (SHARES_SUSPENSE) = everything collected
|
|
||||||
* Cr Share Capital (SHARES_EQUITY) = share amount
|
|
||||||
* Cr Income from Fees (INCOME_FROM_FEES) = charge amount (only when a charge applies)
|
|
||||||
* Cr Savings (via deposit cmd) = over-payment (only when the member paid more)
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* Applying clears the liability, issues the share capital, marks the account's charges paid and sets
|
|
||||||
* {@code collection_applied}, which is what reveals the purchase transaction in the passbook and transactions views.
|
|
||||||
* Account creation, approval and activation post no GL of their own — all share accounting happens here.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* <b>When the cash does not match</b>
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A member paying at the counter rarely hands over the exact figure, so neither case is treated as an error to be
|
|
||||||
* argued with at the window:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li><b>Short</b> — blocked. Share capital cannot be part-issued, so the clerk takes the balance on a further receipt
|
|
||||||
* and applies the receipts together: one settlement, several receipts, all marked applied.</li>
|
|
||||||
* <li><b>Over</b> — allowed, and the difference is credited to the member's linked savings account through Fineract's
|
|
||||||
* own deposit command, so it reaches their passbook rather than resting in a suspense GL that no record can attribute
|
|
||||||
* back to them.</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
@Path("/v1/shareadvancecollection")
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
public class ShareAdvanceCollectionApiResource {
|
|
||||||
|
|
||||||
// ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
|
|
||||||
// Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr).
|
|
||||||
private static final int DEBIT = 2;
|
|
||||||
private static final int CREDIT = 1;
|
|
||||||
|
|
||||||
// ── CashAccountsForShares financial_account_type ───────────────────────
|
|
||||||
private static final int FA_SHARES_REFERENCE = 1;
|
|
||||||
private static final int FA_SHARES_SUSPENSE = 2;
|
|
||||||
private static final int FA_INCOME_FROM_FEES = 3;
|
|
||||||
private static final int FA_SHARES_EQUITY = 4;
|
|
||||||
|
|
||||||
// ── acc_product_mapping.product_type for shares ────────────────────────
|
|
||||||
private static final int PRODUCT_TYPE_SHARES = 4;
|
|
||||||
|
|
||||||
// ── m_share_account.status_enum ───────────────────────────────────────
|
|
||||||
private static final int STATUS_ACTIVE = 300;
|
|
||||||
|
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
|
||||||
private final Gson gson = new Gson();
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// POST — Take cash from a member with no share account yet
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
@POST
|
|
||||||
@Transactional
|
|
||||||
public Response collect(final String body) {
|
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
|
||||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
|
||||||
|
|
||||||
final JsonObject json = parseBody(body);
|
|
||||||
final Long clientId = getLong(json, "clientId");
|
|
||||||
final BigDecimal amount = getBigDecimal(json, "amount");
|
|
||||||
final String note = sanitiseNote(getString(json, "note"));
|
|
||||||
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
|
|
||||||
|
|
||||||
if (clientId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.clientid.required", "clientId is required.");
|
|
||||||
}
|
|
||||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.invalid", "amount must be greater than zero.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object> client = loadClient(clientId);
|
|
||||||
final Long officeId = toLong(client.get("office_id"));
|
|
||||||
final String clientName = (String) client.get("display_name");
|
|
||||||
|
|
||||||
// The share product drives the GL mapping. Collection happens before an account exists,
|
|
||||||
// so the product is supplied by the caller or defaults to the only mapped share product.
|
|
||||||
final Long productId = resolveProductId(getLong(json, "productId"));
|
|
||||||
final String currencyCode = resolveCurrency(productId);
|
|
||||||
|
|
||||||
final Long glReference = requireGl(productId, FA_SHARES_REFERENCE, "Cash at Vault (Share Reference)");
|
|
||||||
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
|
||||||
|
|
||||||
final long userId = currentUser.getId();
|
|
||||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
final String txnId = "SHADV-" + System.nanoTime();
|
|
||||||
final String receiptNo = nextReceiptNo();
|
|
||||||
|
|
||||||
final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
|
|
||||||
|
|
||||||
insertEntry(officeId, glReference, DEBIT, amount, txnId, entryDate, null, userId, now, currencyCode, desc);
|
|
||||||
insertEntry(officeId, glSuspense, CREDIT, amount, txnId, entryDate, null, userId, now, currencyCode,
|
|
||||||
"Share advance held in Sundry | Receipt " + receiptNo);
|
|
||||||
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"INSERT INTO m_share_advance_collection "
|
|
||||||
+ "(receipt_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
|
||||||
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
|
||||||
receiptNo, clientId, officeId, java.sql.Date.valueOf(entryDate), amount, currencyCode, txnId, userId,
|
|
||||||
currentUser.getUsername(), note);
|
|
||||||
|
|
||||||
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_share_advance_collection WHERE receipt_no = ?", Long.class,
|
|
||||||
receiptNo);
|
|
||||||
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("collectionId", id);
|
|
||||||
out.put("receiptNo", receiptNo);
|
|
||||||
out.put("clientId", clientId);
|
|
||||||
out.put("clientName", clientName);
|
|
||||||
out.put("amount", amount);
|
|
||||||
out.put("currency", currencyCode);
|
|
||||||
out.put("status", "UNAPPLIED");
|
|
||||||
out.put("glTransactionId", txnId);
|
|
||||||
out.put("message", "Cash received and held in Share Sundry. Quote receipt " + receiptNo + " when the share account is created.");
|
|
||||||
return Response.ok(gson.toJson(out)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// GET — Unapplied receipts, optionally for one client
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
@GET
|
|
||||||
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
|
|
||||||
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
|
|
||||||
+ " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note "
|
|
||||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' ");
|
|
||||||
if (clientId != null) {
|
|
||||||
sql.append(" AND c.client_id = ").append(clientId);
|
|
||||||
}
|
|
||||||
sql.append(" ORDER BY c.collection_date, c.id");
|
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString());
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("unappliedCollections", rows);
|
|
||||||
out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
|
|
||||||
return Response.ok(gson.toJson(out)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// GET — One unapplied receipt by the voucher number quoted by the member
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// The voucher arrives as a query parameter because numbers are formatted CR/2108/0001:
|
|
||||||
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
|
|
||||||
// firewall with a 400 before the request reaches this resource. The older
|
|
||||||
// /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes.
|
|
||||||
@GET
|
|
||||||
@Path("/by-voucher")
|
|
||||||
public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo,
|
|
||||||
@QueryParam("clientId") final Long clientIdHint, @QueryParam("glAccountNo") final String glAccountNoOverride) {
|
|
||||||
if (voucherNo == null || voucherNo.isBlank()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required",
|
|
||||||
"A voucher or receipt number is required.");
|
|
||||||
}
|
|
||||||
return findByVoucher(voucherNo, clientIdHint, glAccountNoOverride);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("/by-voucher/{voucherNo}")
|
|
||||||
public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint,
|
|
||||||
@QueryParam("glAccountNo") final String glAccountNoOverride) {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
|
|
||||||
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate
|
|
||||||
.queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
|
|
||||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id "
|
|
||||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id "
|
|
||||||
+ " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
|
|
||||||
|
|
||||||
if (rows.isEmpty()) {
|
|
||||||
// Cash is normally taken through the cashier's Transaction Entry deposit into the GL
|
|
||||||
// account the counter deposited it to, which lives in m_cashier_deposit_request. Adopt
|
|
||||||
// that approved deposit as a collection so the member's voucher resolves here. Which GL
|
|
||||||
// account to look in is never guessed from product configuration — the counter selects it
|
|
||||||
// by hand every time, since a product mapping can silently go missing or point at the
|
|
||||||
// wrong account and a plain voucher lookup should never depend on that being right.
|
|
||||||
final Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride);
|
|
||||||
if (adopted != null) {
|
|
||||||
return Response.ok(gson.toJson(adopted)).build();
|
|
||||||
}
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher "
|
|
||||||
+ voucherNo + " in GL account " + glAccountNoOverride + ". Check the voucher number, the GL account, and confirm the "
|
|
||||||
+ "deposit has been approved by a manager.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object> row = rows.get(0);
|
|
||||||
if (!"UNAPPLIED".equals(row.get("status"))) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
|
|
||||||
"Voucher " + voucherNo + " is already " + row.get("status") + " and cannot be applied again.");
|
|
||||||
}
|
|
||||||
return Response.ok(gson.toJson(row)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// GET — GL accounts selectable for a by-voucher lookup
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
/**
|
|
||||||
* Every active, postable GL account, for the GL account picker on the Apply Collection screen.
|
|
||||||
*
|
|
||||||
* Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to
|
|
||||||
* point at today — that mapping is exactly what this lookup no longer trusts, since it can go
|
|
||||||
* missing or wrong without anyone noticing until a voucher search fails at the counter. The person
|
|
||||||
* applying a receipt picks the account the deposit actually went to, every time.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("/glaccounts")
|
|
||||||
public Response glAccounts() {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
|
||||||
"SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("glAccounts", rows);
|
|
||||||
return Response.ok(gson.toJson(out)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// GET — What a share account needs before a collection can be applied
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
/**
|
|
||||||
* Share value plus any outstanding entry fee for one share account -- shown up front on the Apply
|
|
||||||
* Collection screen so the person at the counter knows the full amount to collect (and that it may
|
|
||||||
* come in as more than one receipt, e.g. one for the share value and a separate one for the fee)
|
|
||||||
* before a search ever comes up short, rather than learning it only from an error after the fact.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("/requirement")
|
|
||||||
public Response requirement(@QueryParam("shareAccountId") final Long shareAccountId) {
|
|
||||||
this.context.authenticatedUser();
|
|
||||||
if (shareAccountId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
|
|
||||||
}
|
|
||||||
final Map<String, Object> acct = loadAccount(shareAccountId);
|
|
||||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
|
||||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("accountNo", acct.get("account_no"));
|
|
||||||
out.put("shareAmount", shareAmount);
|
|
||||||
out.put("chargeAmount", chargeAmount);
|
|
||||||
out.put("required", shareAmount.add(chargeAmount));
|
|
||||||
return Response.ok(gson.toJson(out)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// POST — Apply a receipt to an activated share account
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
@POST
|
|
||||||
@Path("/{collectionId}/apply")
|
|
||||||
@Transactional
|
|
||||||
public Response apply(@PathParam("collectionId") final Long collectionId, final String body) {
|
|
||||||
|
|
||||||
final AppUser currentUser = this.context.authenticatedUser();
|
|
||||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
|
||||||
|
|
||||||
final JsonObject json = parseBody(body);
|
|
||||||
final Long shareAccountId = getLong(json, "shareAccountId");
|
|
||||||
if (shareAccountId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A member who pays in instalments has more than one receipt against the same application:
|
|
||||||
// ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they
|
|
||||||
// are applied together as one settlement. The path receipt leads; any others come in the
|
|
||||||
// body. These are the SHARE-side receipts only -- money that actually sat in Sundry and
|
|
||||||
// genuinely needs to move out of it into Share Capital.
|
|
||||||
final List<Long> collectionIds = new java.util.ArrayList<>();
|
|
||||||
collectionIds.add(collectionId);
|
|
||||||
for (final Long additionalId : getLongList(json, "additionalCollectionIds")) {
|
|
||||||
if (!collectionIds.contains(additionalId)) {
|
|
||||||
collectionIds.add(additionalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A separately-verified entry-fee receipt, when the member's fee was collected as its own
|
|
||||||
// receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry.
|
|
||||||
// This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL
|
|
||||||
// entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave
|
|
||||||
// null) when the fee was paid as part of the same Sundry receipt(s) as the share value --
|
|
||||||
// that legacy, single-receipt case still needs the fee amount swept out of Sundry below.
|
|
||||||
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
|
|
||||||
|
|
||||||
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
|
|
||||||
for (final Long id : collectionIds) {
|
|
||||||
final Map<String, Object> row = loadCollection(id);
|
|
||||||
if (!"UNAPPLIED".equals(row.get("status"))) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
|
|
||||||
"Receipt " + row.get("receipt_no") + " is already " + row.get("status") + ".");
|
|
||||||
}
|
|
||||||
collections.add(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> chargeColl = null;
|
|
||||||
if (chargeCollectionId != null) {
|
|
||||||
chargeColl = loadCollection(chargeCollectionId);
|
|
||||||
if (!"UNAPPLIED".equals(chargeColl.get("status"))) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
|
|
||||||
"Entry fee receipt " + chargeColl.get("receipt_no") + " is already " + chargeColl.get("status") + ".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object> coll = collections.get(0);
|
|
||||||
|
|
||||||
final Map<String, Object> acct = loadAccount(shareAccountId);
|
|
||||||
|
|
||||||
// Every receipt belongs to the member: applying one to someone else's account would move
|
|
||||||
// one member's money into another's share capital. Checked per receipt, since a settlement
|
|
||||||
// can now carry several.
|
|
||||||
final Long acctClientId = toLong(acct.get("client_id"));
|
|
||||||
for (final Map<String, Object> row : collections) {
|
|
||||||
if (!toLong(row.get("client_id")).equals(acctClientId)) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no")
|
|
||||||
+ " belongs to a different member than share account " + acct.get("account_no") + ".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt "
|
|
||||||
+ chargeColl.get("receipt_no") + " belongs to a different member than share account " + acct.get("account_no") + ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
final int statusEnum = toInt(acct.get("status_enum"));
|
|
||||||
if (statusEnum != STATUS_ACTIVE) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.active",
|
|
||||||
"Share account " + acct.get("account_no") + " must be Active before a collection can be applied.");
|
|
||||||
}
|
|
||||||
if (Boolean.TRUE.equals(acct.get("collection_applied"))) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.already.settled",
|
|
||||||
"Share account " + acct.get("account_no") + " has already been settled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Share value the account was opened for, and any entry fee attached to it.
|
|
||||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
|
||||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
|
||||||
|
|
||||||
// When the fee is being verified as its own receipt, the Sundry-side receipts only need to
|
|
||||||
// cover the share value -- the fee is proved paid separately below, and its money is never
|
|
||||||
// expected to be part of what came out of Sundry. Without a separate fee receipt, the fee is
|
|
||||||
// assumed bundled into the same Sundry receipt(s) as the share value, exactly as before.
|
|
||||||
final BigDecimal requiredForShareReceipts = chargeColl != null ? shareAmount : shareAmount.add(chargeAmount);
|
|
||||||
|
|
||||||
BigDecimal receiptAmount = BigDecimal.ZERO;
|
|
||||||
final StringBuilder receiptNos = new StringBuilder();
|
|
||||||
for (final Map<String, Object> row : collections) {
|
|
||||||
receiptAmount = receiptAmount.add((BigDecimal) row.get("amount"));
|
|
||||||
if (receiptNos.length() > 0) {
|
|
||||||
receiptNos.append(" + ");
|
|
||||||
}
|
|
||||||
receiptNos.append(row.get("receipt_no"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Short is blocked: share capital cannot be part-issued — the member either bought those
|
|
||||||
// shares or did not — so settling for less would leave the account short by an amount owed
|
|
||||||
// by nobody. The clerk collects the balance on another receipt and applies both together.
|
|
||||||
if (receiptAmount.compareTo(requiredForShareReceipts) < 0) {
|
|
||||||
final BigDecimal shortfall = requiredForShareReceipts.subtract(receiptAmount);
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short",
|
|
||||||
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires "
|
|
||||||
+ requiredForShareReceipts + ". Collect the remaining " + shortfall
|
|
||||||
+ " from the member, then apply both receipts together.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The fee receipt, when given, must cover the fee on its own -- checked separately from the
|
|
||||||
// share-side shortfall above, since the two are no longer one combined pot.
|
|
||||||
if (chargeColl != null && chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
final BigDecimal chargeReceiptAmount = (BigDecimal) chargeColl.get("amount");
|
|
||||||
if (chargeReceiptAmount.compareTo(chargeAmount) < 0) {
|
|
||||||
final BigDecimal shortfall = chargeAmount.subtract(chargeReceiptAmount);
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.charge.amount.short",
|
|
||||||
"Entry fee receipt " + chargeColl.get("receipt_no") + " totals " + chargeReceiptAmount + " but share account "
|
|
||||||
+ acct.get("account_no") + " requires " + chargeAmount + " for the entry fee. Collect the remaining "
|
|
||||||
+ shortfall + " from the member.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Over-payment is allowed through rather than turning a member away over a rounded-up note.
|
|
||||||
// The difference is credited to their linked savings account below. Only the share-side
|
|
||||||
// excess is handled here -- an over-payment on a separately-verified fee receipt would need
|
|
||||||
// its own GL leg to fund the savings credit, which this flow does not post (that receipt's
|
|
||||||
// money is left exactly where the cashier already put it), so it is left uncollected rather
|
|
||||||
// than silently unbalancing the ledger.
|
|
||||||
final BigDecimal excessAmount = receiptAmount.subtract(requiredForShareReceipts);
|
|
||||||
final Long savingsAccountId = toLong(acct.get("savings_account_id"));
|
|
||||||
if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings",
|
|
||||||
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no")
|
|
||||||
+ " requires only " + requiredForShareReceipts + ". The extra " + excessAmount
|
|
||||||
+ " must be credited to the member's savings account, but none is linked to this share account. Link one, or "
|
|
||||||
+ "collect the exact amount.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long officeId = toLong(acct.get("office_id"));
|
|
||||||
final Long productId = toLong(acct.get("product_id"));
|
|
||||||
final String currencyCode = (String) acct.get("currency_code");
|
|
||||||
|
|
||||||
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
|
||||||
final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
|
|
||||||
|
|
||||||
final long userId = currentUser.getId();
|
|
||||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
|
|
||||||
final String txnId = "SHAPP-" + System.nanoTime();
|
|
||||||
final String accountNo = (String) acct.get("account_no");
|
|
||||||
|
|
||||||
// Dr Sundry for the share-side receipts, including any over-payment on them — the liability
|
|
||||||
// those receipts created is cleared in full. A verified fee receipt is deliberately excluded:
|
|
||||||
// it was never in Sundry, so nothing here should debit Sundry for it.
|
|
||||||
insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
|
||||||
"Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
|
|
||||||
|
|
||||||
// Cr Share Capital for the share value.
|
|
||||||
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
|
||||||
"Share capital issued for " + accountNo);
|
|
||||||
|
|
||||||
// Cr Income from Fees for the entry fee, but only when it was NOT verified as its own receipt
|
|
||||||
// above -- that money already sits in whichever GL the cashier deposited it to (often the fee
|
|
||||||
// income account itself), so moving it again here would double it. When it was bundled into
|
|
||||||
// the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs
|
|
||||||
// to move, exactly as before.
|
|
||||||
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
if (chargeColl == null) {
|
|
||||||
// Resolved from the share product's "Income from Fees" mapping, which points at the
|
|
||||||
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
|
|
||||||
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
|
|
||||||
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
|
||||||
"Share entry fee for " + accountNo);
|
|
||||||
}
|
|
||||||
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
|
|
||||||
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The over-payment goes back to the member as a savings deposit rather than a bare journal
|
|
||||||
// entry, so it lands in their passbook and running balance the way any counter deposit
|
|
||||||
// would. Posted through Fineract's own deposit command, which writes its own Cr Savings
|
|
||||||
// Control leg — the Dr side is the Sundry debit above, which covered the whole receipt.
|
|
||||||
Long excessSavingsTransactionId = null;
|
|
||||||
if (excessAmount.compareTo(BigDecimal.ZERO) > 0) {
|
|
||||||
excessSavingsTransactionId = executeSavingsDeposit(savingsAccountId, excessAmount, entryDate,
|
|
||||||
"Excess on share application receipt " + receiptNos + " for share account " + accountNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stamp the originating cashier deposits so the same vouchers cannot be applied twice.
|
|
||||||
// Matched on voucher_no alone -- voucher numbers are unique across the whole cashier deposit
|
|
||||||
// table regardless of which GL account they were posted to, so no GL account lookup is needed
|
|
||||||
// here either. Covers the fee receipt's own deposit too, when one was verified.
|
|
||||||
for (final Map<String, Object> row : collections) {
|
|
||||||
if (row.get("voucher_no") != null) {
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
|
|
||||||
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL",
|
|
||||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chargeColl != null && chargeColl.get("voucher_no") != null) {
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
|
|
||||||
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL",
|
|
||||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeColl.get("voucher_no"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every receipt in this settlement is marked applied, not just the one in the path — they
|
|
||||||
// were all consumed by it, and an unmarked one would still show as money awaiting use. The fee
|
|
||||||
// receipt is marked applied too, even though it posted no GL entry of its own.
|
|
||||||
for (final Long id : collectionIds) {
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
|
|
||||||
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
|
|
||||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id);
|
|
||||||
}
|
|
||||||
if (chargeCollectionId != null) {
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
|
|
||||||
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
|
|
||||||
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeCollectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reveals the purchase transaction in the passbook and transactions views.
|
|
||||||
jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
|
|
||||||
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("collectionId", collectionId);
|
|
||||||
out.put("collectionIds", collectionIds);
|
|
||||||
out.put("receiptNo", receiptNos.toString());
|
|
||||||
out.put("shareAccountId", shareAccountId);
|
|
||||||
out.put("accountNo", accountNo);
|
|
||||||
out.put("shareAmount", shareAmount);
|
|
||||||
out.put("chargeAmount", chargeAmount);
|
|
||||||
out.put("chargeReceiptVerifiedSeparately", chargeColl != null);
|
|
||||||
out.put("totalApplied", requiredForShareReceipts);
|
|
||||||
out.put("totalCollected", receiptAmount);
|
|
||||||
out.put("glTransactionId", txnId);
|
|
||||||
out.put("status", "APPLIED");
|
|
||||||
// Told at the counter, while the member is still there, rather than left to be noticed on
|
|
||||||
// a statement later.
|
|
||||||
out.put("excessAmount", excessAmount);
|
|
||||||
if (excessSavingsTransactionId != null) {
|
|
||||||
out.put("excessSavingsTransactionId", excessSavingsTransactionId);
|
|
||||||
out.put("excessMessage", "The member paid " + excessAmount + " more than this application required. It has been credited to "
|
|
||||||
+ "their savings account.");
|
|
||||||
}
|
|
||||||
return Response.ok(gson.toJson(out)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// Helpers
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adopts an approved cashier deposit into the Share Sundry GL as a share advance collection.
|
|
||||||
*
|
|
||||||
* The cashier's Transaction Entry deposit already posted Dr Cash / Cr Share Sundry and was approved by a manager,
|
|
||||||
* so the money is genuinely in Sundry — it simply has no collection row yet. This creates that row on first lookup
|
|
||||||
* so the voucher resolves and can be applied.
|
|
||||||
*
|
|
||||||
* A deposit made straight to the Sundry GL carries no member, so the member is taken from the share account the
|
|
||||||
* clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which
|
|
||||||
* is what keeps the money tied to the right member.
|
|
||||||
*
|
|
||||||
* @param glAccountNoOverride
|
|
||||||
* required -- the GL account number the deposit was posted to, selected by the counter
|
|
||||||
* rather than resolved from any product's accounting configuration, so a broken or
|
|
||||||
* missing product mapping can never make a genuine voucher unreachable.
|
|
||||||
* @return the newly created collection row, or null when no approved deposit matches.
|
|
||||||
*/
|
|
||||||
private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) {
|
|
||||||
if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required",
|
|
||||||
"Select the GL account this voucher was deposited to, then search again.");
|
|
||||||
}
|
|
||||||
final String shareSundryAccountNo = glAccountNoOverride.trim();
|
|
||||||
final List<Map<String, Object>> deposits = jdbcTemplate
|
|
||||||
.queryForList(
|
|
||||||
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
|
|
||||||
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id"
|
|
||||||
+ " FROM m_cashier_deposit_request d"
|
|
||||||
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
|
|
||||||
voucherNo, shareSundryAccountNo);
|
|
||||||
|
|
||||||
if (deposits.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final Map<String, Object> dep = deposits.get(0);
|
|
||||||
|
|
||||||
if (toLong(dep.get("share_account_id")) != null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
|
|
||||||
"Voucher " + voucherNo + " has already been applied to a share account.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer the member recorded on the deposit; fall back to the account being settled.
|
|
||||||
final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint;
|
|
||||||
if (clientId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
|
|
||||||
"Voucher " + voucherNo + " was deposited straight to the Share Sundry GL without a member,"
|
|
||||||
+ " so it cannot be matched. Re-enter the deposit against the member.");
|
|
||||||
}
|
|
||||||
|
|
||||||
final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId);
|
|
||||||
final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR";
|
|
||||||
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
|
|
||||||
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
|
|
||||||
final String receiptNo = nextReceiptNo();
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"INSERT INTO m_share_advance_collection "
|
|
||||||
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
|
|
||||||
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
|
|
||||||
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency,
|
|
||||||
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"),
|
|
||||||
"Adopted from cashier deposit voucher " + voucherNo);
|
|
||||||
|
|
||||||
return jdbcTemplate.queryForMap(
|
|
||||||
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
|
|
||||||
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
|
|
||||||
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
|
|
||||||
receiptNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long loadClientOfficeId(final Long clientId) {
|
|
||||||
return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Share value the account was opened for: the pending/approved purchase transaction. */
|
|
||||||
private BigDecimal shareValueOf(final Long shareAccountId) {
|
|
||||||
final BigDecimal total = jdbcTemplate
|
|
||||||
.queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
|
|
||||||
+ " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
|
|
||||||
return total == null ? BigDecimal.ZERO : total;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Entry fee still outstanding on the account, zero when no charge was attached. */
|
|
||||||
private BigDecimal outstandingChargeOf(final Long shareAccountId) {
|
|
||||||
// Driven by the money still owed rather than the is_paid_derived flag alone: accounts
|
|
||||||
// created before the flat-charge fix carry the flag set to true while the full amount is
|
|
||||||
// still outstanding, and those charges must still be settled by the collection.
|
|
||||||
final BigDecimal total = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) " + " FROM m_share_account_charge "
|
|
||||||
+ " WHERE account_id = ? AND is_active = true " + " AND COALESCE(amount_outstanding_derived, 0) > 0",
|
|
||||||
BigDecimal.class, shareAccountId);
|
|
||||||
return total == null ? BigDecimal.ZERO : total;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> loadCollection(final Long id) {
|
|
||||||
try {
|
|
||||||
return jdbcTemplate.queryForMap("SELECT * FROM m_share_advance_collection WHERE id = ?", id);
|
|
||||||
} catch (EmptyResultDataAccessException e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found",
|
|
||||||
"Share advance collection " + id + " does not exist.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> loadClient(final Long clientId) {
|
|
||||||
try {
|
|
||||||
return jdbcTemplate.queryForMap("SELECT id, office_id, display_name FROM m_client WHERE id = ?", clientId);
|
|
||||||
} catch (EmptyResultDataAccessException e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.not.found", "Client " + clientId + " does not exist.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> loadAccount(final Long shareAccountId) {
|
|
||||||
try {
|
|
||||||
return jdbcTemplate.queryForMap("SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, "
|
|
||||||
+ " sa.currency_code, sa.collection_applied, sa.savings_account_id, c.office_id "
|
|
||||||
+ " FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", shareAccountId);
|
|
||||||
} catch (EmptyResultDataAccessException e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.found",
|
|
||||||
"Share account " + shareAccountId + " does not exist.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uses the caller's product when given, else the single mapped share product.
|
|
||||||
*
|
|
||||||
* The mapping table is joined to m_share_product because acc_product_mapping can hold rows for product ids that are
|
|
||||||
* not share products (stale rows left by deleted products), and counting those would make auto-detection fail even
|
|
||||||
* when only one real share product is mapped.
|
|
||||||
*/
|
|
||||||
private Long resolveProductId(final Long requested) {
|
|
||||||
if (requested != null) {
|
|
||||||
return requested;
|
|
||||||
}
|
|
||||||
final List<Long> ids = jdbcTemplate.queryForList("SELECT DISTINCT m.product_id FROM acc_product_mapping m "
|
|
||||||
+ " JOIN m_share_product p ON p.id = m.product_id WHERE m.product_type = ?", Long.class, PRODUCT_TYPE_SHARES);
|
|
||||||
if (ids.isEmpty()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.product.not.mapped",
|
|
||||||
"No share product has GL mappings configured.");
|
|
||||||
}
|
|
||||||
if (ids.size() > 1) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.productid.required",
|
|
||||||
"productId is required: " + ids.size() + " share products have GL mappings.");
|
|
||||||
}
|
|
||||||
return ids.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveCurrency(final Long productId) {
|
|
||||||
try {
|
|
||||||
final String code = jdbcTemplate.queryForObject("SELECT currency_code FROM m_share_product WHERE id = ?", String.class,
|
|
||||||
productId);
|
|
||||||
return code == null ? "INR" : code;
|
|
||||||
} catch (EmptyResultDataAccessException e) {
|
|
||||||
return "INR";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
|
|
||||||
final Long glId = resolveGl(productId, financialAccountType);
|
|
||||||
if (glId == null) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.not.mapped",
|
|
||||||
label + " GL is not mapped for share product " + productId + ".");
|
|
||||||
}
|
|
||||||
return glId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long resolveGl(final Long productId, final int financialAccountType) {
|
|
||||||
try {
|
|
||||||
return jdbcTemplate.queryForObject(
|
|
||||||
"SELECT gl_account_id FROM acc_product_mapping "
|
|
||||||
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
|
|
||||||
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
|
|
||||||
} catch (IncorrectResultSizeDataAccessException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String nextReceiptNo() {
|
|
||||||
final Long seq = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) + 1 FROM m_share_advance_collection", Long.class);
|
|
||||||
return String.format("SHADV%06d", seq == null ? 1 : seq);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
|
||||||
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
|
||||||
final String description) {
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
|
||||||
+ " entry_date, type_enum, amount, description, entity_id, "
|
|
||||||
+ " created_by, last_modified_by, created_date, lastmodified_date, "
|
|
||||||
+ " is_running_balance_calculated, office_running_balance, "
|
|
||||||
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
|
|
||||||
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
|
||||||
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
|
|
||||||
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
|
|
||||||
*
|
|
||||||
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
|
|
||||||
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
|
|
||||||
* the tenant's own zone first, which is the zone {@code now} was taken in.
|
|
||||||
*/
|
|
||||||
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
|
|
||||||
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
|
|
||||||
.withOffsetSameInstant(ZoneOffset.UTC);
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonObject parseBody(final String body) {
|
|
||||||
if (body == null || body.isBlank()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.empty", "Request body is required.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JsonParser.parseString(body).getAsJsonObject();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.invalid", "Request body must be valid JSON.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private LocalDate parseDate(final String raw) {
|
|
||||||
if (raw == null || raw.isBlank()) {
|
|
||||||
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return LocalDate.parse(raw);
|
|
||||||
} catch (DateTimeParseException e) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.date.invalid",
|
|
||||||
"transactionDate must be in YYYY-MM-DD format. Received: " + raw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String sanitiseNote(final String raw) {
|
|
||||||
if (raw == null || raw.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final String trimmed = raw.trim();
|
|
||||||
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getString(final JsonObject json, final String field) {
|
|
||||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long getLong(final JsonObject json, final String field) {
|
|
||||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal getBigDecimal(final JsonObject json, final String field) {
|
|
||||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsBigDecimal() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Further receipts making up one settlement. Empty when the caller sent a single receipt. */
|
|
||||||
private List<Long> getLongList(final JsonObject json, final String field) {
|
|
||||||
final List<Long> values = new java.util.ArrayList<>();
|
|
||||||
if (!json.has(field) || json.get(field).isJsonNull() || !json.get(field).isJsonArray()) {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
for (final com.google.gson.JsonElement element : json.get(field).getAsJsonArray()) {
|
|
||||||
if (!element.isJsonNull()) {
|
|
||||||
values.add(element.getAsLong());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Credits an over-payment to the member's savings account.
|
|
||||||
*
|
|
||||||
* Routed through Fineract's own savings deposit command rather than a direct journal entry, so the money appears in
|
|
||||||
* the member's passbook, statement and running balance exactly as an over-the-counter deposit does. A bare GL entry
|
|
||||||
* would move the balance in the ledger while leaving the member's own account history silent about it.
|
|
||||||
*/
|
|
||||||
private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) {
|
|
||||||
final JsonObject payload = new JsonObject();
|
|
||||||
payload.addProperty("locale", "en");
|
|
||||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
|
||||||
payload.addProperty("transactionDate", txnDate.toString());
|
|
||||||
payload.addProperty("transactionAmount", amount);
|
|
||||||
payload.addProperty("paymentTypeId", resolveExcessPaymentTypeId());
|
|
||||||
payload.addProperty("note", note);
|
|
||||||
|
|
||||||
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
|
|
||||||
.build();
|
|
||||||
return this.commandsSourceWritePlatformService.logCommandSource(cmd).getResourceId();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The payment type this deposit is recorded under.
|
|
||||||
*
|
|
||||||
* Reuses the share settlement type seeded for the savings-debit route, since this is the same kind of movement in
|
|
||||||
* the opposite direction — an internal transfer between a share application and the member's savings, not cash
|
|
||||||
* over the counter. Falls back to any active type so an over-payment is never blocked by a missing seed row.
|
|
||||||
*/
|
|
||||||
private Long resolveExcessPaymentTypeId() {
|
|
||||||
final List<Long> byName = jdbcTemplate.queryForList(
|
|
||||||
"SELECT id FROM m_payment_type WHERE value = 'Share Application Settlement' ORDER BY id LIMIT 1", Long.class);
|
|
||||||
if (!byName.isEmpty()) {
|
|
||||||
return byName.get(0);
|
|
||||||
}
|
|
||||||
final List<Long> any = jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1", Long.class);
|
|
||||||
if (any.isEmpty()) {
|
|
||||||
throw new PlatformDataIntegrityException("error.msg.shareadvance.no.payment.type",
|
|
||||||
"No payment type is configured, so the over-payment cannot be credited to the member's savings account.");
|
|
||||||
}
|
|
||||||
return any.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Long toLong(final Object o) {
|
|
||||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int toInt(final Object o) {
|
|
||||||
return o instanceof Number ? ((Number) o).intValue() : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-14
@@ -375,20 +375,8 @@ public class ShareCollectionApiResource {
|
|||||||
userId, userId, now, now,
|
userId, userId, now, now,
|
||||||
java.sql.Date.valueOf(entryDate),
|
java.sql.Date.valueOf(entryDate),
|
||||||
java.sql.Date.valueOf(entryDate),
|
java.sql.Date.valueOf(entryDate),
|
||||||
toUtc(now),
|
now.atOffset(ZoneOffset.UTC),
|
||||||
toUtc(now));
|
now.atOffset(ZoneOffset.UTC));
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
|
|
||||||
*
|
|
||||||
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
|
|
||||||
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
|
|
||||||
* the tenant's own zone first, which is the zone {@code now} was taken in.
|
|
||||||
*/
|
|
||||||
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
|
|
||||||
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
|
|
||||||
.withOffsetSameInstant(ZoneOffset.UTC);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> glEntryMap(final String account, final String type,
|
private Map<String, Object> glEntryMap(final String account, final String type,
|
||||||
|
|||||||
+1
-55
@@ -35,9 +35,6 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
|
||||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
|
||||||
import org.apache.fineract.organisation.monetary.domain.Money;
|
import org.apache.fineract.organisation.monetary.domain.Money;
|
||||||
import org.apache.fineract.organisation.office.domain.Office;
|
import org.apache.fineract.organisation.office.domain.Office;
|
||||||
import org.apache.fineract.portfolio.charge.domain.Charge;
|
import org.apache.fineract.portfolio.charge.domain.Charge;
|
||||||
@@ -92,12 +89,6 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
private final FineractRequestContextHolder fineractRequestContextHolder;
|
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||||
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
|
private final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper;
|
||||||
private final SavingsHelper savingsHelper;
|
private final SavingsHelper savingsHelper;
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final VoucherService voucherService;
|
|
||||||
|
|
||||||
/** A cheque book issuance charge, keyed by its m_cheque_book id. */
|
|
||||||
private static final String REF_CHEQUEBOOK_CHARGE = "CHEQUEBOOK_CHARGE";
|
|
||||||
|
|
||||||
|
|
||||||
/** The configured charge, if the admin has set one up — absent means "no charge configured yet." */
|
/** The configured charge, if the admin has set one up — absent means "no charge configured yet." */
|
||||||
private FacilityChargeMapping resolveMapping() {
|
private FacilityChargeMapping resolveMapping() {
|
||||||
@@ -253,7 +244,6 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
|
|
||||||
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId,
|
||||||
accountId);
|
accountId);
|
||||||
raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId);
|
|
||||||
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId,
|
||||||
chequeBookId);
|
chequeBookId);
|
||||||
}
|
}
|
||||||
@@ -288,47 +278,6 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
this.currentAccountRepository.saveAndFlush(account);
|
this.currentAccountRepository.saveAndFlush(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a cheque book issuance charge in the voucher register.
|
|
||||||
*
|
|
||||||
* The account is debited and the charge's income account credited — the society's fee, taken
|
|
||||||
* from the customer. The debit names the customer and their account number even though it posts
|
|
||||||
* against the product's control GL, so the slip reads as the customer's own.
|
|
||||||
*
|
|
||||||
* A failure here does not fail the charge. The money is already taken and the ledger already
|
|
||||||
* posted; reversing that over a slip would undo a correct deduction for a paperwork reason. The
|
|
||||||
* problem is logged and the charge stands.
|
|
||||||
*/
|
|
||||||
private void raiseChargeVoucher(final ChequeAccountType accountType, final Long accountId, final Long ownAccountGlId,
|
|
||||||
final Long incomeGlId, final BigDecimal chargeAmount, final Long chequeBookId) {
|
|
||||||
try {
|
|
||||||
final Map<String, Object> holder = accountHolderForVoucher(accountType, accountId);
|
|
||||||
final Map<String, Object> incomeGl = this.voucherService.glAccount(incomeGlId);
|
|
||||||
final String narration = "Cheque book issuance charge - book #" + chequeBookId;
|
|
||||||
|
|
||||||
this.voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, REF_CHEQUEBOOK_CHARGE, chequeBookId,
|
|
||||||
this.context.authenticatedUser(), VoucherService.PREFIX_DEBIT,
|
|
||||||
List.of(VoucherLine
|
|
||||||
.debitGl(ownAccountGlId, (String) holder.get("party"), (String) holder.get("account_no"), chargeAmount)
|
|
||||||
.withDescription(narration),
|
|
||||||
VoucherLine
|
|
||||||
.creditGl(incomeGlId, (String) incomeGl.get("name"), (String) incomeGl.get("gl_code"), chargeAmount)
|
|
||||||
.withDescription(narration)));
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
log.error("Could not raise a voucher for cheque book {} issuance charge", chequeBookId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The customer and account number a charge is taken from, for printing on a voucher. */
|
|
||||||
private Map<String, Object> accountHolderForVoucher(final ChequeAccountType accountType, final Long accountId) {
|
|
||||||
final String sql = accountType == ChequeAccountType.SAVINGS
|
|
||||||
? "SELECT sa.account_no, c.display_name AS party FROM m_savings_account sa "
|
|
||||||
+ "LEFT JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?"
|
|
||||||
: "SELECT ca.account_no, c.display_name AS party FROM m_current_account ca "
|
|
||||||
+ "LEFT JOIN m_client c ON c.id = ca.client_id WHERE ca.id = ?";
|
|
||||||
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */
|
/** Same shape as {@code getSavingsControlGlId}/{@code getCurrentControlGlId} in ClerkDisbursementRequestApiResource. */
|
||||||
private Long getSavingsControlGlId(final Long savingsAccountId) {
|
private Long getSavingsControlGlId(final Long savingsAccountId) {
|
||||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa "
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_savings_account sa "
|
||||||
@@ -371,10 +320,7 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
|
|
||||||
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
final CommandWrapper cmd = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||||
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
this.fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
// The charge raises its own voucher, naming the account charged and the fee; the journal
|
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
// command must not raise a second one over the same money.
|
|
||||||
final CommandProcessingResult result = this.voucherService
|
|
||||||
.withOwnVoucher(() -> this.commandsSourceWritePlatformService.logCommandSource(cmd));
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
|
log.warn("GL journal entry for cheque book {} issuance charge did not return a result.", chequeBookId);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user