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
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:
+16
-1
@@ -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;
|
||||||
|
|||||||
+195
-5
@@ -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);
|
||||||
|
|||||||
+7
@@ -70,6 +70,13 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
final int promoted =this.batchRunWritePlatformService.promoteMinorsWhoHaveComeOfAge(businessDate);
|
||||||
|
|
||||||
if (promoted == 0) {
|
if (promoted == 0) {
|
||||||
|
|||||||
+7
@@ -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.
|
||||||
|
|||||||
+57
-14
@@ -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);
|
||||||
|
|||||||
+13
@@ -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 })
|
||||||
|
|||||||
+19
-9
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-2
@@ -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;
|
||||||
|
|||||||
+11
-16
@@ -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) {
|
||||||
|
|||||||
+4
-4
@@ -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>
|
||||||
|
|||||||
+41
@@ -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>
|
||||||
+46
@@ -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 <= 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>
|
||||||
Reference in New Issue
Block a user