feat(dds): FD-style account statuses, period-end interest, agent summary
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-1) (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 - 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-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 Kafka (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run

- Accounts move ACTIVE -> MATURED (nightly Post DDS Interest job) -> CLOSED on
  maturity payout, or to PREMATURE_CLOSED on early closure; both close the
  account themselves. Open-account queries treat MATURED as open; migration
  0628 brings existing accounts into line.
- Interest counts the opening day, runs through the day before the job date,
  and stops the day before maturity or closure; agent top-up counts the
  closing day.
- Calculation runs are applied as generated and empty runs are not saved.
- GET /dds/parties/summary adds up each agent's book (accounts, matured,
  premature closed, balance, collected, in arrears) with a partial index on
  collections (0627).
- Approvals: commission slab review data, and a pending premature closure
  shows what it will pay out rather than the bare balance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 18:33:06 +05:30
co-authored by Claude Opus 5.5
parent 375c2c95b6
commit 6508dbed67
14 changed files with 462 additions and 52 deletions
@@ -80,6 +80,10 @@ public class Party extends AbstractAuditableCustom {
public static final String STATUS_ACTIVE = "ACTIVE"; public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_CLOSED = "CLOSED"; public static final String STATUS_CLOSED = "CLOSED";
/** Past its maturity date and not yet paid out - set by the nightly Post DDS Interest job, as FD's MATURED. */
public static final String STATUS_MATURED = "MATURED";
/** Closed before maturity, the shortfall settled - FD's Premature Closure. */
public static final String STATUS_PREMATURE_CLOSED = "PREMATURE_CLOSED";
@ManyToOne @ManyToOne
@JoinColumn(name = "client_id", nullable = false) @JoinColumn(name = "client_id", nullable = false)
@@ -566,8 +570,13 @@ public class Party extends AbstractAuditableCustom {
private static final String MINOR = "Minor"; private static final String MINOR = "Minor";
private static final String ADULT = "Adult"; private static final String ADULT = "Adult";
/** Closed either way - paid out at maturity, or broken before it. */
public boolean isClosed() { public boolean isClosed() {
return STATUS_CLOSED.equals(this.status); return STATUS_CLOSED.equals(this.status) || STATUS_PREMATURE_CLOSED.equals(this.status);
}
public boolean isMatured() {
return STATUS_MATURED.equals(this.status);
} }
/** /**
@@ -581,6 +590,12 @@ public class Party extends AbstractAuditableCustom {
this.closedByUserId = closedBy; this.closedByUserId = closedBy;
} }
/** Closed before its term ran out, as a fixed deposit broken early is - kept apart from a maturity close. */
public void closePremature(final LocalDate closeDate, final Long closedBy) {
close(closeDate, closedBy);
this.status = STATUS_PREMATURE_CLOSED;
}
/** Ties the account to its product by id; called whenever subType is set or changed. */ /** Ties the account to its product by id; called whenever subType is set or changed. */
public void linkProduct(final Long productId) { public void linkProduct(final Long productId) {
this.accountTypeId = productId; this.accountTypeId = productId;
@@ -22,6 +22,9 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@@ -30,8 +33,10 @@ import java.util.Objects;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException; import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException;
import org.apache.fineract.commands.exception.CommandNotFoundException; 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.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.calculation.DdsInterestCalculation;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -103,6 +108,9 @@ public class DdsApprovalReadService {
if (isAgentRequest(row)) { if (isAgentRequest(row)) {
row.put("guarantors", guarantorsOf(jsonOf(found.get(0).get("json")))); row.put("guarantors", guarantorsOf(jsonOf(found.get(0).get("json"))));
} }
if ("COMMISSIONSLAB".equals(row.get("entity"))) {
row.put("slabChart", slabChart(row, jsonOf(found.get(0).get("json"))));
}
if (isPartyRequest(row)) { if (isPartyRequest(row)) {
final JsonObject partyJson = jsonOf(found.get(0).get("json")); final JsonObject partyJson = jsonOf(found.get(0).get("json"));
row.put("nominees", nomineesOf(partyJson)); row.put("nominees", nomineesOf(partyJson));
@@ -432,6 +440,118 @@ public class DdsApprovalReadService {
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));
private static final String GROUP_SLAB = "Commission Slab";
/** A commission slab's terms, in the order the Commission Slab Master asks for them. */
private static final List<ProductField> SLAB_FIELDS = List.of( //
new ProductField("slab_type", "slabType", "Slab Type", "slabtype", GROUP_SLAB), //
new ProductField("slab_from", "slabFrom", "Collection From", "money", GROUP_SLAB), //
new ProductField("slab_to", "slabTo", "Collection To", "money", GROUP_SLAB), //
new ProductField("commission_percent", "commissionPercent", "Commission", "percent", GROUP_SLAB));
/** The slab a request names - what it asks for, and what it leaves out as the slab stands. */
private Map<String, Object> slabColumns(final JsonObject json, final Long resourceId) {
final Map<String, Object> stored = resourceId == null ? Map.of()
: this.jdbcTemplate.queryForList("select * from m_dds_commission_slab where id = ?", resourceId).stream() // NOSONAR
.findFirst().orElse(Map.of());
final Map<String, Object> columns = new LinkedHashMap<>();
columns.put("slabType", text(json, "slabType") != null ? text(json, "slabType") : stored.get("slab_type"));
columns.put("slabFrom", number(json, "slabFrom") != null ? number(json, "slabFrom") : stored.get("slab_from"));
columns.put("slabTo", number(json, "slabTo") != null ? number(json, "slabTo") : stored.get("slab_to"));
columns.put("commissionPercent",
number(json, "commissionPercent") != null ? number(json, "commissionPercent") : stored.get("commission_percent"));
return columns;
}
/** "Account · ₹50,001 – ₹1,00,000 · 2%" - what the slab is, from the request or, failing that, the slab itself. */
private String slabSummary(final JsonObject json, final Long resourceId) {
final Map<String, Object> slab = slabColumns(json, resourceId);
final String type = (String) slab.get("slabType");
final BigDecimal from = (BigDecimal) slab.get("slabFrom");
final BigDecimal to = (BigDecimal) slab.get("slabTo");
final BigDecimal rate = (BigDecimal) slab.get("commissionPercent");
if (from == null && to == null && rate == null) {
return resourceId == null ? "" : "Slab #" + resourceId;
}
return format(type, "slabtype") + " · " + format(from == null ? null : from.toPlainString(), "money") + " – "
+ format(to == null ? null : to.toPlainString(), "money") + " · "
+ format(rate == null ? null : rate.toPlainString(), "percent");
}
/**
* Every slab of the request's type, as the chart will read once it is approved: the slab asked for
* marked, a slab it overlaps flagged, so the checker sees whether the chart still has one rate for
* every collection volume.
*/
private List<Map<String, Object>> slabChart(final Map<String, Object> row, final JsonObject json) {
final Long resourceId = (Long) row.get("resourceId");
final boolean pending = "PENDING".equals(row.get("status"));
final Map<String, Object> stored = resourceId == null ? Map.of()
: this.jdbcTemplate.queryForList("select * from m_dds_commission_slab where id = ?", resourceId).stream() // NOSONAR
.findFirst().orElse(Map.of());
final String type = text(json, "slabType") != null ? text(json, "slabType") : (String) stored.get("slab_type");
if (type == null) {
return List.of();
}
final List<Map<String, Object>> chart = new ArrayList<>();
BigDecimal from = null;
BigDecimal to = null;
for (final Map<String, Object> each : this.jdbcTemplate.queryForList( // NOSONAR
"select id, slab_from, slab_to, commission_percent from m_dds_commission_slab where slab_type = ? order by slab_from, id",
type)) {
final Long id = ((Number) each.get("id")).longValue();
final Map<String, Object> line = new LinkedHashMap<>();
line.put("id", id);
line.put("from", each.get("slab_from"));
line.put("to", each.get("slab_to"));
line.put("rate", each.get("commission_percent"));
if (id.equals(resourceId)) {
line.put("requested", true);
if (pending) {
// As it will stand once approved, with what it was beside it.
line.put("was", Map.of("from", each.get("slab_from"), "to", each.get("slab_to"), "rate", each.get("commission_percent")));
putIfGiven(line, "from", number(json, "slabFrom"));
putIfGiven(line, "to", number(json, "slabTo"));
putIfGiven(line, "rate", number(json, "commissionPercent"));
}
from = (BigDecimal) line.get("from");
to = (BigDecimal) line.get("to");
}
chart.add(line);
}
if (resourceId == null || stored.isEmpty()) {
// A new slab still waiting: not in the table yet, so placed where it will sit.
final Map<String, Object> line = new LinkedHashMap<>();
line.put("id", null);
line.put("from", number(json, "slabFrom"));
line.put("to", number(json, "slabTo"));
line.put("rate", number(json, "commissionPercent"));
line.put("requested", true);
from = (BigDecimal) line.get("from");
to = (BigDecimal) line.get("to");
chart.add(line);
chart.sort((a, b) -> a.get("from") == null || b.get("from") == null ? 0
: ((BigDecimal) a.get("from")).compareTo((BigDecimal) b.get("from")));
}
if (from != null && to != null) {
for (final Map<String, Object> line : chart) {
if (Boolean.TRUE.equals(line.get("requested")) || line.get("from") == null || line.get("to") == null) {
continue;
}
final boolean overlaps = ((BigDecimal) line.get("from")).compareTo(to) <= 0 && ((BigDecimal) line.get("to")).compareTo(from) >= 0;
line.put("overlaps", overlaps);
}
}
return chart;
}
private static void putIfGiven(final Map<String, Object> line, final String key, final BigDecimal value) {
if (value != null) {
line.put(key, value);
}
}
/** /**
* What an entry that moves no money actually asks for, field by field, so the checker can read the * What an entry that moves no money actually asks for, field by field, so the checker can read the
* terms they are approving rather than only a code. * terms they are approving rather than only a code.
@@ -447,19 +567,21 @@ public class DdsApprovalReadService {
if (isPartyRequest(row)) { if (isPartyRequest(row)) {
return partyFieldsFor(jsonOf(entry.get("json"))); return partyFieldsFor(jsonOf(entry.get("json")));
} }
if (!"DDSACCOUNTTYPE".equals(row.get("entity"))) { final boolean slab = "COMMISSIONSLAB".equals(row.get("entity"));
if (!slab && !"DDSACCOUNTTYPE".equals(row.get("entity"))) {
return List.of(); return List.of();
} }
final String table = slab ? "m_dds_commission_slab" : "m_dds_account_type";
final JsonObject json = jsonOf(entry.get("json")); final JsonObject json = jsonOf(entry.get("json"));
final Long resourceId = (Long) row.get("resourceId"); final Long resourceId = (Long) row.get("resourceId");
final boolean compare = "UPDATE".equals(row.get("action")) && "PENDING".equals(row.get("status")) && resourceId != null; final boolean compare = "UPDATE".equals(row.get("action")) && "PENDING".equals(row.get("status")) && resourceId != null;
final Map<String, Object> current = compare final Map<String, Object> current = compare
? this.jdbcTemplate.queryForList("select * from m_dds_account_type where id = ?", resourceId).stream().findFirst() // NOSONAR ? this.jdbcTemplate.queryForList("select * from " + table + " where id = ?", resourceId).stream().findFirst() // NOSONAR
.orElse(Map.of()) .orElse(Map.of())
: Map.of(); : Map.of();
final List<Map<String, Object>> fields = new ArrayList<>(); final List<Map<String, Object>> fields = new ArrayList<>();
for (final ProductField field : PRODUCT_FIELDS) { for (final ProductField field : slab ? SLAB_FIELDS : PRODUCT_FIELDS) {
final boolean requested = json.has(field.parameter()) && !json.get(field.parameter()).isJsonNull(); final boolean requested = json.has(field.parameter()) && !json.get(field.parameter()).isJsonNull();
final String proposed = requested ? display(json.get(field.parameter()).getAsString(), field.format()) : null; final String proposed = requested ? display(json.get(field.parameter()).getAsString(), field.format()) : null;
final Map<String, Object> out = new LinkedHashMap<>(); final Map<String, Object> out = new LinkedHashMap<>();
@@ -503,10 +625,36 @@ public class DdsApprovalReadService {
case "yesno" -> Boolean.parseBoolean(value.trim()) ? "Yes" : "No"; case "yesno" -> Boolean.parseBoolean(value.trim()) ? "Yes" : "No";
case "percent" -> plain(value) + "%"; case "percent" -> plain(value) + "%";
case "months" -> plain(value) + " month" + ("1".equals(plain(value)) ? "" : "s"); case "months" -> plain(value) + " month" + ("1".equals(plain(value)) ? "" : "s");
case "money" -> rupees(value);
case "slabtype" -> value.trim().isEmpty() ? null
: value.trim().substring(0, 1).toUpperCase() + value.trim().substring(1).toLowerCase();
default -> value.trim(); default -> value.trim();
}; };
} }
/** ₹ with Indian grouping, paise only when there are any: 1000000.01 reads ₹10,00,000.01. */
private static String rupees(final String value) {
final String plain = plain(value);
final boolean negative = plain.startsWith("-");
final String digits = negative ? plain.substring(1) : plain;
final int dot = digits.indexOf('.');
final String whole = dot < 0 ? digits : digits.substring(0, dot);
final String paise = dot < 0 ? "" : "." + (digits.substring(dot + 1) + "0").substring(0, 2);
if (!whole.chars().allMatch(Character::isDigit)) {
return value.trim();
}
final StringBuilder grouped = new StringBuilder();
final int len = whole.length();
for (int i = 0; i < len; i++) {
grouped.append(whole.charAt(i));
final int left = len - i - 1;
if (left > 0 && (left == 3 || (left > 3 && (left - 3) % 2 == 0))) {
grouped.append(',');
}
}
return (negative ? "-₹" : "₹") + grouped + paise;
}
/** 2.000000 reads as 2, and 1.50 as 1.5, so a stored rate and a typed one compare equal. */ /** 2.000000 reads as 2, and 1.50 as 1.5, so a stored rate and a typed one compare equal. */
private static String plain(final String value) { private static String plain(final String value) {
try { try {
@@ -751,7 +899,7 @@ public class DdsApprovalReadService {
if (reason != null) { if (reason != null) {
details = details + " · Reason: " + reason; details = details + " · Reason: " + reason;
} }
amount = status == STATUS_AWAITING ? balanceOf(resourceId) : paidOut(resourceId, "PREMATURE_PAYOUT"); amount = status == STATUS_AWAITING ? prematurePayout(resourceId, json) : paidOut(resourceId, "PREMATURE_PAYOUT");
} }
case "PARTY:CLOSE" -> { case "PARTY:CLOSE" -> {
type = "Close DDS Account"; type = "Close DDS Account";
@@ -784,7 +932,9 @@ public class DdsApprovalReadService {
} }
case "COMMISSIONSLAB:CREATE", "COMMISSIONSLAB:UPDATE" -> { case "COMMISSIONSLAB:CREATE", "COMMISSIONSLAB:UPDATE" -> {
type = "CREATE".equals(action) ? "New Commission Slab" : "Edit Commission Slab"; type = "CREATE".equals(action) ? "New Commission Slab" : "Edit Commission Slab";
details = resourceId == null ? "" : "Slab #" + resourceId; details = slabSummary(json, resourceId);
// The slab in the columns Approvals -> Agent & Slab Approval lists slabs by.
row.putAll(slabColumns(json, resourceId));
} }
case "DDSBATCH:POST" -> { case "DDSBATCH:POST" -> {
final Map<String, Object> run = batchRun(resourceId); final Map<String, Object> run = batchRun(resourceId);
@@ -933,6 +1083,46 @@ public class DdsApprovalReadService {
return accountTypeId == null ? "" : first("select account_type_code from m_dds_account_type where id = ?", accountTypeId); return accountTypeId == null ? "" : first("select account_type_code from m_dds_account_type where id = ?", accountTypeId);
} }
/**
* What a pending premature closure will pay the customer - not the bare balance, which leaves out the
* charge. The same sums the closure makes: the customer bearing it gives back the credited interest
* scaled by the share of the rate given up; the agent bearing it adds interest on the balance from
* the closing day to the month's end.
*/
private BigDecimal prematurePayout(final Long partyId, final JsonObject json) {
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList( // NOSONAR
"select p.current_balance as \"balance\", p.interest_rate as \"rate\", t.premature_interest_reduction as \"reduction\" "
+ "from m_dds_party p left join m_dds_account_type t on t.account_type_code = p.sub_type where p.id = ?",
partyId);
if (found.isEmpty()) {
return null;
}
final BigDecimal balance = (BigDecimal) found.get(0).get("balance");
final BigDecimal rate = (BigDecimal) found.get(0).get("rate");
if (balance == null || rate == null || rate.signum() <= 0) {
return balance;
}
if ("CUSTOMER".equals(text(json, "shortfallBorneBy"))) {
final BigDecimal reduction = (BigDecimal) found.get(0).get("reduction");
if (reduction == null || reduction.signum() <= 0) {
return balance;
}
final BigDecimal credited = 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);
if (credited == null || credited.signum() <= 0) {
return balance;
}
final BigDecimal recovered = credited.multiply(reduction.min(rate)).divide(rate, MathContext.DECIMAL64).setScale(2,
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));
}
private BigDecimal balanceOf(final Long partyId) { private BigDecimal balanceOf(final Long partyId) {
final List<BigDecimal> found = this.jdbcTemplate.queryForList("select current_balance from m_dds_party where id = ?", // NOSONAR final List<BigDecimal> found = this.jdbcTemplate.queryForList("select current_balance from m_dds_party where id = ?", // NOSONAR
BigDecimal.class, partyId); BigDecimal.class, partyId);
@@ -70,7 +70,14 @@ public class PostDdsInterestTasklet implements Tasklet {
log.info("Post DDS Interest: brought {} matured account(s) up to their maturity date as on {}", maturedPosted, businessDate); log.info("Post DDS Interest: brought {} matured account(s) up to their maturity date as on {}", maturedPosted, businessDate);
} }
final int promoted = this.batchRunWritePlatformService.promoteMinorsWhoHaveComeOfAge(businessDate); // After the interest above, so an account matures with its interest brought up to date.
final int matured = this.batchRunWritePlatformService.markMaturedAccounts(businessDate);
if (matured > 0) {
log.info("Post DDS Interest: marked {} account(s) MATURED as on {}", matured, businessDate);
}
final int promoted =this.batchRunWritePlatformService.promoteMinorsWhoHaveComeOfAge(businessDate);
if (promoted == 0) { if (promoted == 0) {
log.debug("Post DDS Interest: no minor's account came of age with KYC verified on {}", businessDate); log.debug("Post DDS Interest: no minor's account came of age with KYC verified on {}", businessDate);
@@ -83,6 +83,13 @@ public interface BatchRunWritePlatformService {
*/ */
int promoteMinorsWhoHaveComeOfAge(LocalDate businessDate); int promoteMinorsWhoHaveComeOfAge(LocalDate businessDate);
/**
* Marks open accounts whose maturity date has come as MATURED, as FD's maturity job does. Run nightly.
*
* @return how many accounts matured.
*/
int markMaturedAccounts(LocalDate businessDate);
/** /**
* Credits every row on a run and marks it posted. Called from the background thread that * Credits every row on a run and marks it posted. Called from the background thread that
* {@code postBatchRun} hands the work to, not from a request. * {@code postBatchRun} hands the work to, not from a request.
@@ -24,6 +24,7 @@ import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
@@ -181,10 +182,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
subType); subType);
switch (jobType) { switch (jobType) {
case INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false, // A run on the screen counts up to the day before its job date: that day is still open, and a
officeNames, subType); // deposit made after the run would otherwise lose its own day, which the next run starts after.
case OD_INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true, case INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate.minusDays(1),
officeNames, subType); false, officeNames, subType);
case OD_INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate.minusDays(1),
true, officeNames, subType);
case INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false, case INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false,
officeNames, subType); officeNames, subType);
case OD_INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true, case OD_INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true,
@@ -196,14 +199,35 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType); default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
} }
// Nothing to calculate or post - the period is already done, or had no activity - so nothing is
// saved: a run with no entries would only sit on the list as a second copy of one already there.
if (batchRun.getRows().isEmpty()) {
final Map<String, Object> nothing = new LinkedHashMap<>();
nothing.put("nothingToApply", true);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).with(nothing).build();
}
// A new preview of the same job replaces the one before it: generating twice used to leave two // A new preview of the same job replaces the one before it: generating twice used to leave two
// identical "not posted" runs on the list, and nobody could tell which one to post. // identical "not posted" runs on the list, and nobody could tell which one to post.
discardSupersededPreviews(jobType, subType); discardSupersededPreviews(jobType, subType);
this.batchRunRepository.saveAndFlush(batchRun); this.batchRunRepository.saveAndFlush(batchRun);
final Map<String, Object> outcome = new LinkedHashMap<>();
if (jobType.endsWith("_CALCULATION")) {
// A calculation books nothing on the ledger - it records what has been earned against each account
// in DDS's own tables - so there is nothing for a checker to approve and nothing to wait for:
// it is applied as it is generated. What it recorded is then paid out by the Posting run, which
// is the step that moves money and the one a checker approves.
applyRows(batchRun);
batchRun.markPosted(LocalDate.now());
this.batchRunRepository.saveAndFlush(batchRun);
outcome.put("calculated", true);
}
return new CommandProcessingResultBuilder() // return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) // .withCommandId(command.commandId()) //
.withEntityId(batchRun.getId()) // .withEntityId(batchRun.getId()) //
.with(outcome) //
.build(); .build();
} }
@@ -265,7 +289,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
/** /**
* Works out each account's interest since it was last calculated, up to the job date. * Works out each account's interest since it was last calculated, up to and including {@code through}.
* *
* A daily-deposit balance is built up a little at a time, so the closing balance says nothing * A daily-deposit balance is built up a little at a time, so the closing balance says nothing
* about how long the money was actually held: an instalment paid on the last day of the month has * about how long the money was actually held: an instalment paid on the last day of the month has
@@ -274,7 +298,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
* the maturity figure quoted at account opening uses, so the two agree. * the maturity figure quoted at account opening uses, so the two agree.
*/ */
private void generateInterestRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom, private void generateInterestRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom,
final Long partyTo, final LocalDate jobDate, final boolean odInterest, final Set<String> officeNames, final Long partyTo, final LocalDate through, final boolean odInterest, final Set<String> officeNames,
final String subType) { final String subType) {
final List<Party> parties = odInterest final List<Party> parties = odInterest
@@ -291,7 +315,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
return; return;
} }
final Map<Long, List<PartyTransaction>> movements = movementsByParty(eligible, jobDate); final Map<Long, List<PartyTransaction>> movements = movementsByParty(eligible, through);
for (final Party party : eligible) { for (final Party party : eligible) {
final BigDecimal rate = party.getInterestRate(); final BigDecimal rate = party.getInterestRate();
@@ -304,12 +328,15 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
final LocalDate accrueFrom = party.interestCalculatedThrough(odInterest) != null final LocalDate accrueFrom = party.interestCalculatedThrough(odInterest) != null
? party.interestCalculatedThrough(odInterest) ? party.interestCalculatedThrough(odInterest)
: party.getOpeningDate(); : party.getOpeningDate();
if (!accrueFrom.isBefore(jobDate)) { // The period counted is after its start date, so a first run starts a day early: the opening
// day itself earns, as every deposit's own day does.
final LocalDate countedAfter = party.interestCalculatedThrough(odInterest) != null ? accrueFrom : accrueFrom.minusDays(1);
if (!countedAfter.isBefore(through)) {
continue; continue;
} }
final BigDecimal dailyProduct = DdsInterestCalculation.dailyProduct(party.getOpeningBalance(), final BigDecimal dailyProduct = DdsInterestCalculation.dailyProduct(party.getOpeningBalance(),
movementsOf(movements.getOrDefault(party.getId(), List.of())), accrueFrom, jobDate); movementsOf(movements.getOrDefault(party.getId(), List.of())), countedAfter, through);
if (dailyProduct.compareTo(BigDecimal.ZERO) <= 0) { if (dailyProduct.compareTo(BigDecimal.ZERO) <= 0) {
continue; continue;
} }
@@ -320,7 +347,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
} }
batchRun.addRow( batchRun.addRow(
BatchRunRow.forInterest(party.getId(), party.getAgent().getId(), interest, dailyProduct, rate, accrueFrom, jobDate)); BatchRunRow.forInterest(party.getId(), party.getAgent().getId(), interest, dailyProduct, rate, accrueFrom, through));
} }
} }
@@ -712,7 +739,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
return; return;
} }
party.accrueInterest(row.getAmount(), jobDate, odInterest); party.accrueInterest(row.getAmount(), row.getToDate(), odInterest);
} }
/** /**
@@ -959,9 +986,16 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
public int postMaturityInterest(final LocalDate businessDate) { public int postMaturityInterest(final LocalDate businessDate) {
int posted = 0; int posted = 0;
for (final Party party : this.partyRepository.findMaturedWithInterestOutstanding(businessDate)) { for (final Party party : this.partyRepository.findMaturedWithInterestOutstanding(businessDate)) {
// Interest runs to the day before maturity: n instalments earn n, n-1 ... 1 days, the basis the
// maturity amount quoted at opening is worked out on. Counting maturity day as well would pay
// every instalment a day more than the customer was promised.
final LocalDate lastEarningDay = party.getMaturityDate().minusDays(1);
if (party.interestCalculatedThrough(false) != null && !party.interestCalculatedThrough(false).isBefore(lastEarningDay)) {
continue;
}
// Each account's last stretch of interest is its own slip. // Each account's last stretch of interest is its own slip.
final BigDecimal paid = this.ddsLedger.withVoucher(DdsVoucherService.TYPE_INTEREST, DdsVoucherService.REF_PARTY, final BigDecimal paid = this.ddsLedger.withVoucher(DdsVoucherService.TYPE_INTEREST, DdsVoucherService.REF_PARTY,
party.getId(), () -> postInterestThrough(party, party.getMaturityDate())); party.getId(), () -> postInterestThrough(party, lastEarningDay));
if (paid.compareTo(BigDecimal.ZERO) > 0) { if (paid.compareTo(BigDecimal.ZERO) > 0) {
posted++; posted++;
} }
@@ -987,6 +1021,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
* it cannot pay, so the turnover waits on their own KYC being verified - and until it is, the * it cannot pay, so the turnover waits on their own KYC being verified - and until it is, the
* payout refuses while collections carry on. Nothing is written for an account still waiting. * payout refuses while collections carry on. Nothing is written for an account still waiting.
*/ */
@Transactional
@Override
public int markMaturedAccounts(final LocalDate businessDate) {
return this.partyRepository.markMatured(businessDate);
}
@Transactional @Transactional
@Override @Override
public int promoteMinorsWhoHaveComeOfAge(final LocalDate businessDate) { public int promoteMinorsWhoHaveComeOfAge(final LocalDate businessDate) {
@@ -1031,8 +1071,9 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
return alreadyCalculated; return alreadyCalculated;
} }
// After this date is counted, so an account never calculated starts a day early and its opening day earns.
final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false) final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false)
: party.getOpeningDate(); : party.getOpeningDate().minusDays(1);
if (!accrueFrom.isBefore(through)) { if (!accrueFrom.isBefore(through)) {
return alreadyCalculated; return alreadyCalculated;
} }
@@ -1065,8 +1106,9 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
if (through == null || rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) { if (through == null || rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
return calculated; return calculated;
} }
// Counted after this date, as postInterestThrough counts, so the opening day earns.
final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false) final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false)
: party.getOpeningDate(); : party.getOpeningDate() == null ? null : party.getOpeningDate().minusDays(1);
if (accrueFrom == null || !accrueFrom.isBefore(through)) { if (accrueFrom == null || !accrueFrom.isBefore(through)) {
return calculated; return calculated;
} }
@@ -1099,6 +1141,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
subType); subType);
if (INTEREST_CALCULATION.equals(jobType)) { if (INTEREST_CALCULATION.equals(jobType)) {
// The nightly job runs after the period's last day has closed, so that day is counted.
generateInterestRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType); generateInterestRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType);
} else { } else {
generateInterestPostingRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType); generateInterestPostingRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType);
@@ -95,6 +95,19 @@ public class PartiesApiResource {
return readPlatformService.retrieveDueForClosure(); return readPlatformService.retrieveDueForClosure();
} }
@GET
@Path("summary")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "DDS book by agent", description = "One line per agent: accounts open and closed, the day's "
+ "collection due, the balance held, what has been collected and how many accounts are behind their daily "
+ "schedule. Worked out by the database, so the DDS accounts screen loads a few lines rather than every account; "
+ "an agent's accounts are then read with ?agentId=.")
public List<Map<String, Object>> retrieveAgentSummary() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAgentSummary();
}
@GET @GET
@Path("creditaccounts") @Path("creditaccounts")
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -22,6 +22,7 @@ import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@@ -41,11 +42,11 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
* Open accounts still assigned to this agent. Nobody collects for an agent who has been switched * Open accounts still assigned to this agent. Nobody collects for an agent who has been switched
* off, so these would simply stop being collected from without anyone being told. * off, so these would simply stop being collected from without anyone being told.
*/ */
@Query("select count(p) from Party p where p.agent.id = :agentId and p.status = 'ACTIVE'") @Query("select count(p) from Party p where p.agent.id = :agentId and p.status in ('ACTIVE', 'MATURED')")
Long countActivePartiesOfAgent(@Param("agentId") Long agentId); Long countActivePartiesOfAgent(@Param("agentId") Long agentId);
/** Open accounts on a scheme, which take its rate when the rate is changed. */ /** Open accounts on a scheme, which take its rate when the rate is changed. */
@Query("select p from Party p where p.subType = :subType and p.status = 'ACTIVE'") @Query("select p from Party p where p.subType = :subType and p.status in ('ACTIVE', 'MATURED')")
List<Party> findActiveOnSubType(@Param("subType") String subType); List<Party> findActiveOnSubType(@Param("subType") String subType);
/** /**
@@ -54,12 +55,12 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
* draw on and put the DDS balances out of step with what was actually paid. * draw on and put the DDS balances out of step with what was actually paid.
*/ */
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo " @Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType)") + "and p.status in ('ACTIVE', 'MATURED') and (:subType is null or p.subType = :subType)")
List<Party> findForInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, List<Party> findForInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType); @Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo " @Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) " + "and p.status in ('ACTIVE', 'MATURED') and (:subType is null or p.subType = :subType) "
+ "and (p.od1Flag = true or p.od2Flag = true)") + "and (p.od1Flag = true or p.od2Flag = true)")
List<Party> findForOdInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, List<Party> findForOdInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType); @Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
@@ -70,12 +71,12 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
* rows. * rows.
*/ */
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo " @Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) and p.interestCalc > 0") + "and p.status in ('ACTIVE', 'MATURED') and (:subType is null or p.subType = :subType) and p.interestCalc > 0")
List<Party> findWithAccruedInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, List<Party> findWithAccruedInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType); @Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo " @Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) and p.odInterestCalc > 0") + "and p.status in ('ACTIVE', 'MATURED') and (:subType is null or p.subType = :subType) and p.odInterestCalc > 0")
List<Party> findWithAccruedOdInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, List<Party> findWithAccruedOdInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType); @Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
@@ -98,7 +99,7 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
* have waited longest are listed at the top of what the clerk reviews. * have waited longest are listed at the top of what the clerk reviews.
*/ */
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo " @Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) " + "and p.status in ('ACTIVE', 'MATURED') and (:subType is null or p.subType = :subType) "
+ "and p.maturityDate is not null and p.maturityDate <= :asOn and p.currentBalance > 0 " + "and p.maturityDate is not null and p.maturityDate <= :asOn and p.currentBalance > 0 "
+ "and lower(p.advCode) = 'transfer to saving' " + "and lower(p.advCode) = 'transfer to saving' "
+ "and p.creditToAccount is not null and length(trim(p.creditToAccount)) > 0 " + "and p.creditToAccount is not null and length(trim(p.creditToAccount)) > 0 "
@@ -118,7 +119,7 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
* Maturity is the one date the money cannot wait for the next run, so these are found on their * Maturity is the one date the money cannot wait for the next run, so these are found on their
* own and brought up to date. Anything already posted through its maturity date is left alone. * own and brought up to date. Anything already posted through its maturity date is left alone.
*/ */
@Query("select p from Party p where p.status = 'ACTIVE' and p.maturityDate is not null and p.maturityDate <= :asOn " @Query("select p from Party p where p.status in ('ACTIVE', 'MATURED') and p.maturityDate is not null and p.maturityDate <= :asOn "
+ "and (p.interestDate is null or p.interestDate < p.maturityDate) order by p.maturityDate, p.id") + "and (p.interestDate is null or p.interestDate < p.maturityDate) order by p.maturityDate, p.id")
List<Party> findMaturedWithInterestOutstanding(@Param("asOn") LocalDate asOn); List<Party> findMaturedWithInterestOutstanding(@Param("asOn") LocalDate asOn);
@@ -128,7 +129,16 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
* Found on their birthday and not before, but turned over only once their own KYC is verified - * Found on their birthday and not before, but turned over only once their own KYC is verified -
* the caller checks that, because the answer lives on the bank's KYC record rather than here. * the caller checks that, because the answer lives on the bank's KYC record rather than here.
*/ */
@Query("select p from Party p where p.status = 'ACTIVE' and lower(p.adultMinor) = 'minor' " @Query("select p from Party p where p.status in ('ACTIVE', 'MATURED') and lower(p.adultMinor) = 'minor' "
+ "and p.birthDate is not null and p.birthDate <= :bornOnOrBefore order by p.id") + "and p.birthDate is not null and p.birthDate <= :bornOnOrBefore order by p.id")
List<Party> findMinorsWhoHaveComeOfAge(@Param("bornOnOrBefore") LocalDate bornOnOrBefore); List<Party> findMinorsWhoHaveComeOfAge(@Param("bornOnOrBefore") LocalDate bornOnOrBefore);
/**
* Turns open accounts whose maturity date has come to MATURED, as FD's maturity job does. They stay
* open - collections, interest and the payout still reach them - until the payout closes them.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update Party p set p.status = 'MATURED' where p.status = 'ACTIVE' and p.maturityDate is not null "
+ "and p.maturityDate <= :asOn")
int markMatured(@Param("asOn") LocalDate asOn);
} }
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.dds.party.service; package org.apache.fineract.portfolio.dds.party.service;
import java.util.Collection; import java.util.Collection;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.apache.fineract.portfolio.dds.party.data.PartyData; import org.apache.fineract.portfolio.dds.party.data.PartyData;
@@ -73,4 +74,11 @@ public interface PartyReadPlatformService {
* told. * told.
*/ */
Collection<PartyData> retrieveDueForClosure(); Collection<PartyData> retrieveDueForClosure();
/**
* Each agent's DDS book in one line - accounts open and closed, the day's collection due, the balance
* held, what has been collected and how many accounts are behind - worked out by the database, so
* the DDS accounts screen opens on a few lines however many accounts there are.
*/
List<Map<String, Object>> retrieveAgentSummary();
} }
@@ -275,11 +275,44 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
// Matured as of the bank's business date - the date the payout itself checks - not the // Matured as of the bank's business date - the date the payout itself checks - not the
// server's clock, so the list never offers an account the payout would call not yet matured. // server's clock, so the list never offers an account the payout would call not yet matured.
final String sql = "select " + PARTY_MAPPER.schema() final String sql = "select " + PARTY_MAPPER.schema()
+ " where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date <= ?" + " where p.status in ('ACTIVE', 'MATURED') and p.maturity_date is not null and p.maturity_date <= ?"
+ " order by p.maturity_date, p.party_code"; + " order by p.maturity_date, p.party_code";
return attachNominees(this.jdbcTemplate.query(sql, PARTY_MAPPER, DateUtils.getBusinessLocalDate())); // NOSONAR return attachNominees(this.jdbcTemplate.query(sql, PARTY_MAPPER, DateUtils.getBusinessLocalDate())); // NOSONAR
} }
/**
* One line per agent, added up in the database rather than by sending every account to the screen.
*
* "Behind" is the rule DdsSchedule applies to one account - collected less than the instalments due
* by today, counting from the opening day and stopping at the term's length - written as SQL so the
* count agrees with the Schedule column on the agent's own accounts. Collections are summed once
* per account in the derived table, which the partial index on PARTY_TRAN credits answers alone.
*/
private static final String AGENT_SUMMARY_SQL = "select a.id as \"agentId\", a.agent_code as \"agentCode\", "
+ "a.agent_name as \"agentName\", o.name as \"officeName\", count(p.id) as \"accounts\", "
+ "sum(case when p.status not in ('CLOSED', 'PREMATURE_CLOSED') then 1 else 0 end) as \"open\", "
+ "sum(case when p.status in ('CLOSED', 'PREMATURE_CLOSED') then 1 else 0 end) as \"closed\", "
// Matured: marked so by the nightly job, or past its maturity date before the job has run.
+ "sum(case when p.status = 'MATURED' or (p.status = 'ACTIVE' and p.maturity_date <= current_date) "
+ "then 1 else 0 end) as \"matured\", "
+ "coalesce(sum(case when p.status = 'MATURED' or (p.status = 'ACTIVE' and p.maturity_date <= current_date) "
+ "then p.current_balance end), 0) as \"maturedBalance\", "
+ "sum(case when p.status = 'PREMATURE_CLOSED' then 1 else 0 end) as \"prematureClosed\", "
+ "coalesce(sum(case when p.status not in ('CLOSED', 'PREMATURE_CLOSED') then p.daily_deposit_amount end), 0) as \"dailyDue\", "
+ "coalesce(sum(p.current_balance), 0) as \"balance\", coalesce(sum(c.collected), 0) as \"collected\", "
+ "sum(case when p.status not in ('CLOSED', 'PREMATURE_CLOSED') and p.daily_deposit_amount > 0 and coalesce(c.collected, 0) < "
+ "p.daily_deposit_amount * least(greatest((current_date - p.opening_date) + 1, 0), coalesce(p.days, 0)) "
+ "then 1 else 0 end) as \"inArrears\" "
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id left join m_office o on o.id = a.office_id "
+ "left join (select t.party_id, sum(t.amount) as collected from m_dds_party_transaction t "
+ "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' group by t.party_id) c on c.party_id = p.id "
+ "group by a.id, a.agent_code, a.agent_name, o.name order by a.agent_code";
@Override
public List<Map<String, Object>> retrieveAgentSummary() {
return this.jdbcTemplate.queryForList(AGENT_SUMMARY_SQL); // NOSONAR
}
@Override @Override
public PartyData retrieveOne(final Long partyId) { public PartyData retrieveOne(final Long partyId) {
try { try {
@@ -411,7 +444,7 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
} }
final Map<String, Object> party = parties.get(0); final Map<String, Object> party = parties.get(0);
final Map<String, Object> closure = new LinkedHashMap<>(); final Map<String, Object> closure = new LinkedHashMap<>();
final boolean closed = "CLOSED".equals(party.get("status")); final boolean closed = "CLOSED".equals(party.get("status")) || "PREMATURE_CLOSED".equals(party.get("status"));
closure.put("closed", closed); closure.put("closed", closed);
if (!closed) { if (!closed) {
return closure; return closure;
@@ -319,7 +319,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
} }
/** /**
* Pays a matured account out. The account stays open. * Pays a matured account out, and closes it (status CLOSED) on the same day.
* *
* <p> * <p>
* What is paid is what the account actually holds - the deposits that came in plus the interest * What is paid is what the account actually holds - the deposits that came in plus the interest
@@ -335,11 +335,6 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
* no account given parked in Matured Deposits Payable, for the branch to send on * no account given parked in Matured Deposits Payable, for the branch to send on
* </pre> * </pre>
* *
* <p>
* Closing is deliberately not part of this. Paying out and closing are two decisions: the officer
* pays, sees the balance reach zero and the money land where it was meant to, and only then
* closes. Doing both on one click would close accounts whose payment had not been checked, and a
* closed account is far harder to put right than an open one with a balance.
*/ */
@Transactional @Transactional
@Override @Override
@@ -399,7 +394,8 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
// not happen - the server was down, or the scheme has automatic posting switched off. Without // not happen - the server was down, or the scheme has automatic posting switched off. Without
// it a customer who paid every instalment still loses the last stretch of interest, and // it a customer who paid every instalment still loses the last stretch of interest, and
// nothing on screen would say so. // nothing on screen would say so.
this.batchRunWritePlatformService.postInterestThrough(party, party.getMaturityDate()); // Through the day before maturity, as the maturity amount quoted at opening is worked out.
this.batchRunWritePlatformService.postInterestThrough(party, party.getMaturityDate().minusDays(1));
final BigDecimal amount = party.getCurrentBalance(); final BigDecimal amount = party.getCurrentBalance();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
@@ -795,7 +791,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
} }
private void refuseChangeOfLockedTerms(final Party party, final Map<String, Object> changes, final boolean officeMoves) { private void refuseChangeOfLockedTerms(final Party party, final Map<String, Object> changes, final boolean officeMoves) {
if (!Party.STATUS_ACTIVE.equals(party.getStatus())) { if (party.isClosed()) {
return; return;
} }
final List<ApiParameterError> errors = new ArrayList<>(); final List<ApiParameterError> errors = new ArrayList<>();
@@ -925,7 +921,9 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
// The interest has to be brought up to date by the branch before the account is closed early: // 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 // 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. // what is given back. Closing is refused until then, and says which runs are missing.
final BigDecimal interestOwed = this.batchRunWritePlatformService.interestNotYetPosted(party, closeDate); // 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) { if (interestOwed.compareTo(BigDecimal.ZERO) > 0) {
final boolean calculatedOnly = party.accruedInterest(false) != null final boolean calculatedOnly = party.accruedInterest(false) != null
&& party.accruedInterest(false).compareTo(interestOwed) == 0; && party.accruedInterest(false).compareTo(interestOwed) == 0;
@@ -939,7 +937,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
} }
// Nothing is left to credit by now; kept so a rounding remainder is never lost. // Nothing is left to credit by now; kept so a rounding remainder is never lost.
this.batchRunWritePlatformService.postInterestThrough(party, closeDate); 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));
final BigDecimal borneAmount = customerBears final BigDecimal borneAmount = customerBears
@@ -950,7 +948,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
payOutRemainder(party, command, closeDate, lockinOverrideReason); payOutRemainder(party, command, closeDate, lockinOverrideReason);
party.close(closeDate, currentUserId()); party.closePremature(closeDate, currentUserId());
this.partyRepository.saveAndFlush(party); this.partyRepository.saveAndFlush(party);
final Map<String, Object> changes = new HashMap<>(); final Map<String, Object> changes = new HashMap<>();
@@ -1042,11 +1040,8 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
return BigDecimal.ZERO; return BigDecimal.ZERO;
} }
final int remainingDays = closeDate.lengthOfMonth() - closeDate.getDayOfMonth(); // From the closing day to the month's end: the customer's own interest stops the day before closing.
if (remainingDays <= 0) { final int remainingDays = closeDate.lengthOfMonth() - closeDate.getDayOfMonth() + 1;
// Closed on the last day of the month - there is nothing left of it to fund.
return BigDecimal.ZERO;
}
final BigDecimal topUp = DdsInterestCalculation.interestOn(balance.multiply(BigDecimal.valueOf(remainingDays)), rate); final BigDecimal topUp = DdsInterestCalculation.interestOn(balance.multiply(BigDecimal.valueOf(remainingDays)), rate);
if (topUp.compareTo(BigDecimal.ZERO) <= 0) { if (topUp.compareTo(BigDecimal.ZERO) <= 0) {
@@ -111,7 +111,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
private DdsReportData agentCheckList() { private DdsReportData agentCheckList() {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", a.agent_city as \"City\", " final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", a.agent_city as \"City\", "
+ AGENT_STATUS_LABEL + " as \"Status\", a.agent_opening_date as \"Opening Date\", " + AGENT_STATUS_LABEL + " as \"Status\", a.agent_opening_date as \"Opening Date\", "
+ "(select count(*) from m_dds_party p where p.agent_id = a.id and p.status = 'ACTIVE') as \"Accounts\", " + "(select count(*) from m_dds_party p where p.agent_id = a.id and p.status in ('ACTIVE', 'MATURED')) as \"Accounts\", "
+ AGENT_CASH_IN_HAND + " as \"Cash In Hand\", a.commission as \"Commission Due\" " + AGENT_CASH_IN_HAND + " as \"Cash In Hand\", a.commission as \"Commission Due\" "
+ "from m_dds_agent a order by a.agent_code"; + "from m_dds_agent a order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql); final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql);
@@ -307,7 +307,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
final Map<String, Object> agentTotals = this.jdbcTemplate.queryForMap(agentSql, agentFrom, agentTo); final Map<String, Object> agentTotals = this.jdbcTemplate.queryForMap(agentSql, agentFrom, agentTo);
final String partySql = "select count(*) as \"Total Accounts\", " final String partySql = "select count(*) as \"Total Accounts\", "
+ "count(*) filter (where status = 'ACTIVE') as \"Active Accounts\", " + "count(*) filter (where status in ('ACTIVE', 'MATURED')) as \"Active Accounts\", "
+ "coalesce(sum(current_balance), 0) as \"Deposits Held\", " + "coalesce(sum(current_balance), 0) as \"Deposits Held\", "
+ "coalesce(sum(interest_calc + od_interest_calc), 0) as \"Interest Accrued\" " + "coalesce(sum(interest_calc + od_interest_calc), 0) as \"Interest Accrued\" "
+ "from m_dds_party where agent_id between ? and ? and id between ? and ?"; + "from m_dds_party where agent_id between ? and ? and id between ? and ?";
@@ -364,7 +364,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id " + "from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
// Open accounts only: this is the list payouts are arranged from, and a closed account // Open accounts only: this is the list payouts are arranged from, and a closed account
// has nothing left to pay. // has nothing left to pay.
+ "where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date between ? and ? " + "where p.status in ('ACTIVE', 'MATURED') and p.maturity_date is not null and p.maturity_date between ? and ? "
+ "and p.agent_id between ? and ? " + "and p.agent_id between ? and ? "
+ "and p.id between ? and ? order by p.maturity_date, p.party_code"; + "and p.id between ? and ? order by p.maturity_date, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom, final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
@@ -465,7 +465,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", a.agent_code as \"Agent Code\", " final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", a.agent_code as \"Agent Code\", "
+ "p.opening_date as \"Opening Date\", p.close_date as \"Close Date\", p.current_balance as \"Final Balance\" " + "p.opening_date as \"Opening Date\", p.close_date as \"Close Date\", p.current_balance as \"Final Balance\" "
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id " + "from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
+ "where p.status = 'CLOSED' and p.close_date between ? and ? and a.id between ? and ? and p.id between ? and ? " + "where p.status in ('CLOSED', 'PREMATURE_CLOSED') and p.close_date between ? and ? and a.id between ? and ? and p.id between ? and ? "
+ "order by p.close_date, p.party_code"; + "order by p.close_date, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom, final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo); partyTo);
@@ -197,4 +197,6 @@
<include file="parts/0623_dds_account_audit_and_links.xml" relativeToChangelogFile="true"/> <include file="parts/0623_dds_account_audit_and_links.xml" relativeToChangelogFile="true"/>
<include file="parts/0624_dds_account_number_serial.xml" relativeToChangelogFile="true"/> <include file="parts/0624_dds_account_number_serial.xml" relativeToChangelogFile="true"/>
<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/0628_dds_party_matured_and_premature_status.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,41 @@
<?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">
<!--
What each DDS account has collected is summed from its PARTY_TRAN credits - for the accounts list,
the agent summary and every account's schedule. Interest, transfers and payouts sit in the same
table and are never part of that sum, so the index holds only the collections, with their amounts,
and the sums are read from it without touching the table.
-->
<changeSet id="0627_dds_party_collection_index" author="CBS" dbms="postgresql">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from pg_indexes where indexname = 'idx_dds_party_txn_collections'</sqlCheck>
</preConditions>
<sql>
create index idx_dds_party_txn_collections on m_dds_party_transaction (party_id) include (amount)
where transaction_type = 'PARTY_TRAN' and direction = 'CREDIT'
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,46 @@
<?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">
<!--
DDS accounts take FD's statuses: ACTIVE, MATURED (maturity date reached, not yet paid out), CLOSED
(paid out at maturity) and PREMATURE_CLOSED (broken before maturity). The nightly Post DDS Interest
job marks accounts MATURED from now on; this brings the accounts already on the books into line.
-->
<changeSet author="fineract" id="1">
<sql>
update m_dds_party set status = 'MATURED'
where status = 'ACTIVE' and maturity_date is not null and maturity_date &lt;= current_date;
</sql>
</changeSet>
<!-- An account closed early is told apart by its premature payout. -->
<changeSet author="fineract" id="2">
<sql>
update m_dds_party p set status = 'PREMATURE_CLOSED'
where p.status = 'CLOSED' and exists (select 1 from m_dds_party_transaction t
where t.party_id = p.id and t.transaction_type = 'PREMATURE_PAYOUT');
</sql>
</changeSet>
</databaseChangeLog>