feat(dds): premature closure posts owed interest, account-based agent penalty, TDS and income GLs
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run

- Premature closure credits the interest still owed through the day before
  closing itself instead of refusing; GET /dds/parties/{id}/interestdue
  previews it with the agent penalty, and pending approvals show the payout
  including it.
- Interest a customer gives back on closing early is booked to the product's
  new Premature Closure Income GL.
- Agent penalty is the scheme's rate on that account's own collection, capped
  at the commission it earned them, instead of a share of everything the agent
  is owed. Charges are held once in m_dds_agent_premature_charge rather than
  as a standing deduction every commission run took again; a commission
  calculation recovers them and its posting settles them - the penalty as
  reversed commission expense, funded interest moved back from interest
  expense.
- Commission posting books the TDS withheld (194H) to the product's new TDS
  Payable GL.
- Migration 0629.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 12:43:07 +05:30
co-authored by Claude Opus 5.5
parent 6508dbed67
commit 37503b541c
13 changed files with 444 additions and 41 deletions
@@ -65,6 +65,12 @@ public final class DdsAccountTypeData implements Serializable {
private final Long commissionExpenseGlId; private final Long commissionExpenseGlId;
private final String commissionExpenseGlCode; private final String commissionExpenseGlCode;
private final String commissionExpenseGlName; 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 createdDate;
private final LocalDateTime lastModifiedDate; private final LocalDateTime lastModifiedDate;
} }
@@ -133,6 +133,17 @@ public class DdsAccountType extends AbstractAuditableCustom {
@Column(name = "commission_expense_gl_id") @Column(name = "commission_expense_gl_id")
private Long commissionExpenseGlId; 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() { protected DdsAccountType() {
// //
} }
@@ -152,6 +163,8 @@ public class DdsAccountType extends AbstractAuditableCustom {
this.depositLiabilityGlId = command.longValueOfParameterNamed("depositLiabilityGlId"); this.depositLiabilityGlId = command.longValueOfParameterNamed("depositLiabilityGlId");
this.interestExpenseGlId = command.longValueOfParameterNamed("interestExpenseGlId"); this.interestExpenseGlId = command.longValueOfParameterNamed("interestExpenseGlId");
this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId"); 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. // A newly created account type is available for use straight away.
this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active"); this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active");
} }
@@ -275,6 +288,15 @@ public class DdsAccountType extends AbstractAuditableCustom {
this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId"); this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId");
actualChanges.put("commissionExpenseGlId", this.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)) { if (command.isChangeInBooleanParameterNamed("active", this.active)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active"); final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
@@ -238,6 +238,89 @@ public class DdsLedger {
ddsPair(party, date, amount, depositLiabilityOf(party), interestExpenseOf(party), description); 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.
*
* <pre>
* DR DDS Customer Deposits the customer's balance comes down
* CR Premature Closure Income the product's premature-closure income
* </pre>
*/
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:
*
* <pre>
* DR Agent Commission Expense
* CR Interest Expense
* </pre>
*/
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.
*
* <pre>
* DR Agent Commission Expense
* CR TDS Payable
* </pre>
*/
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<Map<String, Object>> 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<String, Object> row : basis) {
total = total.add((BigDecimal) row.get("volume"));
}
BigDecimal allotted = BigDecimal.ZERO;
for (int i = 0; i < basis.size(); i++) {
final Map<String, Object> 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 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. * 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. */ /** Columns of m_dds_account_type a product's account may be read from - never anything a caller typed. */
private static final Set<String> PRODUCT_GL_COLUMNS = Set.of("deposit_liability_gl_id", "interest_expense_gl_id", private static final Set<String> 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. */ /** What the bank owes this party - their product's customer-deposits account. */
public GLAccount depositLiabilityOf(final Party party) { public GLAccount depositLiabilityOf(final Party party) {
@@ -47,7 +47,7 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode", private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode",
"accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active", "accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active",
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate", "prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate",
"depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId", "depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId", "prematureIncomeGlId", "tdsPayableGlId",
"dateFormat", "locale")); "dateFormat", "locale"));
/** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */ /** 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(); .notNull().integerGreaterThanZero();
v.reset().parameter("commissionExpenseGlId").value(this.fromApiJsonHelper.extractLongNamed("commissionExpenseGlId", element)) v.reset().parameter("commissionExpenseGlId").value(this.fromApiJsonHelper.extractLongNamed("commissionExpenseGlId", element))
.notNull().integerGreaterThanZero(); .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. // Mandatory: every account opened on the scheme takes this rate.
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element)) v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED); .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. // 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)) { if (this.fromApiJsonHelper.parameterExists(required, element)) {
v.reset().parameter(required).value(this.fromApiJsonHelper.extractLongNamed(required, element)).notNull() v.reset().parameter(required).value(this.fromApiJsonHelper.extractLongNamed(required, element)).notNull()
.integerGreaterThanZero(); .integerGreaterThanZero();
@@ -56,11 +56,16 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
+ " t.interest_expense_gl_id as interestExpenseGlId, ie.gl_code as interestExpenseGlCode, " + " t.interest_expense_gl_id as interestExpenseGlId, ie.gl_code as interestExpenseGlCode, "
+ " ie.name as interestExpenseGlName, " + " ie.name as interestExpenseGlName, "
+ " t.commission_expense_gl_id as commissionExpenseGlId, ce.gl_code as commissionExpenseGlCode, " + " 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 " + " 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 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 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 @Override
@@ -87,6 +92,12 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
.commissionExpenseGlId(JdbcSupport.getLong(rs, "commissionExpenseGlId")) // .commissionExpenseGlId(JdbcSupport.getLong(rs, "commissionExpenseGlId")) //
.commissionExpenseGlCode(rs.getString("commissionExpenseGlCode")) // .commissionExpenseGlCode(rs.getString("commissionExpenseGlCode")) //
.commissionExpenseGlName(rs.getString("commissionExpenseGlName")) // .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")) // .createdDate(localDateTime(rs, "createdDate")) //
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) // .lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
.build(); .build();
@@ -56,6 +56,7 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
/** acc_gl_account.classification_enum: 2 liability, 5 expense. */ /** acc_gl_account.classification_enum: 2 liability, 5 expense. */
private static final int LIABILITY = 2; private static final int LIABILITY = 2;
private static final int INCOME = 4;
private static final int EXPENSE = 5; private static final int EXPENSE = 5;
/** acc_gl_account.account_usage 1 - a detail account an entry can be posted to, not a header. */ /** acc_gl_account.account_usage 1 - a detail account an entry can be posted to, not a header. */
private static final int DETAIL = 1; 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, "depositLiabilityGlId", "Customer Deposits", List.of(LIABILITY), errors);
checkLedgerAccount(command, "interestExpenseGlId", "Interest Expense", List.of(EXPENSE), errors); checkLedgerAccount(command, "interestExpenseGlId", "Interest Expense", List.of(EXPENSE), errors);
checkLedgerAccount(command, "commissionExpenseGlId", "Agent Commission 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()) { if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
} }
@@ -36,7 +36,9 @@ import org.apache.fineract.commands.exception.CommandNotFoundException;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger; import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException; 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.calculation.DdsInterestCalculation;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -72,6 +74,8 @@ public class DdsApprovalReadService {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final DdsLedger ddsLedger; private final DdsLedger ddsLedger;
private final PartyRepository partyRepository;
private final BatchRunWritePlatformService batchRunWritePlatformService;
public List<Map<String, Object>> list(final String status, final String fromDate, final String toDate) { public List<Map<String, Object>> list(final String status, final String fromDate, final String toDate) {
final StringBuilder sql = new StringBuilder(ENTRIES_SQL).append(" and c.status = ? "); 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", new ProductField("deposit_liability_gl_id", "depositLiabilityGlId", "DDS Customer Deposits (Liability)", "gl",
GROUP_ACCOUNTING), // GROUP_ACCOUNTING), //
new ProductField("interest_expense_gl_id", "interestExpenseGlId", "Interest Expense", "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"; private static final String GROUP_SLAB = "Commission Slab";
@@ -1097,8 +1103,13 @@ public class DdsApprovalReadService {
if (found.isEmpty()) { if (found.isEmpty()) {
return null; return null;
} }
final BigDecimal balance = (BigDecimal) found.get(0).get("balance");
final BigDecimal rate = (BigDecimal) found.get(0).get("rate"); 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) { if (balance == null || rate == null || rate.signum() <= 0) {
return balance; return balance;
} }
@@ -1107,10 +1118,11 @@ public class DdsApprovalReadService {
if (reduction == null || reduction.signum() <= 0) { if (reduction == null || reduction.signum() <= 0) {
return balance; 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 " "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')", + "where party_id = ? and transaction_type in ('INTEREST_POST', 'PREMATURE_INT_RECOVERY', 'PREMATURE_AGENT_TOPUP')",
BigDecimal.class, partyId); BigDecimal.class, partyId);
final BigDecimal credited = creditedSoFar == null ? owed : creditedSoFar.add(owed);
if (credited == null || credited.signum() <= 0) { if (credited == null || credited.signum() <= 0) {
return balance; return balance;
} }
@@ -1118,7 +1130,6 @@ public class DdsApprovalReadService {
RoundingMode.HALF_UP); RoundingMode.HALF_UP);
return balance.subtract(recovered); return balance.subtract(recovered);
} }
final LocalDate closeDate = DateUtils.getBusinessLocalDate();
final int remainingDays = closeDate.lengthOfMonth() - closeDate.getDayOfMonth() + 1; final int remainingDays = closeDate.lengthOfMonth() - closeDate.getDayOfMonth() + 1;
return balance.add(DdsInterestCalculation.interestOn(balance.multiply(BigDecimal.valueOf(remainingDays)), rate)); return balance.add(DdsInterestCalculation.interestOn(balance.multiply(BigDecimal.valueOf(remainingDays)), rate));
} }
@@ -513,7 +513,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(collectionVolume, final DdsCommissionCalculation commission = DdsCommissionCalculation.of(collectionVolume,
matchingSlabs.get(0).getCommissionPercent(), agent.isTdsApplicable(), agent.getTdsPercent(), matchingSlabs.get(0).getCommissionPercent(), agent.isTdsApplicable(), agent.getTdsPercent(),
agent.totalDeductions()); agent.totalDeductions().add(prematureChargesOutstanding(agent.getId())));
if (!commission.isPayable()) { if (!commission.isPayable()) {
// The deductions have swallowed this period's commission. Nothing is owed, and the // 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 // 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(); final BigDecimal tds = row.getTdsAmount() == null ? BigDecimal.ZERO : row.getTdsAmount();
agent.accrueCommission(row.getAmount(), tds, jobDate); 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<String, Object> 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<String, Object> 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, this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount,
"DDS commission, batch run #" + batchRun.getId(), shares); "DDS commission, batch run #" + batchRun.getId(), shares);
this.ddsLedger.markCommissionPaid(agent.getId(), batchRun.getId()); 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. */ /** Fineract's savings statuses, named the way the screens name them. */
private String statusName(final int status) { private String statusName(final int status) {
return switch (status) { return switch (status) {
@@ -30,7 +30,10 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection; import java.util.Collection;
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;
@@ -40,9 +43,14 @@ import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; 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.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger; 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.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.PartyReadPlatformService;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService; import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.stereotype.Component; 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. * the account it opens and the entries it writes are their own record.
*/ */
private final PartyWritePlatformService partyWritePlatformService; private final PartyWritePlatformService partyWritePlatformService;
private final PartyRepository partyRepository;
private final BatchRunWritePlatformService batchRunWritePlatformService;
@GET @GET
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -154,6 +164,30 @@ public class PartiesApiResource {
return ddsLedger.sundryAccounts(); 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<String, Object> 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<String, Object> 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 @GET
@Path("{partyId}/closure") @Path("{partyId}/closure")
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -96,4 +96,11 @@ public interface PartyWritePlatformService {
* scheme's lock-in is not changed; only this account is let through it. * scheme's lock-in is not changed; only this account is let through it.
*/ */
CommandProcessingResult prematureCloseWithinLockin(Long partyId, JsonCommand command); 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<String, Object> previewAgentPenalty(Long partyId);
} }
@@ -27,7 +27,6 @@ import java.math.MathContext;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.Period; import java.time.Period;
import java.time.format.DateTimeFormatter;
import java.util.HashMap; import java.util.HashMap;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; 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. // account agree on where the date comes from and on the format it arrives in.
final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate"); 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. // 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 LocalDate lastEarningDay = closeDate.minusDays(1);
final BigDecimal interestOwed = this.batchRunWritePlatformService.interestNotYetPosted(party, lastEarningDay); // Interest still owed through that day - calculated by a run but not posted, or not calculated at
if (interestOwed.compareTo(BigDecimal.ZERO) > 0) { // all - is worked out and credited here first, so the settlement below starts from everything
final boolean calculatedOnly = party.accruedInterest(false) != null // earned. The passbook shows it as its own line before the recovery or top-up.
&& 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.
this.batchRunWritePlatformService.postInterestThrough(party, lastEarningDay); this.batchRunWritePlatformService.postInterestThrough(party, lastEarningDay);
final boolean customerBears = CUSTOMER_BEARS.equals(command.stringValueOfParameterNamed(SHORTFALL_BORNE_BY)); 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(); 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); final BigDecimal balanceAfter = party.debit(recovered);
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), closeDate, PREMATURE_INTEREST_RECOVERY, this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), closeDate, PREMATURE_INTEREST_RECOVERY,
DIRECTION_DEBIT, null, null, null, recovered, null, null, 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, DIRECTION_CREDIT, null, null, null, topUp, null, null,
remainingDays + " days of " + closeDate.getMonth() + " funded by " + party.getAgent().getAgentCode(), balanceAfter)); remainingDays + " days of " + closeDate.getMonth() + " funded by " + party.getAgent().getAgentCode(), balanceAfter));
party.getAgent().chargePrematureClosure(topUp); recordAgentCharge(party, CHARGE_INTEREST_TOPUP, topUp, closeDate);
this.agentRepository.saveAndFlush(party.getAgent());
return topUp; 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 * The agent's share, charged whichever way the interest was settled: an account closed early is a
* round they did not keep going. * round they did not keep going.
* *
* Taken on what they are owed now rather than on this account's own collections, because * Taken on this account alone, as a clawback of the commission it earned them - never on what the
* commission is earned per agent over a period and was never broken down per account - there is * agent is owed for everyone else, which would make one small account cost a busy agent more than it
* no per-account figure to take a percentage of. * ever earned them, and cost nothing on the day after their commission was paid.
*/ */
private BigDecimal chargeAgentCommission(final Party party, final DdsAccountType scheme) { private BigDecimal chargeAgentCommission(final Party party, final DdsAccountType scheme) {
final BigDecimal penaltyRate = scheme.getPrematureAgentCommissionPenalty(); if (party.getAgent() == null) {
final Agent agent = party.getAgent();
if (penaltyRate == null || penaltyRate.compareTo(BigDecimal.ZERO) <= 0 || agent == null
|| agent.getCommission().compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO; 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) { if (penalty.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO; return BigDecimal.ZERO;
} }
agent.chargePrematureClosure(penalty); recordAgentCharge(party, CHARGE_PENALTY, penalty, DateUtils.getBusinessLocalDate());
this.agentRepository.saveAndFlush(agent);
return penalty; 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<String, Object> 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<BigDecimal> 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<String, Object> 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<String, Object> 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 * 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. * of the customer's, or into matured-deposits payable when they have none.
@@ -199,4 +199,5 @@
<include file="parts/0625_dds_default_commission_slabs.xml" relativeToChangelogFile="true"/> <include file="parts/0625_dds_default_commission_slabs.xml" relativeToChangelogFile="true"/>
<include file="parts/0627_dds_party_collection_index.xml" relativeToChangelogFile="true"/> <include file="parts/0627_dds_party_collection_index.xml" relativeToChangelogFile="true"/>
<include file="parts/0628_dds_party_matured_and_premature_status.xml" relativeToChangelogFile="true"/> <include file="parts/0628_dds_party_matured_and_premature_status.xml" relativeToChangelogFile="true"/>
<include file="parts/0629_dds_premature_income.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Two more accounts on each DDS product: Premature Closure Income, where the interest a customer gives
back on closing early is booked, and TDS Payable, where the TDS withheld from the agents' commission
(Section 194H) is owed until it is paid over.
-->
<changeSet id="0629_dds_premature_income_gl" author="CBS">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_dds_account_type" columnName="premature_income_gl_id"/>
</not>
</preConditions>
<addColumn tableName="m_dds_account_type">
<column name="premature_income_gl_id" type="BIGINT"/>
</addColumn>
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="premature_income_gl_id"
constraintName="fk_dds_account_type_premature_income_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
</changeSet>
<!-- Kept apart from the change above, which databases have already applied. -->
<changeSet id="0629_dds_tds_payable_gl" author="CBS">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_dds_account_type" columnName="tds_payable_gl_id"/>
</not>
</preConditions>
<addColumn tableName="m_dds_account_type">
<column name="tds_payable_gl_id" type="BIGINT"/>
</addColumn>
<addForeignKeyConstraint baseTableName="m_dds_account_type" baseColumnNames="tds_payable_gl_id"
constraintName="fk_dds_account_type_tds_payable_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
</changeSet>
<!--
Each charge an early closure lays on an agent, held once rather than as a standing deduction - a
standing deduction is taken again by every commission run. A Commission Calculation takes the
charges from the commission it accrues (recovered_batch_run_id); the Commission Posting that pays
that commission settles them (booked_batch_run_id). A penalty needs no entry - the commission was
booked net, which reverses that much commission expense; interest the agent funded moves from the
product's Interest Expense to its Agent Commission Expense.
-->
<changeSet id="0629_dds_agent_premature_charge" author="CBS">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_dds_agent_premature_charge"/>
</not>
</preConditions>
<createTable tableName="m_dds_agent_premature_charge">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="agent_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_dds_premature_charge_agent" referencedTableName="m_dds_agent"
referencedColumnNames="id"/>
</column>
<column name="party_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_dds_premature_charge_party" referencedTableName="m_dds_party"
referencedColumnNames="id"/>
</column>
<column name="charge_type" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="charged_on" type="DATE">
<constraints nullable="false"/>
</column>
<column name="recovered_batch_run_id" type="BIGINT"/>
<column name="booked_batch_run_id" type="BIGINT"/>
</createTable>
<createIndex tableName="m_dds_agent_premature_charge" indexName="idx_dds_premature_charge_agent">
<column name="agent_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>