From 6508dbed676b3ee29395b27d0167e13679b25048 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 29 Oct 2026 18:33:06 +0530 Subject: [PATCH] feat(dds): FD-style account statuses, period-end interest, agent summary - 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 --- .../portfolio/dds/party/domain/Party.java | 17 +- .../service/DdsApprovalReadService.java | 200 +++++++++++++++++- .../PostDdsInterestTasklet.java | 9 +- .../service/BatchRunWritePlatformService.java | 7 + ...WritePlatformServiceJpaRepositoryImpl.java | 71 +++++-- .../dds/party/api/PartiesApiResource.java | 13 ++ .../dds/party/domain/PartyRepository.java | 28 ++- .../service/PartyReadPlatformService.java | 8 + .../service/PartyReadPlatformServiceImpl.java | 37 +++- ...WritePlatformServiceJpaRepositoryImpl.java | 27 +-- .../DdsReportReadPlatformServiceImpl.java | 8 +- .../tenant/final-changelog-tenant.xml | 2 + .../parts/0627_dds_party_collection_index.xml | 41 ++++ ...dds_party_matured_and_premature_status.xml | 46 ++++ 14 files changed, 462 insertions(+), 52 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0627_dds_party_collection_index.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0628_dds_party_matured_and_premature_status.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/Party.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/Party.java index 27208303f..8fe3b41fd 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/Party.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/Party.java @@ -80,6 +80,10 @@ public class Party extends AbstractAuditableCustom { public static final String STATUS_ACTIVE = "ACTIVE"; 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 @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 ADULT = "Adult"; + /** Closed either way - paid out at maturity, or broken before it. */ 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; } + /** 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. */ public void linkProduct(final Long productId) { this.accountTypeId = productId; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java index 06e6e4596..8e3b55f60 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java @@ -22,6 +22,9 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.LocalDate; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -30,8 +33,10 @@ import java.util.Objects; import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException; import org.apache.fineract.commands.exception.CommandNotFoundException; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.dds.accounting.DdsLedger; import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException; +import org.apache.fineract.portfolio.dds.calculation.DdsInterestCalculation; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -103,6 +108,9 @@ public class DdsApprovalReadService { if (isAgentRequest(row)) { 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)) { final JsonObject partyJson = jsonOf(found.get(0).get("json")); 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("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 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 slabColumns(final JsonObject json, final Long resourceId) { + final Map 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 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 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> slabChart(final Map row, final JsonObject json) { + final Long resourceId = (Long) row.get("resourceId"); + final boolean pending = "PENDING".equals(row.get("status")); + final Map 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> chart = new ArrayList<>(); + BigDecimal from = null; + BigDecimal to = null; + for (final Map 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 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 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 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 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 * terms they are approving rather than only a code. @@ -447,19 +567,21 @@ public class DdsApprovalReadService { if (isPartyRequest(row)) { 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(); } + final String table = slab ? "m_dds_commission_slab" : "m_dds_account_type"; final JsonObject json = jsonOf(entry.get("json")); final Long resourceId = (Long) row.get("resourceId"); final boolean compare = "UPDATE".equals(row.get("action")) && "PENDING".equals(row.get("status")) && resourceId != null; final Map 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()) : Map.of(); final List> 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 String proposed = requested ? display(json.get(field.parameter()).getAsString(), field.format()) : null; final Map out = new LinkedHashMap<>(); @@ -503,10 +625,36 @@ public class DdsApprovalReadService { case "yesno" -> Boolean.parseBoolean(value.trim()) ? "Yes" : "No"; case "percent" -> plain(value) + "%"; 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(); }; } + /** ₹ 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. */ private static String plain(final String value) { try { @@ -751,7 +899,7 @@ public class DdsApprovalReadService { if (reason != null) { 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" -> { type = "Close DDS Account"; @@ -784,7 +932,9 @@ public class DdsApprovalReadService { } case "COMMISSIONSLAB:CREATE", "COMMISSIONSLAB:UPDATE" -> { 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" -> { final Map 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); } + /** + * 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> 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) { final List found = this.jdbcTemplate.queryForList("select current_balance from m_dds_party where id = ?", // NOSONAR BigDecimal.class, partyId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/jobs/postddsinterest/PostDdsInterestTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/jobs/postddsinterest/PostDdsInterestTasklet.java index 72bf1668f..5b4a2752d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/jobs/postddsinterest/PostDdsInterestTasklet.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/jobs/postddsinterest/PostDdsInterestTasklet.java @@ -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); } - 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) { log.debug("Post DDS Interest: no minor's account came of age with KYC verified on {}", businessDate); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformService.java index 7bf7b5a70..ce0b68e73 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformService.java @@ -83,6 +83,13 @@ public interface BatchRunWritePlatformService { */ 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 * {@code postBatchRun} hands the work to, not from a request. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java index 39471cb22..493053fb2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java @@ -24,6 +24,7 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -181,10 +182,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr subType); switch (jobType) { - case INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false, - officeNames, subType); - case OD_INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true, - officeNames, subType); + // A run on the screen counts up to the day before its job date: that day is still open, and a + // deposit made after the run would otherwise lose its own day, which the next run starts after. + case INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate.minusDays(1), + 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, officeNames, subType); 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); } + // 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 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 // identical "not posted" runs on the list, and nobody could tell which one to post. discardSupersededPreviews(jobType, subType); this.batchRunRepository.saveAndFlush(batchRun); + final Map 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() // .withCommandId(command.commandId()) // .withEntityId(batchRun.getId()) // + .with(outcome) // .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 * 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. */ 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 officeNames, + final Long partyTo, final LocalDate through, final boolean odInterest, final Set officeNames, final String subType) { final List parties = odInterest @@ -291,7 +315,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr return; } - final Map> movements = movementsByParty(eligible, jobDate); + final Map> movements = movementsByParty(eligible, through); for (final Party party : eligible) { final BigDecimal rate = party.getInterestRate(); @@ -304,12 +328,15 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr final LocalDate accrueFrom = party.interestCalculatedThrough(odInterest) != null ? party.interestCalculatedThrough(odInterest) : 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; } 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) { continue; } @@ -320,7 +347,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr } 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; } - 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) { int posted = 0; 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. 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) { 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 * 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 @Override public int promoteMinorsWhoHaveComeOfAge(final LocalDate businessDate) { @@ -1031,8 +1071,9 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr 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) - : party.getOpeningDate(); + : party.getOpeningDate().minusDays(1); if (!accrueFrom.isBefore(through)) { return alreadyCalculated; } @@ -1065,8 +1106,9 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr if (through == null || rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) { return calculated; } + // Counted after this date, as postInterestThrough counts, so the opening day earns. 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)) { return calculated; } @@ -1099,6 +1141,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr subType); 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); } else { generateInterestPostingRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/api/PartiesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/api/PartiesApiResource.java index d4c766125..d4d30f2cf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/api/PartiesApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/api/PartiesApiResource.java @@ -95,6 +95,19 @@ public class PartiesApiResource { 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> retrieveAgentSummary() { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return readPlatformService.retrieveAgentSummary(); + } + @GET @Path("creditaccounts") @Consumes({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/domain/PartyRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/domain/PartyRepository.java index b8d37f63a..59bc3088e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/domain/PartyRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/domain/PartyRepository.java @@ -22,6 +22,7 @@ import java.time.LocalDate; import java.util.List; import java.util.Optional; 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.repository.query.Param; @@ -41,11 +42,11 @@ public interface PartyRepository extends JpaRepository { * 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. */ - @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); /** 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 findActiveOnSubType(@Param("subType") String subType); /** @@ -54,12 +55,12 @@ public interface PartyRepository extends JpaRepository { * 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 " - + "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 findForInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, @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 " - + "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)") List findForOdInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, @Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType); @@ -70,12 +71,12 @@ public interface PartyRepository extends JpaRepository { * rows. */ @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 findWithAccruedInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, @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 " - + "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 findWithAccruedOdInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo, @Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType); @@ -98,7 +99,7 @@ public interface PartyRepository extends JpaRepository { * 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 " - + "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 lower(p.advCode) = 'transfer to saving' " + "and p.creditToAccount is not null and length(trim(p.creditToAccount)) > 0 " @@ -118,7 +119,7 @@ public interface PartyRepository extends JpaRepository { * 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. */ - @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") List findMaturedWithInterestOutstanding(@Param("asOn") LocalDate asOn); @@ -128,7 +129,16 @@ public interface PartyRepository extends JpaRepository { * 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. */ - @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") List 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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformService.java index 8b781ced9..fb7616434 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformService.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.dds.party.service; import java.util.Collection; +import java.util.List; import java.util.Map; import org.apache.fineract.portfolio.dds.party.data.PartyData; @@ -73,4 +74,11 @@ public interface PartyReadPlatformService { * told. */ Collection 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> retrieveAgentSummary(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java index 01c06c5a0..08bfecc15 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java @@ -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 // server's clock, so the list never offers an account the payout would call not yet matured. 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"; 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> retrieveAgentSummary() { + return this.jdbcTemplate.queryForList(AGENT_SUMMARY_SQL); // NOSONAR + } + @Override public PartyData retrieveOne(final Long partyId) { try { @@ -411,7 +444,7 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService { } final Map party = parties.get(0); final Map 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); if (!closed) { return closure; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java index 80e23f424..1ba764ca3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java @@ -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. * *

* 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 * * - *

- * 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 @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 // it a customer who paid every instalment still loses the last stretch of interest, and // 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(); 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 changes, final boolean officeMoves) { - if (!Party.STATUS_ACTIVE.equals(party.getStatus())) { + if (party.isClosed()) { return; } final List 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: // 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. - 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) { final boolean calculatedOnly = party.accruedInterest(false) != null && 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. - this.batchRunWritePlatformService.postInterestThrough(party, closeDate); + this.batchRunWritePlatformService.postInterestThrough(party, lastEarningDay); final boolean customerBears = CUSTOMER_BEARS.equals(command.stringValueOfParameterNamed(SHORTFALL_BORNE_BY)); final BigDecimal borneAmount = customerBears @@ -950,7 +948,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla payOutRemainder(party, command, closeDate, lockinOverrideReason); - party.close(closeDate, currentUserId()); + party.closePremature(closeDate, currentUserId()); this.partyRepository.saveAndFlush(party); final Map changes = new HashMap<>(); @@ -1042,11 +1040,8 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla return BigDecimal.ZERO; } - final int remainingDays = closeDate.lengthOfMonth() - closeDate.getDayOfMonth(); - if (remainingDays <= 0) { - // Closed on the last day of the month - there is nothing left of it to fund. - return BigDecimal.ZERO; - } + // From the closing day to the month's end: the customer's own interest stops the day before closing. + final int remainingDays = closeDate.lengthOfMonth() - closeDate.getDayOfMonth() + 1; final BigDecimal topUp = DdsInterestCalculation.interestOn(balance.multiply(BigDecimal.valueOf(remainingDays)), rate); if (topUp.compareTo(BigDecimal.ZERO) <= 0) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/service/DdsReportReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/service/DdsReportReadPlatformServiceImpl.java index 4f7797ba5..e3f30c7d4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/service/DdsReportReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/report/service/DdsReportReadPlatformServiceImpl.java @@ -111,7 +111,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe private DdsReportData agentCheckList() { 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\", " - + "(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\" " + "from m_dds_agent a order by a.agent_code"; final List> rows = this.jdbcTemplate.queryForList(sql); @@ -307,7 +307,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe final Map agentTotals = this.jdbcTemplate.queryForMap(agentSql, agentFrom, agentTo); 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(interest_calc + od_interest_calc), 0) as \"Interest Accrued\" " + "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 " // Open accounts only: this is the list payouts are arranged from, and a closed account // 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.id between ? and ? order by p.maturity_date, p.party_code"; final List> 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\", " + "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 " - + "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"; final List> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 39cfad061..ffc4d2d5b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -197,4 +197,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0627_dds_party_collection_index.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0627_dds_party_collection_index.xml new file mode 100644 index 000000000..59f5ec15f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0627_dds_party_collection_index.xml @@ -0,0 +1,41 @@ + + + + + + + + select count(*) from pg_indexes where indexname = 'idx_dds_party_txn_collections' + + + create index idx_dds_party_txn_collections on m_dds_party_transaction (party_id) include (amount) + where transaction_type = 'PARTY_TRAN' and direction = 'CREDIT' + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0628_dds_party_matured_and_premature_status.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0628_dds_party_matured_and_premature_status.xml new file mode 100644 index 000000000..38979d982 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0628_dds_party_matured_and_premature_status.xml @@ -0,0 +1,46 @@ + + + + + + + + update m_dds_party set status = 'MATURED' + where status = 'ACTIVE' and maturity_date is not null and maturity_date <= current_date; + + + + + + + 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'); + + +