diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/data/DdsAccountTypeData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/data/DdsAccountTypeData.java index 07012545c..5f7c5ed3e 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/data/DdsAccountTypeData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/data/DdsAccountTypeData.java @@ -65,6 +65,12 @@ public final class DdsAccountTypeData implements Serializable { private final Long commissionExpenseGlId; private final String commissionExpenseGlCode; private final String commissionExpenseGlName; + private final Long prematureIncomeGlId; + private final String prematureIncomeGlCode; + private final String prematureIncomeGlName; + private final Long tdsPayableGlId; + private final String tdsPayableGlCode; + private final String tdsPayableGlName; private final LocalDateTime createdDate; private final LocalDateTime lastModifiedDate; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountType.java index 0c47f1e3b..bcfb22d8a 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/accounttype/domain/DdsAccountType.java @@ -133,6 +133,17 @@ public class DdsAccountType extends AbstractAuditableCustom { @Column(name = "commission_expense_gl_id") private Long commissionExpenseGlId; + /** + * What an early closure earns the bank: the interest a customer gives back, and the penalty taken off + * the agent's commission. + */ + @Column(name = "premature_income_gl_id") + private Long prematureIncomeGlId; + + /** Where TDS withheld from the agents' commission on this product (Section 194H) is owed until paid over. */ + @Column(name = "tds_payable_gl_id") + private Long tdsPayableGlId; + protected DdsAccountType() { // } @@ -152,6 +163,8 @@ public class DdsAccountType extends AbstractAuditableCustom { this.depositLiabilityGlId = command.longValueOfParameterNamed("depositLiabilityGlId"); this.interestExpenseGlId = command.longValueOfParameterNamed("interestExpenseGlId"); this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId"); + this.prematureIncomeGlId = command.longValueOfParameterNamed("prematureIncomeGlId"); + this.tdsPayableGlId = command.longValueOfParameterNamed("tdsPayableGlId"); // A newly created account type is available for use straight away. this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active"); } @@ -275,6 +288,15 @@ public class DdsAccountType extends AbstractAuditableCustom { this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId"); actualChanges.put("commissionExpenseGlId", this.commissionExpenseGlId); } + if (command.parameterExists("prematureIncomeGlId") + && command.isChangeInLongParameterNamed("prematureIncomeGlId", this.prematureIncomeGlId)) { + this.prematureIncomeGlId = command.longValueOfParameterNamed("prematureIncomeGlId"); + actualChanges.put("prematureIncomeGlId", this.prematureIncomeGlId); + } + if (command.parameterExists("tdsPayableGlId") && command.isChangeInLongParameterNamed("tdsPayableGlId", this.tdsPayableGlId)) { + this.tdsPayableGlId = command.longValueOfParameterNamed("tdsPayableGlId"); + actualChanges.put("tdsPayableGlId", this.tdsPayableGlId); + } if (command.isChangeInBooleanParameterNamed("active", this.active)) { final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java index 6909bab99..6a4a5f121 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java @@ -238,6 +238,89 @@ public class DdsLedger { ddsPair(party, date, amount, depositLiabilityOf(party), interestExpenseOf(party), description); } + /** + * Interest a customer gives back on closing early, as the bank's income: the interest was paid and + * stays an expense, and what comes back is earned for the closure. + * + *
+     *   DR  DDS Customer Deposits        the customer's balance comes down
+     *   CR  Premature Closure Income     the product's premature-closure income
+     * 
+ */ + public void recoverPrematureInterest(final Party party, final LocalDate date, final BigDecimal amount, final String description) { + if (party.getSavingsAccountId() != null) { + final SavingsAccount account = load(party.getSavingsAccountId()); + final SavingsAccountTransaction withdrawal = withdraw(account, date, amount); + postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), prematureIncomeOf(party), + description, withdrawal.getId(), account.getId()); + return; + } + ddsPair(party, date, amount, depositLiabilityOf(party), prematureIncomeOf(party), description); + } + + /** + * Interest an agent funded on an early closure, now paid for out of their commission. The bank booked + * it as interest on the closing day; the commission it came off was booked net. So it moves across: + * + *
+     *   DR  Agent Commission Expense
+     *   CR  Interest Expense
+     * 
+ */ + public void bookAgentInterestTopUpRecovery(final Party party, final LocalDate date, final BigDecimal amount, + final String description) { + ddsPair(party, date, amount, commissionExpenseOf(party), interestExpenseOf(party), description); + } + + private static final String TDS_BASIS_SQL = "select upper(trim(p.sub_type)) as \"product\", t.commission_expense_gl_id as \"commissionGlId\", " + + "t.tds_payable_gl_id as \"tdsGlId\", sum(pt.amount) as \"volume\" from m_dds_party_transaction pt " + + "join m_dds_party p on p.id = pt.party_id left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) " + + "where pt.agent_id = ? and pt.transaction_type = 'PARTY_TRAN' and pt.direction = 'CREDIT' and pt.commission_paid_batch_run_id = ? " + + "group by upper(trim(p.sub_type)), t.commission_expense_gl_id, t.tds_payable_gl_id order by upper(trim(p.sub_type))"; + + /** + * TDS withheld from an agent's commission (Section 194H), booked when the commission it was withheld + * from is paid. The agent is paid net; the TDS is commission the bank owes the tax department instead, + * so it is commission expense like the rest, credited to TDS Payable - shared over the products by + * what was collected for each, as the commission is. + * + *
+     *   DR  Agent Commission Expense
+     *   CR  TDS Payable
+     * 
+ */ + public void bookCommissionTds(final Office office, final Long agentId, final Long payingRunId, final LocalDate date, + final BigDecimal tds, final String description) { + if (tds == null || tds.signum() <= 0) { + return; + } + final List> basis = this.jdbcTemplate.queryForList(TDS_BASIS_SQL, agentId, payingRunId); // NOSONAR + if (basis.isEmpty()) { + throw new DdsLedgerNotConfiguredException("a collection paid by this commission run to tell which DDS product's TDS Payable " + + "account the TDS belongs to"); + } + BigDecimal total = BigDecimal.ZERO; + for (final Map row : basis) { + total = total.add((BigDecimal) row.get("volume")); + } + BigDecimal allotted = BigDecimal.ZERO; + for (int i = 0; i < basis.size(); i++) { + final Map row = basis.get(i); + final String product = (String) row.get("product"); + final BigDecimal share = i == basis.size() - 1 || total.signum() == 0 ? tds.subtract(allotted) + : tds.multiply((BigDecimal) row.get("volume")).divide(total, 2, RoundingMode.HALF_UP); + allotted = allotted.add(share); + if (share.signum() <= 0) { + continue; + } + final GLAccount commission = accountNamed(row.get("commissionGlId") == null ? null : ((Number) row.get("commissionGlId")).longValue(), + product, "Agent Commission Expense"); + final GLAccount tdsPayable = accountNamed(row.get("tdsGlId") == null ? null : ((Number) row.get("tdsGlId")).longValue(), product, + "TDS Payable"); + postPair(office, organisationCurrency(), date, share, commission, tdsPayable, description, null, null); + } + } + /** * The party's whole balance paid into another account of the customer's at this bank. Nothing leaves * the bank: what it owed on the DDS account it now owes on theirs. @@ -432,7 +515,17 @@ public class DdsLedger { /** Columns of m_dds_account_type a product's account may be read from - never anything a caller typed. */ private static final Set PRODUCT_GL_COLUMNS = Set.of("deposit_liability_gl_id", "interest_expense_gl_id", - "commission_expense_gl_id"); + "commission_expense_gl_id", "premature_income_gl_id"); + + /** What an early closure of this party's account earns the bank - their product's premature-closure income. */ + public GLAccount prematureIncomeOf(final Party party) { + return productAccount(party, "premature_income_gl_id", "Premature Closure Income"); + } + + /** What the agents' commission on this party's product costs the bank. */ + public GLAccount commissionExpenseOf(final Party party) { + return productAccount(party, "commission_expense_gl_id", "Agent Commission Expense"); + } /** What the bank owes this party - their product's customer-deposits account. */ public GLAccount depositLiabilityOf(final Party party) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/serialization/DdsAccountTypeCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/serialization/DdsAccountTypeCommandFromApiJsonDeserializer.java index 891da538e..f6222a25c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/serialization/DdsAccountTypeCommandFromApiJsonDeserializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/serialization/DdsAccountTypeCommandFromApiJsonDeserializer.java @@ -47,7 +47,7 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer { private static final Set SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode", "accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active", "prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate", - "depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId", + "depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId", "prematureIncomeGlId", "tdsPayableGlId", "dateFormat", "locale")); /** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */ @@ -80,6 +80,12 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer { .notNull().integerGreaterThanZero(); v.reset().parameter("commissionExpenseGlId").value(this.fromApiJsonHelper.extractLongNamed("commissionExpenseGlId", element)) .notNull().integerGreaterThanZero(); + // What an early closure earns the bank - the customer's interest given back and the agent's penalty. + v.reset().parameter("prematureIncomeGlId").value(this.fromApiJsonHelper.extractLongNamed("prematureIncomeGlId", element)) + .notNull().integerGreaterThanZero(); + // Where the TDS withheld from the agents' commission (Section 194H) is owed until it is paid over. + v.reset().parameter("tdsPayableGlId").value(this.fromApiJsonHelper.extractLongNamed("tdsPayableGlId", element)).notNull() + .integerGreaterThanZero(); // Mandatory: every account opened on the scheme takes this rate. v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element)) .notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED); @@ -114,7 +120,8 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer { } // Once named, a product's customer and interest accounts can be changed but not cleared. - for (final String required : List.of("depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId")) { + for (final String required : List.of("depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId", + "prematureIncomeGlId", "tdsPayableGlId")) { if (this.fromApiJsonHelper.parameterExists(required, element)) { v.reset().parameter(required).value(this.fromApiJsonHelper.extractLongNamed(required, element)).notNull() .integerGreaterThanZero(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformServiceImpl.java index a4edf5f31..34fb90681 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeReadPlatformServiceImpl.java @@ -56,11 +56,16 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead + " t.interest_expense_gl_id as interestExpenseGlId, ie.gl_code as interestExpenseGlCode, " + " ie.name as interestExpenseGlName, " + " t.commission_expense_gl_id as commissionExpenseGlId, ce.gl_code as commissionExpenseGlCode, " - + " ce.name as commissionExpenseGlName " + + " ce.name as commissionExpenseGlName, " + + " t.premature_income_gl_id as prematureIncomeGlId, pi.gl_code as prematureIncomeGlCode, " + + " pi.name as prematureIncomeGlName, " + + " t.tds_payable_gl_id as tdsPayableGlId, tp.gl_code as tdsPayableGlCode, tp.name as tdsPayableGlName " + " from m_dds_account_type t " + " left join acc_gl_account dl on dl.id = t.deposit_liability_gl_id " + " left join acc_gl_account ie on ie.id = t.interest_expense_gl_id " - + " left join acc_gl_account ce on ce.id = t.commission_expense_gl_id"; + + " left join acc_gl_account ce on ce.id = t.commission_expense_gl_id " + + " left join acc_gl_account pi on pi.id = t.premature_income_gl_id " + + " left join acc_gl_account tp on tp.id = t.tds_payable_gl_id"; } @Override @@ -87,6 +92,12 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead .commissionExpenseGlId(JdbcSupport.getLong(rs, "commissionExpenseGlId")) // .commissionExpenseGlCode(rs.getString("commissionExpenseGlCode")) // .commissionExpenseGlName(rs.getString("commissionExpenseGlName")) // + .prematureIncomeGlId(JdbcSupport.getLong(rs, "prematureIncomeGlId")) // + .prematureIncomeGlCode(rs.getString("prematureIncomeGlCode")) // + .prematureIncomeGlName(rs.getString("prematureIncomeGlName")) // + .tdsPayableGlId(JdbcSupport.getLong(rs, "tdsPayableGlId")) // + .tdsPayableGlCode(rs.getString("tdsPayableGlCode")) // + .tdsPayableGlName(rs.getString("tdsPayableGlName")) // .createdDate(localDateTime(rs, "createdDate")) // .lastModifiedDate(localDateTime(rs, "lastModifiedDate")) // .build(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java index cb3259b6c..4c87df0a4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java @@ -56,6 +56,7 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA /** acc_gl_account.classification_enum: 2 liability, 5 expense. */ private static final int LIABILITY = 2; + private static final int INCOME = 4; private static final int EXPENSE = 5; /** acc_gl_account.account_usage 1 - a detail account an entry can be posted to, not a header. */ private static final int DETAIL = 1; @@ -148,6 +149,10 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA checkLedgerAccount(command, "depositLiabilityGlId", "Customer Deposits", List.of(LIABILITY), errors); checkLedgerAccount(command, "interestExpenseGlId", "Interest Expense", List.of(EXPENSE), errors); checkLedgerAccount(command, "commissionExpenseGlId", "Agent Commission Expense", List.of(EXPENSE), errors); + // What an early closure takes back - the customer's interest and the agent's penalty - the bank earns. + checkLedgerAccount(command, "prematureIncomeGlId", "Premature Closure Income", List.of(INCOME), errors); + // TDS withheld from commission is owed to the tax department until it is paid over. + checkLedgerAccount(command, "tdsPayableGlId", "TDS Payable", List.of(LIABILITY), errors); if (!errors.isEmpty()) { throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java index 8e3b55f60..a33a91cd0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java @@ -36,7 +36,9 @@ import org.apache.fineract.commands.exception.CommandNotFoundException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.dds.accounting.DdsLedger; import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService; import org.apache.fineract.portfolio.dds.calculation.DdsInterestCalculation; +import org.apache.fineract.portfolio.dds.party.domain.PartyRepository; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -72,6 +74,8 @@ public class DdsApprovalReadService { private final JdbcTemplate jdbcTemplate; private final DdsLedger ddsLedger; + private final PartyRepository partyRepository; + private final BatchRunWritePlatformService batchRunWritePlatformService; public List> list(final String status, final String fromDate, final String toDate) { final StringBuilder sql = new StringBuilder(ENTRIES_SQL).append(" and c.status = ? "); @@ -438,7 +442,9 @@ public class DdsApprovalReadService { new ProductField("deposit_liability_gl_id", "depositLiabilityGlId", "DDS Customer Deposits (Liability)", "gl", GROUP_ACCOUNTING), // new ProductField("interest_expense_gl_id", "interestExpenseGlId", "Interest Expense", "gl", GROUP_ACCOUNTING), // - new ProductField("commission_expense_gl_id", "commissionExpenseGlId", "Agent Commission Expense", "gl", GROUP_ACCOUNTING)); + new ProductField("commission_expense_gl_id", "commissionExpenseGlId", "Agent Commission Expense", "gl", GROUP_ACCOUNTING), // + new ProductField("premature_income_gl_id", "prematureIncomeGlId", "Premature Closure Income", "gl", GROUP_ACCOUNTING), // + new ProductField("tds_payable_gl_id", "tdsPayableGlId", "TDS Payable (194H)", "gl", GROUP_ACCOUNTING)); private static final String GROUP_SLAB = "Commission Slab"; @@ -1097,8 +1103,13 @@ public class DdsApprovalReadService { if (found.isEmpty()) { return null; } - final BigDecimal balance = (BigDecimal) found.get(0).get("balance"); final BigDecimal rate = (BigDecimal) found.get(0).get("rate"); + final LocalDate closeDate = DateUtils.getBusinessLocalDate(); + // The closure first credits the interest still owed through the day before it, so the figures start there. + final BigDecimal owed = this.partyRepository.findById(partyId) + .map(party -> this.batchRunWritePlatformService.interestNotYetPosted(party, closeDate.minusDays(1))).orElse(BigDecimal.ZERO); + final BigDecimal stored = (BigDecimal) found.get(0).get("balance"); + final BigDecimal balance = stored == null ? null : stored.add(owed); if (balance == null || rate == null || rate.signum() <= 0) { return balance; } @@ -1107,10 +1118,11 @@ public class DdsApprovalReadService { if (reduction == null || reduction.signum() <= 0) { return balance; } - final BigDecimal credited = this.jdbcTemplate.queryForObject( // NOSONAR + final BigDecimal creditedSoFar = this.jdbcTemplate.queryForObject( // NOSONAR "select coalesce(sum(case when direction = 'CREDIT' then amount else -amount end), 0) from m_dds_party_transaction " + "where party_id = ? and transaction_type in ('INTEREST_POST', 'PREMATURE_INT_RECOVERY', 'PREMATURE_AGENT_TOPUP')", BigDecimal.class, partyId); + final BigDecimal credited = creditedSoFar == null ? owed : creditedSoFar.add(owed); if (credited == null || credited.signum() <= 0) { return balance; } @@ -1118,7 +1130,6 @@ public class DdsApprovalReadService { RoundingMode.HALF_UP); return balance.subtract(recovered); } - final LocalDate closeDate = DateUtils.getBusinessLocalDate(); final int remainingDays = closeDate.lengthOfMonth() - closeDate.getDayOfMonth() + 1; return balance.add(DdsInterestCalculation.interestOn(balance.multiply(BigDecimal.valueOf(remainingDays)), rate)); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java index 493053fb2..dd083ecbb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java @@ -513,7 +513,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr final DdsCommissionCalculation commission = DdsCommissionCalculation.of(collectionVolume, matchingSlabs.get(0).getCommissionPercent(), agent.isTdsApplicable(), agent.getTdsPercent(), - agent.totalDeductions()); + agent.totalDeductions().add(prematureChargesOutstanding(agent.getId()))); if (!commission.isPayable()) { // The deductions have swallowed this period's commission. Nothing is owed, and the // agent is not put into debt over it - the deductions are standing figures the bank @@ -795,6 +795,57 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr final BigDecimal tds = row.getTdsAmount() == null ? BigDecimal.ZERO : row.getTdsAmount(); agent.accrueCommission(row.getAmount(), tds, jobDate); + + // The premature-closure charges this commission was cut by are now recovered - each once. They + // are what the row deducted beyond the agent's standing deductions, taken oldest first. + final BigDecimal deducted = row.getDeductionAmount() == null ? BigDecimal.ZERO : row.getDeductionAmount(); + BigDecimal forCharges = deducted.subtract(agent.totalDeductions()); + for (final Map charge : this.jdbcTemplate.queryForList(OUTSTANDING_CHARGES_SQL, agent.getId())) { // NOSONAR + final BigDecimal amount = (BigDecimal) charge.get("amount"); + if (forCharges.compareTo(amount) < 0) { + break; + } + this.jdbcTemplate.update("update m_dds_agent_premature_charge set recovered_batch_run_id = ? where id = ?", // NOSONAR + batchRun.getId(), charge.get("id")); + forCharges = forCharges.subtract(amount); + } + } + + private static final String OUTSTANDING_CHARGES_SQL = "select id, amount from m_dds_agent_premature_charge " + + "where agent_id = ? and recovered_batch_run_id is null order by id"; + + /** Premature-closure charges laid on the agent that no commission has yet been cut by. */ + private BigDecimal prematureChargesOutstanding(final Long agentId) { + final BigDecimal total = this.jdbcTemplate.queryForObject( // NOSONAR + "select coalesce(sum(amount), 0) from m_dds_agent_premature_charge where agent_id = ? and recovered_batch_run_id is null", + BigDecimal.class, agentId); + return total == null ? BigDecimal.ZERO : total; + } + + /** + * Settles the premature-closure charges a commission payment has now recovered. + * + * The commission was cut by them and booked net, so a penalty needs no entry of its own: the + * commission expense is already that much lower - the clawback is a reversal of commission, as a + * core banking system books it. Interest the agent funded is different: the bank booked it as + * interest on the closing day, and the agent has now paid for it out of commission, so it moves from + * the product's Interest Expense to its Agent Commission Expense. + */ + private void bookRecoveredPrematureCharges(final Agent agent, final BatchRun batchRun) { + for (final Map charge : this.jdbcTemplate.queryForList( // NOSONAR + "select id, party_id, charge_type, amount from m_dds_agent_premature_charge " + + "where agent_id = ? and recovered_batch_run_id is not null and booked_batch_run_id is null order by id", + agent.getId())) { + if ("INTEREST_TOPUP".equals(charge.get("charge_type"))) { + final Long partyId = ((Number) charge.get("party_id")).longValue(); + final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId)); + this.ddsLedger.bookAgentInterestTopUpRecovery(party, batchRun.getJobDate(), (BigDecimal) charge.get("amount"), + "Premature closure interest funded by agent on " + party.getPartyCode() + " recovered from " + + agent.getAgentCode() + "'s commission, batch run #" + batchRun.getId()); + } + this.jdbcTemplate.update("update m_dds_agent_premature_charge set booked_batch_run_id = ? where id = ?", // NOSONAR + batchRun.getId(), charge.get("id")); + } } /** @@ -882,8 +933,21 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount, "DDS commission, batch run #" + batchRun.getId(), shares); this.ddsLedger.markCommissionPaid(agent.getId(), batchRun.getId()); + + // The TDS withheld when the commission now paid was calculated - the calculation runs whose + // collections this payment has just covered. + final BigDecimal tds = this.jdbcTemplate.queryForObject(PAID_COMMISSION_TDS_SQL, BigDecimal.class, agent.getId(), // NOSONAR + agent.getId(), batchRun.getId()); + this.ddsLedger.bookCommissionTds(agent.getOffice(), agent.getId(), batchRun.getId(), batchRun.getJobDate(), tds, + "TDS u/s 194H on DDS commission of " + agent.getAgentCode() + ", batch run #" + batchRun.getId()); + + bookRecoveredPrematureCharges(agent, batchRun); } + private static final String PAID_COMMISSION_TDS_SQL = "select coalesce(sum(r.tds_amount), 0) from m_dds_batch_run_row r " + + "where r.agent_id = ? and r.batch_run_id in (select distinct pt.commission_batch_run_id from m_dds_party_transaction pt " + + "where pt.agent_id = ? and pt.commission_paid_batch_run_id = ?)"; + /** Fineract's savings statuses, named the way the screens name them. */ private String statusName(final int status) { return switch (status) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/api/PartiesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/api/PartiesApiResource.java index d4d30f2cf..23bd4ddf3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/api/PartiesApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/api/PartiesApiResource.java @@ -30,7 +30,10 @@ import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.time.LocalDate; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -40,9 +43,14 @@ 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.core.exception.UnrecognizedQueryParamException; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.dds.accounting.DdsLedger; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService; import org.apache.fineract.portfolio.dds.party.data.PartyData; +import org.apache.fineract.portfolio.dds.party.domain.Party; +import org.apache.fineract.portfolio.dds.party.domain.PartyRepository; +import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException; import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService; import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService; import org.springframework.stereotype.Component; @@ -67,6 +75,8 @@ public class PartiesApiResource { * the account it opens and the entries it writes are their own record. */ private final PartyWritePlatformService partyWritePlatformService; + private final PartyRepository partyRepository; + private final BatchRunWritePlatformService batchRunWritePlatformService; @GET @Consumes({ MediaType.APPLICATION_JSON }) @@ -154,6 +164,30 @@ public class PartiesApiResource { return ddsLedger.sundryAccounts(); } + @GET + @Path("{partyId}/interestdue") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Interest a DDS account has earned but not yet been credited", + description = "Interest calculated or still to calculate through the day before the business date - what an " + + "early closure today credits before it settles. Read only; nothing is posted.") + public Map retrieveInterestDue(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final Party party = partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId)); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + // The closing day earns nothing - the money leaves that day - so interest is owed through the day before. + final LocalDate through = businessDate.minusDays(1); + final BigDecimal due = batchRunWritePlatformService.interestNotYetPosted(party, through); + final Map result = new LinkedHashMap<>(); + result.put("businessDate", businessDate.toString()); + result.put("through", through.toString()); + result.put("creditedThrough", party.getInterestDate() == null ? null : party.getInterestDate().toString()); + result.put("interestDue", due); + // What closing early today charges the agent, worked out as the closure works it out. + result.put("agentPenalty", partyWritePlatformService.previewAgentPenalty(partyId)); + return result; + } + @GET @Path("{partyId}/closure") @Consumes({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformService.java index e9112c08e..ab6492099 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformService.java @@ -96,4 +96,11 @@ public interface PartyWritePlatformService { * scheme's lock-in is not changed; only this account is let through it. */ CommandProcessingResult prematureCloseWithinLockin(Long partyId, JsonCommand command); + + /** + * What closing this account early today would charge its agent - read only: the account's collection, + * the scheme's penalty rate, the agent's commission rate and what the account earned them, and the + * penalty, which is never more than that. + */ + java.util.Map previewAgentPenalty(Long partyId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java index 1ba764ca3..69a82c00f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java @@ -27,7 +27,6 @@ import java.math.MathContext; import java.math.RoundingMode; import java.time.LocalDate; import java.time.Period; -import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.ArrayList; import java.util.List; @@ -918,25 +917,11 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla // account agree on where the date comes from and on the format it arrives in. final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate"); - // The interest has to be brought up to date by the branch before the account is closed early: - // calculated and posted through the closing day, so the passbook shows what was earned before - // what is given back. Closing is refused until then, and says which runs are missing. // The money leaves on the closing day, so that day earns nothing: interest is owed through the day before. final LocalDate lastEarningDay = closeDate.minusDays(1); - final BigDecimal interestOwed = this.batchRunWritePlatformService.interestNotYetPosted(party, lastEarningDay); - if (interestOwed.compareTo(BigDecimal.ZERO) > 0) { - final boolean calculatedOnly = party.accruedInterest(false) != null - && party.accruedInterest(false).compareTo(interestOwed) == 0; - // A rule the request does not meet, not a permission it lacks - so a validation error. - final String message = "Interest of ₹" + interestOwed.setScale(2, RoundingMode.HALF_UP) + " on " + party.getPartyCode() - + " is not posted yet. " - + (calculatedOnly ? "Run Interest Posting" : "Run Interest Calculation and then Interest Posting") + " for " - + closeDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) + ", then close the account."; - throw new PlatformApiDataValidationException("error.msg.dds.party.interest.not.posted", message, - List.of(ApiParameterError.generalError("error.msg.dds.party.interest.not.posted", message))); - } - - // Nothing is left to credit by now; kept so a rounding remainder is never lost. + // Interest still owed through that day - calculated by a run but not posted, or not calculated at + // all - is worked out and credited here first, so the settlement below starts from everything + // earned. The passbook shows it as its own line before the recovery or top-up. this.batchRunWritePlatformService.postInterestThrough(party, lastEarningDay); final boolean customerBears = CUSTOMER_BEARS.equals(command.stringValueOfParameterNamed(SHORTFALL_BORNE_BY)); @@ -1017,7 +1002,9 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla } final String description = "DDS premature closure interest recovery for " + party.getPartyCode(); - this.ddsLedger.reverseInterest(party, closeDate, recovered, description); + // Booked as the bank's income for the closure, not as interest never paid: the interest was + // earned and paid at the time, and what comes back now is what closing early costs the customer. + this.ddsLedger.recoverPrematureInterest(party, closeDate, recovered, description); final BigDecimal balanceAfter = party.debit(recovered); this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), closeDate, PREMATURE_INTEREST_RECOVERY, DIRECTION_DEBIT, null, null, null, recovered, null, null, @@ -1055,8 +1042,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla DIRECTION_CREDIT, null, null, null, topUp, null, null, remainingDays + " days of " + closeDate.getMonth() + " funded by " + party.getAgent().getAgentCode(), balanceAfter)); - party.getAgent().chargePrematureClosure(topUp); - this.agentRepository.saveAndFlush(party.getAgent()); + recordAgentCharge(party, CHARGE_INTEREST_TOPUP, topUp, closeDate); return topUp; } @@ -1064,26 +1050,80 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla * The agent's share, charged whichever way the interest was settled: an account closed early is a * round they did not keep going. * - * Taken on what they are owed now rather than on this account's own collections, because - * commission is earned per agent over a period and was never broken down per account - there is - * no per-account figure to take a percentage of. + * Taken on this account alone, as a clawback of the commission it earned them - never on what the + * agent is owed for everyone else, which would make one small account cost a busy agent more than it + * ever earned them, and cost nothing on the day after their commission was paid. */ private BigDecimal chargeAgentCommission(final Party party, final DdsAccountType scheme) { - final BigDecimal penaltyRate = scheme.getPrematureAgentCommissionPenalty(); - final Agent agent = party.getAgent(); - if (penaltyRate == null || penaltyRate.compareTo(BigDecimal.ZERO) <= 0 || agent == null - || agent.getCommission().compareTo(BigDecimal.ZERO) <= 0) { + if (party.getAgent() == null) { return BigDecimal.ZERO; } - final BigDecimal penalty = agent.getCommission().multiply(penaltyRate).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP); + final BigDecimal penalty = (BigDecimal) agentPenalty(party, scheme).get("penalty"); if (penalty.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.ZERO; } - agent.chargePrematureClosure(penalty); - this.agentRepository.saveAndFlush(agent); + recordAgentCharge(party, CHARGE_PENALTY, penalty, DateUtils.getBusinessLocalDate()); return penalty; } + private static final String ACCOUNT_COLLECTION_SQL = "select coalesce(sum(amount), 0) from m_dds_party_transaction " + + "where party_id = ? and transaction_type = 'PARTY_TRAN' and direction = 'CREDIT'"; + + /** The rate the agent's commission was last worked out at - their slab, as the last Commission Calculation found it. */ + private static final String AGENT_COMMISSION_RATE_SQL = "select r.rate_percent from m_dds_batch_run_row r " + + "join m_dds_batch_run b on b.id = r.batch_run_id where r.agent_id = ? and b.job_type = 'COMMISSION_CALCULATION' " + + "and r.rate_percent is not null order by b.id desc limit 1"; + + /** + * The agent's premature-closure penalty on one account: the scheme's penalty rate on what was collected + * on it, and never more than the commission that collection earned them at their slab rate. An agent + * whose commission has not been worked out yet has no rate to cap by, and pays the scheme's rate. + */ + private Map agentPenalty(final Party party, final DdsAccountType scheme) { + final BigDecimal collected = this.jdbcTemplate.queryForObject(ACCOUNT_COLLECTION_SQL, BigDecimal.class, party.getId()); // NOSONAR + final BigDecimal collection = collected == null ? BigDecimal.ZERO : collected; + final BigDecimal penaltyRate = scheme.getPrematureAgentCommissionPenalty() == null ? BigDecimal.ZERO + : scheme.getPrematureAgentCommissionPenalty(); + final List rates = party.getAgent() == null ? List.of() + : this.jdbcTemplate.queryForList(AGENT_COMMISSION_RATE_SQL, BigDecimal.class, party.getAgent().getId()); // NOSONAR + final BigDecimal commissionRate = rates.isEmpty() ? null : rates.get(0); + + BigDecimal penalty = collection.multiply(penaltyRate).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP); + final BigDecimal earned = commissionRate == null ? null + : collection.multiply(commissionRate).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP); + if (earned != null && penalty.compareTo(earned) > 0) { + penalty = earned; + } + final Map result = new HashMap<>(); + result.put("collection", collection); + result.put("penaltyRate", penaltyRate); + result.put("commissionRate", commissionRate); + result.put("commissionEarned", earned); + result.put("penalty", penalty.max(BigDecimal.ZERO)); + return result; + } + + @Override + public Map previewAgentPenalty(final Long partyId) { + final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId)); + if (party.getAgent() == null) { + return Map.of("penalty", BigDecimal.ZERO); + } + return agentPenalty(party, schemeOf(party)); + } + + private static final String CHARGE_PENALTY = "PENALTY"; + private static final String CHARGE_INTEREST_TOPUP = "INTEREST_TOPUP"; + + /** + * Holds a charge against the agent until a Commission Calculation takes it off their commission - + * once. It used to go onto the agent's standing deduction, which every run deducts again. + */ + private void recordAgentCharge(final Party party, final String chargeType, final BigDecimal amount, final LocalDate chargedOn) { + this.jdbcTemplate.update("insert into m_dds_agent_premature_charge (agent_id, party_id, charge_type, amount, charged_on) " // NOSONAR + + "values (?, ?, ?, ?, ?)", party.getAgent().getId(), party.getId(), chargeType, amount, chargedOn); + } + /** * What is left after the charge goes out the same way a matured payout does - to another account * of the customer's, or into matured-deposits payable when they have none. diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index ffc4d2d5b..b518e4960 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -199,4 +199,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0629_dds_premature_income.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0629_dds_premature_income.xml new file mode 100644 index 000000000..fbb8fdaca --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0629_dds_premature_income.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +