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
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:
+6
@@ -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;
|
||||
}
|
||||
|
||||
+22
@@ -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");
|
||||
|
||||
+94
-1
@@ -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.
|
||||
*
|
||||
* <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 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<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. */
|
||||
public GLAccount depositLiabilityOf(final Party party) {
|
||||
|
||||
+9
-2
@@ -47,7 +47,7 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
private static final Set<String> 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();
|
||||
|
||||
+13
-2
@@ -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();
|
||||
|
||||
+5
@@ -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);
|
||||
}
|
||||
|
||||
+15
-4
@@ -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<Map<String, Object>> 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));
|
||||
}
|
||||
|
||||
+65
-1
@@ -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<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,
|
||||
"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) {
|
||||
|
||||
+34
@@ -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<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
|
||||
@Path("{partyId}/closure")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
|
||||
+7
@@ -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<String, Object> previewAgentPenalty(Long partyId);
|
||||
}
|
||||
|
||||
+71
-31
@@ -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<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
|
||||
* 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/0627_dds_party_collection_index.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>
|
||||
|
||||
+102
@@ -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>
|
||||
Reference in New Issue
Block a user