diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java index 1fbefbc3f..29fb8d506 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java @@ -49,11 +49,13 @@ public interface AmlAlertRepository extends JpaRepository { * A customer's still-open alerts inside the observation window, oldest first. * *

- * What a recurring-behaviour review acts on. Restricted to RAISED and UNDER_REVIEW because a decision about the + * What a recurring-behaviour review acts on. Open is defined as not yet decided rather than by naming the statuses + * that are open, so a status this query does not know about is still treated as work: an alert wrongly included + * costs a reviewer a glance, one wrongly excluded is a pattern the bank never looked at. A decision about the * customer's pattern must not reopen alerts an officer has already disposed of individually - and oldest first so * that a partial failure leaves the longest-standing ones dealt with. */ @Query("SELECT a FROM AmlAlert a WHERE a.client.id = :clientId AND a.raisedOn >= :since " - + "AND a.status IN ('RAISED', 'UNDER_REVIEW') ORDER BY a.raisedOn ASC") + + "AND a.status NOT IN ('WHITELISTED', 'ESCALATED') ORDER BY a.raisedOn ASC") List findOpenAlertsForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDateTime since); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java index bedff9209..d404b8ee6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java @@ -93,7 +93,9 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle summary.riskCategory(), alerts.size(), (int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(), (int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(), - (int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(), + // Open means undecided, not a named status: a status this count does not list would otherwise be + // dropped from the customer's open figure while the alert still sits in the feed awaiting review. + (int) alerts.stream().filter(a -> !"WHITELISTED".equals(a.status()) && !"ESCALATED".equals(a.status())).count(), (int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(), alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add), alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null), diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java index 2c1d1e584..94dd084d7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -91,6 +91,24 @@ import org.springframework.transaction.annotation.Transactional; @Slf4j public class DividendClaimRequestApiResource { + /** + * The next blank to draw a dividend draft on: the lowest unused number in the register. + * + * Ordered by the printed number, never by row id. Row id is insertion order, which matches the + * series only for the first book registered — a later book allocates from the middle and hands + * out numbers out of sequence, which is what makes an unused number in a run impossible to + * account for. The numeric cast is guarded because a series need not be plain digits; a prefixed + * number sorts as text after the numeric ones, matching how the leaflet register displays it. + * + * SKIP LOCKED so two concurrent claims take different leaflets rather than one waiting on the + * other's transaction. + */ + private static final String LOWEST_AVAILABLE_LEAFLET_SQL = """ + SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE' + ORDER BY CASE WHEN dd_number ~ '^[0-9]+$' THEN CAST(dd_number AS BIGINT) END ASC NULLS LAST, dd_number ASC + LIMIT 1 FOR UPDATE SKIP LOCKED + """; + private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; @@ -191,11 +209,12 @@ public class DividendClaimRequestApiResource { .orElseThrow(() -> apiError(409, "That Demand Draft leaflet is no longer available — someone else has " + "just taken it. Go back and choose another leaflet.")) // No leaflet named (a caller that is not the leaflets screen): fall back to the - // oldest available, which is the order a series is meant to be consumed in. - : jdbcTemplate.queryForList(""" - SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE' - ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED - """).stream().findFirst().orElseThrow(() -> apiError(409, + // lowest unused number, which is the order a series is meant to be consumed in. + // Ordered by the printed number rather than by row id — id is insertion order, and + // the two diverge as soon as a second book is registered, which hands out numbers + // from the middle of a series instead of its start. + : jdbcTemplate.queryForList(LOWEST_AVAILABLE_LEAFLET_SQL) + .stream().findFirst().orElseThrow(() -> apiError(409, "No Demand Draft leaflets are available in inventory. Add stock before claiming a dividend by DD.")); final Long leafletId = ((Number) leaflet.get("id")).longValue(); final String ddNumber = (String) leaflet.get("dd_number"); @@ -430,11 +449,9 @@ public class DividendClaimRequestApiResource { final Map record, final BigDecimal amount, final String currencyCode, final LocalDate today, final AppUser currentUser) { - // Oldest series first, and locked so two concurrent approvals cannot take the same leaflet. - final Map leaflet = jdbcTemplate.queryForList(""" - SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE status = 'AVAILABLE' - ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED - """).stream().findFirst() + // Lowest unused number first, and locked so two concurrent approvals cannot take the same + // leaflet. + final Map leaflet = jdbcTemplate.queryForList(LOWEST_AVAILABLE_LEAFLET_SQL).stream().findFirst() .orElseThrow(() -> apiError(409, "No Demand Draft leaflets are available in inventory.")); final Long leafletId = ((Number) leaflet.get("id")).longValue(); final String ddNumber = (String) leaflet.get("dd_number"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 29bbb45d2..1c58cfd3a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -166,6 +166,8 @@ public class ShareAccountDataSerializer { baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(requestedShares).notNull() .longGreaterThanZero(); + validateNoOpenAccountForProduct(clientId, productId, baseDataValidator); + if (shareProduct.getMinimumClientShares() != null && requestedShares < shareProduct.getMinimumClientShares()) { baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(requestedShares).failWithCode( "client.can.not.purchase.shares.lessthan.product.definition", @@ -545,6 +547,43 @@ public class ShareAccountDataSerializer { return actualChanges; } + /** + * A member may hold only one share account per product at a time. + * + * The society's shareholding in a product is one relationship, not several: a member's stake, their dividend and + * their voting entitlement are all read from it, so a second open account splits the same person's holding across + * two records and makes each of those figures ambiguous. It also lets the product's maximum-shares-per-client + * ceiling be passed by opening a second account, since that check only ever sees the account in front of it. + * + * Closed and rejected accounts do not block a new one. A member who closed their account and later rejoins is + * starting a fresh relationship, and a rejected application never became one — neither leaves a holding behind. + * Everything else does: an account still awaiting approval, or approved but not yet paid for, is a commitment the + * society has already recorded, so a second application on top of it is refused rather than quietly allowed. + */ + private void validateNoOpenAccountForProduct(final Long clientId, final Long productId, + final DataValidatorBuilder baseDataValidator) { + if (clientId == null || productId == null) { + return; + } + // 500 REJECTED and 600 CLOSED are the two states that leave no holding behind; see + // ShareAccountStatusType for the full set. + final List> open = this.jdbcTemplate.queryForList(""" + SELECT account_no, status_enum FROM m_share_account + WHERE client_id = ? AND product_id = ? AND status_enum NOT IN (500, 600) + ORDER BY id + """, clientId, productId); + if (open.isEmpty()) { + return; + } + // The existing account number is named so the officer can go and look at it, rather than + // being told only that one exists somewhere. + final String existing = open.get(0).get("account_no") == null ? "" : String.valueOf(open.get(0).get("account_no")); + baseDataValidator.reset().parameter(ShareAccountApiConstants.productid_paramname).value(productId).failWithCode( + "client.already.has.an.open.share.account.for.this.product", + "This member already holds share account " + existing + + " in this product. Close that account before opening another, or add shares to it instead."); + } + private void validateTotalSubsribedShares(final ShareAccount account, final ShareAccountTransaction transaction, final DataValidatorBuilder baseDataValidator) { Long totalSubsribedShares = account.getShareProduct().getSubscribedShares(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java index 0b7f8fe02..8b85f94a0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/api/AllProductsDividendApiResource.java @@ -35,15 +35,21 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.accounting.common.DividendGlAccountResolver; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.commands.service.SynchronousCommandProcessingService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -72,6 +78,7 @@ import org.springframework.stereotype.Component; @Component @Tag(name = "All Products Dividend", description = "Declare one dividend across every share product for a financial year.") @RequiredArgsConstructor +@Slf4j @Produces({ MediaType.APPLICATION_JSON }) public class AllProductsDividendApiResource { @@ -96,6 +103,7 @@ public class AllProductsDividendApiResource { private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final FineractRequestContextHolder fineractRequestContextHolder; private final Gson gson = new GsonBuilder().serializeNulls().create(); // ── GET /v1/shareproducts/dividends ── Every declaration, across products ── @@ -176,6 +184,8 @@ public class AllProductsDividendApiResource { entry.put("productName", capital.productName); entry.put("weightedCapital", capital.weightedCapital); entry.put("amount", amount); + entry.put("memberCount", capital.memberCount); + entry.put("accountCount", capital.accountCount); entry.put("eligible", capital.weightedCapital.signum() > 0); entry.put("reason", capital.weightedCapital.signum() > 0 ? null : "No member has held shares in this product long enough to qualify for this period."); @@ -191,6 +201,21 @@ public class AllProductsDividendApiResource { out.put("dividendBasis", BASIS_RATE_ON_CAPITAL); out.put("ratePercentage", ratePercentage); out.put("totalWeightedCapital", totalWeightedCapital); + // Distinct across products: one person holding shares in both is one member receiving a + // dividend, not two, so summing the per-product counts would overstate the membership. + out.put("totalMemberCount", distinctMemberCount(from, to)); + // Accounts simply add up, unlike members: each holding is allocated, locked and claimed on + // its own, so a person with two of them appears twice here and once in the member count. + out.put("totalAccountCount", capitals.stream().mapToInt(c -> c.accountCount).sum()); + // Reported rather than thrown, so the officer sees why the year they picked cannot be + // declared while the figures are still on screen. The POST enforces the same rule. + out.put("periodNotFinished", !to.isBefore(DateUtils.getBusinessLocalDate())); + // The board's own cap, reported so the screen can flag a rate above it while it can still be + // changed. The POST enforces the same limit. + final BigDecimal rateCeiling = configuredRateCeiling(); + out.put("maximumRatePercentage", rateCeiling); + out.put("exceedsMaximumRate", + rateCeiling != null && rateCeiling.signum() > 0 && ratePercentage != null && ratePercentage.compareTo(rateCeiling) > 0); out.put("profitGlCode", distributableProfitGlCode()); // The funding position, not the basis. Carried so a rate the profit account cannot cover is // visible on screen before submission — the command layer enforces the same ceiling, and an @@ -238,6 +263,8 @@ public class AllProductsDividendApiResource { if (rate.signum() <= 0) { throw apiError(400, "ratePercentage must be greater than zero"); } + rejectIfPeriodNotFinished(to); + rejectIfRateAboveCeiling(rate); final List capitals = capitalPerProduct(from, to); @@ -276,8 +303,27 @@ public class AllProductsDividendApiResource { payload.addProperty("dividendAmount", amount); try { + // Each product gets its own idempotency key. Without one, every command in this loop + // inherits the single key held against the HTTP request, so the first product's + // declaration claims it and every product after is rejected as already processed -- + // the second product silently received nothing while the first went through. + // + // Freshly generated per command rather than derived from the period and rate: a key + // built from those is the same on every run, so once a declaration had been made and + // then reverted, redeclaring the same year at the same rate was refused as a + // duplicate of the reverted one. Declaring twice for a period is already prevented + // properly, by the overlap check against existing declarations, which looks at what + // is actually on the books rather than at what was once submitted. + final String idempotencyKey = java.util.UUID.randomUUID().toString(); + // The command layer records the command it just ran against the HTTP request, and + // treats any command that follows within the same request as a retry of it — + // reusing the first command's idempotency key and rejecting the rest as already + // processed. Cleared before each product so every declaration in this loop is + // executed on its own terms rather than as a repeat of the one before it. + fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); + final CommandWrapper command = new CommandWrapperBuilder().createShareProductDividendPayoutCommand(productId) - .withJson(payload.toString()).build(); + .withJson(payload.toString()).build(idempotencyKey); final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(command); // The rate the board actually resolved, recorded as declared. Written here rather @@ -296,6 +342,10 @@ public class AllProductsDividendApiResource { row.put("dividendId", result.getSubResourceId()); created.add(row); } catch (final RuntimeException e) { + // Logged with the stack trace: the reason reported below comes from the exception's + // message, and several of the failures here carry none, leaving "Could not declare + // for this product" as the only trace of a product that silently received nothing. + log.error("Dividend declaration failed for product {} ({})", productId, productName, e); // Most often the distributable-profit ceiling: an earlier product in this same run has // already consumed the pool. Reported per product so the officer can see which ones // went through and why the rest did not, instead of losing all of them to one failure. @@ -319,31 +369,56 @@ public class AllProductsDividendApiResource { // ── Helpers ──────────────────────────────────────────────────────────── - /** One product's weighted paid-up capital for the period. */ + /** One product's weighted paid-up capital for the period, and how many members it covers. */ private static final class ProductCapital { private final Long productId; private final String productName; private final BigDecimal weightedCapital; + /** + * Members whose holdings make up that capital. + * + * Carried so the preview can say who a product's amount is actually spread across. An amount + * alone does not tell the officer whether it reaches the membership they expect — a product + * showing a plausible total against one member is the case worth catching before approval, + * not after. + */ + private final int memberCount; + /** + * Accounts that capital is spread across. + * + * Tracked alongside the member count because one person may hold shares in more than one + * product, and each holding earns and is claimed separately. The two numbers differ exactly + * when that happens, so reporting both is what tells an officer the split is intentional + * rather than a miscount. + */ + private final int accountCount; - private ProductCapital(final Long productId, final String productName, final BigDecimal weightedCapital) { + private ProductCapital(final Long productId, final String productName, final BigDecimal weightedCapital, + final int memberCount, final int accountCount) { this.productId = productId; this.productName = productName; this.weightedCapital = weightedCapital; + this.memberCount = memberCount; + this.accountCount = accountCount; } } /** Weighted paid-up capital for every share product, in one pass. */ private List capitalPerProduct(final LocalDate from, final LocalDate to) { final List capitals = new ArrayList<>(); - for (final Map product : jdbcTemplate - .queryForList("SELECT id, name, minimum_active_period_frequency FROM m_share_product ORDER BY id")) { + for (final Map product : jdbcTemplate.queryForList( + "SELECT id, name, minimum_active_period_frequency, allow_dividends_inactive_clients FROM m_share_product ORDER BY id")) { final Long productId = ((Number) product.get("id")).longValue(); final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0 : new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue(); - final BigDecimal capitalDays = weightedCapitalDays(productId, from, to, minimumActivePeriod); + final boolean allowInactiveClients = Boolean.TRUE.equals(product.get("allow_dividends_inactive_clients")); + final Map holding = weightedCapitalDays(productId, from, to, minimumActivePeriod, allowInactiveClients); + final BigDecimal capitalDays = (BigDecimal) holding.get("capital_days"); + final int memberCount = ((Number) holding.get("member_count")).intValue(); + final int accountCount = ((Number) holding.get("account_count")).intValue(); capitals.add(new ProductCapital(productId, (String) product.get("name"), - capitalDays.divide(BigDecimal.valueOf(365), 6, RoundingMode.HALF_UP))); + capitalDays.divide(BigDecimal.valueOf(365), 6, RoundingMode.HALF_UP), memberCount, accountCount)); } return capitals; } @@ -466,19 +541,144 @@ public class AllProductsDividendApiResource { * measured to the day after it. Without it a full financial year counts as 364 days and a product asking for the * obvious 365 would qualify nobody — and this preview would disagree with the declaration it is previewing. */ - private BigDecimal weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to, - final int minimumActivePeriod) { - final BigDecimal total = jdbcTemplate.queryForObject(""" - SELECT COALESCE(SUM(t.amount * ((? - GREATEST(t.transaction_date, ?)) + 1)), 0) + private Map weightedCapitalDays(final Long productId, final LocalDate from, final LocalDate to, + final int minimumActivePeriod, final boolean allowInactiveClients) { + // A closed account still earned dividend for the part of the period it was open, so when the + // product allows inactive clients it is included exactly as the declaration includes it -- + // ACTIVE, or CLOSED after the period began. Filtering to ACTIVE alone was what made this + // preview disagree with the declaration it previews: a product whose members had since closed + // their accounts showed a smaller capital here than the allocation actually paid out on, and + // the rate the officer saw was not the rate the members received. + final String statusClause = allowInactiveClients + ? " AND (sa.status_enum = 300 OR (sa.status_enum = 600 AND sa.closed_date > ?)) " + : " AND sa.status_enum = 300 "; + + final List params = new ArrayList<>(); + params.add(java.sql.Date.valueOf(to)); + params.add(java.sql.Date.valueOf(from)); + params.add(productId); + if (allowInactiveClients) { + params.add(java.sql.Date.valueOf(from)); + } + params.add(java.sql.Date.valueOf(to)); + params.add(java.sql.Date.valueOf(to)); + params.add(java.sql.Date.valueOf(from)); + params.add(minimumActivePeriod); + + // Capital and member count come out of one query against one set of rows, so the two can + // never describe different populations -- a count derived separately would drift from the + // amount it is meant to explain the moment either query's filters were touched. + final Map row = jdbcTemplate.queryForMap(""" + SELECT COALESCE(SUM(t.amount * ((? - GREATEST(t.transaction_date, ?)) + 1)), 0) AS capital_days, + COUNT(DISTINCT sa.client_id) AS member_count, + COUNT(DISTINCT sa.id) AS account_count FROM m_share_account sa JOIN m_share_account_transactions t ON t.account_id = sa.id - WHERE sa.product_id = ? AND sa.status_enum = 300 + WHERE sa.product_id = ? + """ + statusClause + """ AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true + AND COALESCE(t.amount_paid, 0) > 0 AND t.transaction_date <= ? AND ((? - GREATEST(t.transaction_date, ?)) + 1) >= ? - """, BigDecimal.class, java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), productId, java.sql.Date.valueOf(to), - java.sql.Date.valueOf(to), java.sql.Date.valueOf(from), minimumActivePeriod); - return total == null ? BigDecimal.ZERO : total; + """, params.toArray()); + return row; + } + + /** + * Refuses a declaration for a period that has not finished yet. + * + * A dividend is a share of the year's profit, and until the last day of the period has passed + * that profit is not a settled figure — declaring against it commits the society to paying out of + * a number that can still move with every transaction booked before 31 March. Each member's + * entitlement is also weighted by how long they held shares across the period, which cannot be + * known while days remain in it: a member joining in February would be paid as though the year + * ended today. + * + * Compared against the business date, not the server clock, so a branch operating on a back-dated + * business date is held to that date. + */ + private void rejectIfPeriodNotFinished(final LocalDate to) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (!to.isBefore(businessDate)) { + throw apiError(400, "The period ending " + to + " has not finished yet — a dividend can only be declared once the " + + "financial year is over and its profit is final. The current business date is " + businessDate + "."); + } + } + + /** + * Refuses a rate above the society's configured ceiling. + * + * {@code dividend-maximum-rate-percentage} is the board's own cap on what may be paid in a year — + * a co-operative society's bye-laws set one, and exceeding it needs a resolution rather than a + * typed figure. It was read only by the distributable-profit screen, which reported it without + * anything enforcing it, so a rate above the cap went through unchallenged. + * + * A missing or disabled configuration row means the society has set no ceiling, which is not a + * failure — the rate then stands on the profit ceiling alone. + */ + private void rejectIfRateAboveCeiling(final BigDecimal rate) { + final BigDecimal ceiling = configuredRateCeiling(); + if (ceiling != null && ceiling.signum() > 0 && rate.compareTo(ceiling) > 0) { + throw apiError(400, "A rate of " + rate.stripTrailingZeros().toPlainString() + "% is above the society's maximum of " + + ceiling.stripTrailingZeros().toPlainString() + "%. Lower the rate, or raise the limit in " + + "Configurations (dividend-maximum-rate-percentage) if the board has resolved a higher one."); + } + } + + /** The society's configured maximum rate, or null when no ceiling is set. */ + private BigDecimal configuredRateCeiling() { + final List> rows = jdbcTemplate.queryForList( + "SELECT value FROM c_configuration WHERE name = 'dividend-maximum-rate-percentage' AND enabled = true"); + if (rows.isEmpty() || rows.get(0).get("value") == null) { + return null; + } + return new BigDecimal(String.valueOf(rows.get(0).get("value"))); + } + + /** + * How many distinct members this rate would reach across every product. + * + * Each product is measured under its own rules — a product's minimum active period and whether it + * pays inactive clients both change who qualifies — and the resulting client ids are then unioned, + * so somebody holding shares in two products counts once. Summing the per-product counts instead + * would report more members than exist. + */ + private int distinctMemberCount(final LocalDate from, final LocalDate to) { + final Set members = new HashSet<>(); + for (final Map product : jdbcTemplate.queryForList( + "SELECT id, minimum_active_period_frequency, allow_dividends_inactive_clients FROM m_share_product")) { + final Long productId = ((Number) product.get("id")).longValue(); + final int minimumActivePeriod = product.get("minimum_active_period_frequency") == null ? 0 + : new BigDecimal(String.valueOf(product.get("minimum_active_period_frequency"))).intValue(); + final boolean allowInactiveClients = Boolean.TRUE.equals(product.get("allow_dividends_inactive_clients")); + + final String statusClause = allowInactiveClients + ? " AND (sa.status_enum = 300 OR (sa.status_enum = 600 AND sa.closed_date > ?)) " + : " AND sa.status_enum = 300 "; + + final List params = new ArrayList<>(); + params.add(productId); + if (allowInactiveClients) { + params.add(java.sql.Date.valueOf(from)); + } + params.add(java.sql.Date.valueOf(to)); + params.add(java.sql.Date.valueOf(to)); + params.add(java.sql.Date.valueOf(from)); + params.add(minimumActivePeriod); + + members.addAll(jdbcTemplate.queryForList(""" + SELECT DISTINCT sa.client_id + FROM m_share_account sa + JOIN m_share_account_transactions t ON t.account_id = sa.id + WHERE sa.product_id = ? + """ + statusClause + """ + AND t.status_enum = 300 AND t.type_enum = 500 AND t.is_active = true + AND COALESCE(t.amount_paid, 0) > 0 + AND t.transaction_date <= ? + AND ((? - GREATEST(t.transaction_date, ?)) + 1) >= ? + """, Long.class, params.toArray())); + } + return members.size(); } private boolean hasDeclarationFor(final Long productId, final LocalDate from, final LocalDate to) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java index b08c8c70a..5e623df47 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductDividendAssembler.java @@ -59,8 +59,13 @@ public class ShareProductDividendAssembler { ShareProductData product = (ShareProductData) this.shareProductReadPlatformService.retrieveOne(productId, false); MonetaryCurrency currency = new MonetaryCurrency(product.getCurrency().getCode(), product.getCurrency().getDecimalPlaces(), product.getCurrency().getInMultiplesOf()); - Collection shareAccountDatas = this.shareAccountReadPlatformService.retrieveAllShareAccountDataForDividends( - productId, product.getAllowDividendCalculationForInactiveClients(), dividendPeriodStartDate); + // Defaulted rather than unboxed directly: the flag is a nullable Boolean, and a product + // saved before the column existed carries null, which throws a NullPointerException on + // unboxing. That exception surfaced as "Could not declare for this product" with no reason + // attached, dropping the product from the run while the others went through. + final boolean allowInactiveClients = Boolean.TRUE.equals(product.getAllowDividendCalculationForInactiveClients()); + Collection shareAccountDatas = this.shareAccountReadPlatformService + .retrieveAllShareAccountDataForDividends(productId, allowInactiveClients, dividendPeriodStartDate); if (shareAccountDatas == null || shareAccountDatas.isEmpty()) { throw new ShareAccountsNotFoundException(product.getId()); } @@ -83,6 +88,15 @@ public class ShareProductDividendAssembler { dividendPeriodStartDate, dividendPeriodEndDate); for (ShareAccountData accountData : shareAccountDatas) { long numberOfShareDaysPerAccount = numberOfSharesdaysPerAccount.get(accountData.getId()); + // No share-days means the account held nothing inside this period at all -- its + // purchase falls after the period end, or never cleared the minimum active period, + // or was never paid for. Such an account is not a member of this dividend, so it + // gets no row: listing it would put a nil entitlement in front of a checker, count + // it in the declaration's member total, and report it as "below the minimum payable + // amount" when the truth is that it does not belong to this year at all. + if (numberOfShareDaysPerAccount <= 0) { + continue; + } double amountForAccount = numberOfShareDaysPerAccount * amountPerShareDay; // Rounded DOWN to a multiple of the configured figure — 546.789 becomes 545, 121.87 // becomes 120 at a multiple of 5. A branch pays this out as a Demand Draft and counts @@ -150,7 +164,26 @@ public class ShareProductDividendAssembler { final PurchasedSharesStatusType type = PurchasedSharesStatusType.fromInt(purchasedSharesData.getType().getId().intValue()); if (status.isApproved() && !type.isChargePayment()) { + // An approved purchase the member never paid for earns nothing. Approval is the + // society agreeing to allot the shares; the dividend is a return on capital that + // was actually brought in, so shares still awaiting payment are not capital yet. + // Counting them would pay a member out of everyone else's money for a holding + // they have not funded, and would dilute every paid-up member's entitlement. + if (type.isPurchased() && (purchasedSharesData.getAmountPaid() == null + || purchasedSharesData.getAmountPaid().signum() <= 0)) { + continue; + } + LocalDate shareStartDate = purchasedSharesData.getPurchasedDate(); + // A purchase made after the period ends belongs to a later year and has no + // holding inside this one. Left in, it is measured from its own date back to the + // period end — a negative span — and those negative share-days are subtracted + // from the product's total, shrinking every member's entitlement to pay for + // shares nobody held during the year. A 20-May-2026 purchase counted -2,450 + // share-days against FY 2025-26 here. + if (DateUtils.isAfter(shareStartDate, postingDate)) { + continue; + } if (DateUtils.isBefore(shareStartDate, lastDividendPostDate)) { shareStartDate = lastDividendPostDate; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index f35f10d21..b2ce9c6b8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -188,6 +188,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP final BigDecimal dividendAmount = this.fromApiJsonHelper .extractBigDecimalWithLocaleNamed(ShareProductApiConstants.dividendAmountParamName, element); + validatePeriodHasFinished(dividendPeriodEndDate); validateNoOverlappingDividendPeriod(productId, dividendPeriodStartDate, dividendPeriodEndDate); validateWithinDistributableProfit(productId, dividendPeriodStartDate, dividendPeriodEndDate, dividendAmount); final String financialYear = financialYearOf(dividendPeriodEndDate); @@ -227,6 +228,26 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP * that period's profit. Two declarations covering the same days would pay members twice for the same period, so any * overlap against an existing declaration for this product is blocked outright. */ + /** + * A dividend may only be declared once the period it covers is over. + * + * The payout is a share of that period's profit, and until the last day has passed the profit is + * not final — every transaction still to be booked moves the figure the declaration was sized on. + * Each member's entitlement is weighted by how long they held shares across the period too, which + * cannot be settled while days remain in it. + * + * Enforced here rather than only on the screen because this command is the single point every + * declaration passes through, including one posted straight to the API. + */ + private void validatePeriodHasFinished(final LocalDate dividendPeriodEndDate) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (!dividendPeriodEndDate.isBefore(businessDate)) { + throw new DividentProcessingException("period.not.finished", + "The period ending " + dividendPeriodEndDate + " has not finished yet, so its profit is not final. A dividend can " + + "only be declared once the financial year is over. The current business date is " + businessDate + "."); + } + } + private void validateNoOverlappingDividendPeriod(final Long productId, final LocalDate dividendPeriodStartDate, final LocalDate dividendPeriodEndDate) { final Integer overlappingCount = jdbcTemplate.queryForObject(""" @@ -300,6 +321,27 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP final Long distributableAccountId = dividendGlAccounts.distributableProfitAccount(); final Long settledAccountId = dividendGlAccounts.retainedProfitAccount(); + // The year's leftover profit is closed off once, not once per product. + // + // Every product's approval arrives here and computes the same society-wide remainder, so + // approving a second product for the same year moved that remainder to Bank Profit a second + // time -- 73,454.80 went across twice and left the Non-Distributable account holding a debit + // balance, money the society had swept out but never had. Each transaction balanced on its + // own, so the trial balance stayed at zero and hid it. + // + // Detected from the ledger rather than a flag on the declaration, because the entry is what + // actually moved the money: a close-off already posted for this financial year means this + // year's remainder is settled, whichever product's approval posted it. + final String financialYearLabel = financialYearOf(periodEndDate); + final Integer alreadyClosedOff = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_gl_journal_entry + WHERE reversed = false AND account_id = ? + AND description LIKE ? + """, Integer.class, distributableAccountId, "FY " + financialYearLabel + " undistributed profit closed off%"); + if (alreadyClosedOff != null && alreadyClosedOff > 0) { + return; + } + final String financialYear = financialYearOf(periodEndDate); // Dated to the day of approval, not the period end. Closing off the remainder is an act of // the approval, so it must appear in the ledger on the day the officer performed it — diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java index c006f010a..a78c88407 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java @@ -27,13 +27,17 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.time.LocalDateTime; import java.util.Collection; +import java.util.List; +import java.util.Map; import java.util.function.Supplier; import lombok.AllArgsConstructor; import lombok.Data; @@ -56,6 +60,7 @@ public class SponsorBankApiResource { private final SponsorBankRepository sponsorBankRepository; private final PlatformSecurityContext context; + private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; private final Gson gson = new GsonBuilder().serializeNulls().create(); /** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */ @@ -63,31 +68,103 @@ public class SponsorBankApiResource { try { return action.get(); } catch (final IllegalArgumentException | IllegalStateException e) { - final JsonObject body = new JsonObject(); - body.addProperty("message", e.getMessage()); - body.addProperty("defaultUserMessage", e.getMessage()); - body.addProperty("httpStatus", 400); - final JsonArray errors = new JsonArray(); - final JsonObject err = new JsonObject(); - err.addProperty("defaultUserMessage", e.getMessage()); - errors.add(err); - body.add("errors", errors); - throw new WebApplicationException( - Response.status(400).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + throw badRequest(e.getMessage()); + } catch (final org.springframework.dao.DataIntegrityViolationException e) { + // A constraint the form cannot pre-empt - in practice the unique bank name. Reported as + // the 400 it is, rather than surfacing as an "Internal Server Error" that tells an + // administrator nothing about what to change. + final String detail = e.getMostSpecificCause().getMessage(); + throw badRequest(detail != null && detail.toLowerCase().contains("name") + ? "A sponsor bank with this name already exists. Bank names must be unique." + : "This sponsor bank could not be saved: " + detail); + } catch (final org.springframework.jdbc.BadSqlGrammarException e) { + // Almost always a pending schema change: the DD Payable column arrives with changelog + // 0568, and a server started before it was applied has no such column. Said plainly so + // the fix (restart/migrate) is obvious rather than looking like a code fault. + throw badRequest("The sponsor bank tables are not up to date with this build - the DD Payable column is missing." + + " Restart the server so the pending database migrations are applied, then try again."); } } + private WebApplicationException badRequest(final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("message", message); + body.addProperty("defaultUserMessage", message); + body.addProperty("httpStatus", 400); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", message); + errors.add(err); + body.add("errors", errors); + return new WebApplicationException( + Response.status(400).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } + @GET @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Retrieve all Sponsor Banks", description = "Returns a list of all registered Sponsor Banks.") public Collection retrieveAllSponsorBanks() { + // The payable account's code and name travel with each bank so the list can show which GL a + // bank's drafts are credited to, and flag a bank that has none, without a second round trip. + final Map glNames = loadGlAccountNames(); return this.sponsorBankRepository.findAll().stream() - .map(sb -> new SponsorBankData(sb.getId(), sb.getName(), sb.getDescription(), sb.getIfscCode(), sb.getBankCode(), - sb.getSwiftCode())) + .map(sb -> { + final String[] gl = sb.getDdPayableGlAccountId() == null ? null : glNames.get(sb.getDdPayableGlAccountId()); + return new SponsorBankData(sb.getId(), sb.getName(), sb.getDescription(), sb.getIfscCode(), sb.getBankCode(), + sb.getSwiftCode(), sb.getDdPayableGlAccountId(), gl == null ? null : gl[0], gl == null ? null : gl[1]); + }) .toList(); } + private Map loadGlAccountNames() { + final Map names = new java.util.HashMap<>(); + for (final Map row : this.jdbcTemplate.queryForList("SELECT id, gl_code, name FROM acc_gl_account")) { + names.put(((Number) row.get("id")).longValue(), new String[] { (String) row.get("gl_code"), (String) row.get("name") }); + } + return names; + } + + /** + * Rejects a payable account that cannot serve as one. + * + * A DD payable is money the bank owes the sponsor until the draft is presented, so it has to be + * a liability account, and it has to accept postings. Catching that here means an administrator + * is told while choosing the account, rather than a clerk discovering it when a draft fails. + */ + private void validateDdPayableGlAccount(final Long glAccountId) { + if (glAccountId == null) { + return; + } + final List> rows = this.jdbcTemplate.queryForList( + "SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?", + glAccountId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("The selected DD Payable GL account does not exist."); + } + final Map gl = rows.get(0); + final String label = gl.get("gl_code") + " · " + gl.get("name"); + // 2 is LIABILITY in Fineract's GLAccountType enum. + if (((Number) gl.get("classification_enum")).intValue() != 2) { + throw new IllegalArgumentException( + "DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. " + + label + " is not a liability account."); + } + // 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header. + if (((Number) gl.get("account_usage")).intValue() == 2) { + throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under " + + label + "."); + } + // Read through both shapes: the column comes back as a Boolean on some drivers and as a + // 0/1 Number on others, and a plain Boolean cast would throw on the latter. + final Object disabled = gl.get("disabled"); + final boolean isDisabled = disabled instanceof Boolean b ? b + : disabled instanceof Number n && n.intValue() != 0; + if (isDisabled) { + throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled."); + } + } + @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @@ -99,16 +176,63 @@ public class SponsorBankApiResource { throw new IllegalArgumentException( "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); } + if (request.getName() == null || request.getName().isBlank()) { + throw new IllegalArgumentException("Sponsor Bank Name is required."); + } + validateDdPayableGlAccount(request.getDdPayableGlAccountId()); final AppUser currentUser = this.context.authenticatedUser(); - final SponsorBank sponsorBank = new SponsorBank( - request.getName(), - request.getDescription(), - request.getIfscCode() != null ? request.getIfscCode().trim().toUpperCase() : null, - request.getBankCode(), - request.getSwiftCode() != null ? request.getSwiftCode().trim().toUpperCase() : null, - currentUser, - LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()) - ); + // Set field by field rather than through the all-args constructor: that constructor is + // positional, so adding a column silently shifts every argument after it, and the shift + // type-checks whenever two neighbours share a type. + final SponsorBank sponsorBank = new SponsorBank(); + sponsorBank.setName(request.getName().trim()); + sponsorBank.setDescription(request.getDescription()); + sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() + ? request.getIfscCode().trim().toUpperCase() : null); + sponsorBank.setBankCode(request.getBankCode()); + sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() + ? request.getSwiftCode().trim().toUpperCase() : null); + sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); + sponsorBank.setCreatedBy(currentUser); + sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); + this.sponsorBankRepository.saveAndFlush(sponsorBank); + return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + }); + } + + /** + * Edits a sponsor bank, which in practice is mostly about pointing it at its DD Payable account. + * + * Existing banks predate the per-bank payable and have to be given one before they can issue any + * further drafts, and an account repointed at the wrong GL has to be correctable without + * deleting a bank that inventory and issued drafts already reference. + */ + @PUT + @Path("{sponsorBankId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Update a Sponsor Bank", description = "Updates a sponsor bank's details and the DD Payable GL account its drafts are credited to.") + public CommandProcessingResult updateSponsorBank(@PathParam("sponsorBankId") final Long sponsorBankId, + final SponsorBankRequest request) { + return safely(() -> { + final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId) + .orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found.")); + if (request.getIfscCode() != null && !request.getIfscCode().isBlank() + && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { + throw new IllegalArgumentException( + "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); + } + validateDdPayableGlAccount(request.getDdPayableGlAccountId()); + if (request.getName() != null && !request.getName().isBlank()) { + sponsorBank.setName(request.getName().trim()); + } + sponsorBank.setDescription(request.getDescription()); + sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() + ? request.getIfscCode().trim().toUpperCase() : null); + sponsorBank.setBankCode(request.getBankCode()); + sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() + ? request.getSwiftCode().trim().toUpperCase() : null); + sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); this.sponsorBankRepository.saveAndFlush(sponsorBank); return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); }); @@ -124,6 +248,11 @@ public class SponsorBankApiResource { private String ifscCode; private String bankCode; private String swiftCode; + /** The GL account drafts on this bank are credited to; null until an administrator sets it. */ + private Long ddPayableGlAccountId; + /** That account's code and name, so a caller can show it without fetching the chart. */ + private String ddPayableGlCode; + private String ddPayableGlName; } @Data @@ -135,5 +264,6 @@ public class SponsorBankApiResource { private String ifscCode; private String bankCode; private String swiftCode; + private Long ddPayableGlAccountId; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java index 3309b249c..dfa5cba14 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java @@ -288,7 +288,7 @@ public class SponsorDDApiResource { @Path("inventory/{inventoryId}/issue-request-batch") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Submit a multi-DD slip", description = "Clerk (maker) step: one account debit or cheque funds several drafts (different beneficiaries/amounts, charges optional per line) submitted together. A physical leaflet is auto-allocated per line from this inventory - the response shows exactly which DD number and beneficiary/amount was assigned to each line. No money moves until a manager approves the whole slip.") + @Operation(summary = "Submit a multi-DD slip", description = "Clerk (maker) step: one account debit, cheque or GL account debit funds several drafts (different beneficiaries/amounts, charges optional per line) submitted together. A physical leaflet is auto-allocated per line from this inventory - the response shows exactly which DD number and beneficiary/amount was assigned to each line. No money moves until a manager approves the whole slip.") public SponsorDDBatchIssueResult submitIssueRequestBatch(@PathParam("inventoryId") final Long inventoryId, final IssueRequestBatchRequest request) { final LocalDate issuedDate = LocalDate.parse(request.getIssuedDate(), this.dateFormatter); @@ -307,6 +307,7 @@ public class SponsorDDApiResource { request.getCurrentAccountId(), request.getChequeNumber(), request.getChequeBankName(), + request.getGlAccountId(), lines )); } @@ -315,9 +316,15 @@ public class SponsorDDApiResource { @Path("issue-requests/batch/{batchId}/approve") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Approve a multi-DD slip", description = "Manager/head-cashier (checker) step: one combined GL posting for the whole slip's total, then issues every draft in it. Approves every line together, not one at a time.") - public CommandProcessingResult approveIssueRequestBatch(@PathParam("batchId") final Long batchId) { - return safely(() -> this.issueRequestWritePlatformService.approveIssueRequestBatch(batchId)); + @Operation(summary = "Approve a multi-DD slip", description = "Manager/head-cashier (checker) step: one combined GL posting, then issues the slip's drafts. With no body (or no approvedRequestIds) the whole slip is issued. Supplying approvedRequestIds issues only those drafts, debits only for them, and rejects the rest of the slip - their DD numbers return to stock.") + public CommandProcessingResult approveIssueRequestBatch(@PathParam("batchId") final Long batchId, + final ApproveIssueRequestBatchRequest request) { + // An empty list is read as "no selection made" rather than "approve nothing": the existing + // callers post an empty body, and a slip that issued no drafts at all is a rejection, which + // has its own endpoint. + final List requested = request == null ? null : request.getApprovedRequestIds(); + final List approvedRequestIds = requested == null || requested.isEmpty() ? null : requested; + return safely(() -> this.issueRequestWritePlatformService.approveIssueRequestBatch(batchId, approvedRequestIds)); } @POST @@ -684,11 +691,13 @@ public class SponsorDDApiResource { private Long clientId; private Long issuingBranchId; private String issuedDate; - private String paymentMethod; // ACCOUNT_DEBIT or CHEQUE only — a slip does not support CASH + private String paymentMethod; // ACCOUNT_DEBIT, CHEQUE or GL — a slip does not support CASH private Long savingsAccountId; private Long currentAccountId; private String chequeNumber; private String chequeBankName; + /** The GL account a GL-funded slip is debited from; unused by every other payment method. */ + private Long glAccountId; private List lines; } @@ -706,6 +715,17 @@ public class SponsorDDApiResource { private String rejectionReason; } + /** + * Which drafts of a slip the customer still wants. + * + * Null or empty means all of them, so an existing caller that posts an empty body keeps issuing + * the whole slip exactly as before. + */ + @lombok.Data + public static class ApproveIssueRequestBatchRequest { + private List approvedRequestIds; + } + @lombok.Data public static class ReprintRequestRequest { private String reason; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java index 59db2f8d7..e6adaf283 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/domain/SponsorBank.java @@ -54,6 +54,21 @@ public class SponsorBank extends AbstractPersistableCustom { @Column(name = "swift_code", length = 11) private String swiftCode; + /** + * The GL account a draft drawn on this bank is credited to. + * + * A draft drawn on SBI is owed to SBI and one drawn on HDFC is owed to HDFC, so what is + * outstanding to either can only be answered if each bank credits its own payable - which is + * precisely the figure a settlement with that bank is reconciled against. Held here rather than + * in the global DD GL mapping for that reason; charges income, GST and the clearing accounts + * stay there, being this bank's own income and tax whichever sponsor the draft is drawn on. + * + * Nullable only so an existing bank can be set up after upgrading. Issuing a draft on a bank + * with none configured is refused rather than falling back to another bank's account. + */ + @Column(name = "dd_payable_gl_account_id") + private Long ddPayableGlAccountId; + @ManyToOne @JoinColumn(name = "createdby_id", nullable = false) private AppUser createdBy; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java index 1e1315337..0d8f39e94 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformService.java @@ -55,19 +55,20 @@ public interface SponsorDDIssueRequestWritePlatformService { CommandProcessingResult rejectIssueRequest(Long issueRequestId, String rejectionReason); /** - * Clerk (maker) step for a multi-DD "slip": one customer account debit (or one cheque) funds - * several drafts in one submission - a physical leaflet is auto-allocated per line from the given - * inventory, and every resulting row shares one batch id so the checker approves/rejects the whole - * slip as a unit rather than line by line. CASH is not supported here - only ACCOUNT_DEBIT/CHEQUE, - * since a cash slip would also need Transaction Entry changes to record one lump intake for N - * drafts, which is a separate piece of work. Returns exactly which physical leaflet (DD number) - * was auto-allocated to each line and for what amount, since that allocation only happens inside - * this call (locked against a concurrent slip grabbing the same leaflet) and can't be known by the + * Clerk (maker) step for a multi-DD "slip": one customer account debit, one cheque, or one GL + * account debit funds several drafts in one submission - a physical leaflet is auto-allocated per + * line from the given inventory, and every resulting row shares one batch id so the checker + * approves/rejects the whole slip as a unit rather than line by line. CASH is not supported here - + * a cash slip would also need Transaction Entry changes to record one lump intake for N drafts, + * and a single counted pile of notes cannot be split across drafts without the denomination total + * ceasing to prove what was actually taken. Returns exactly which physical leaflet (DD number) was + * auto-allocated to each line and for what amount, since that allocation only happens inside this + * call (locked against a concurrent slip grabbing the same leaflet) and can't be known by the * caller beforehand. */ SponsorDDBatchIssueResult submitIssueRequestBatch(Long inventoryId, Long clientId, Long issuingBranchId, LocalDate issuedDate, String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName, - List lines); + Long glAccountId, List lines); /** * Checker step for a multi-DD slip: one combined withdrawal for the slip's total face value, one @@ -77,6 +78,16 @@ public interface SponsorDDIssueRequestWritePlatformService { */ CommandProcessingResult approveIssueRequestBatch(Long batchId); + /** + * As above, but issuing only the drafts the customer still wants. + * + * approvedRequestIds null approves the whole slip (identical to the overload above). Otherwise + * only those rows are issued, the debit covers only them, and the rest of the slip is rejected in + * the same transaction - their leaflets were never taken off AVAILABLE, so those DD numbers + * return to stock for the next customer rather than leaving a gap in the series. + */ + CommandProcessingResult approveIssueRequestBatch(Long batchId, List approvedRequestIds); + /** Rejects every line of a pending multi-DD slip together; no money has moved yet for ACCOUNT_DEBIT/CHEQUE. */ CommandProcessingResult rejectIssueRequestBatch(Long batchId, String rejectionReason); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index 590378db0..cda677df1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -189,7 +189,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI if (glAccountId == null) { throw new IllegalArgumentException("GL Account is required for a GL-funded DD issuance."); } - final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue(); + // The sponsor bank's own payable, since that is what this draft will be credited to. + final Long ddPayableGlAccountId = findDdPayableGlAccountIdForLeaflet(leafletId); if (glAccountId.equals(ddPayableGlAccountId)) { final Map ddPayable = this.voucherService.glAccount(ddPayableGlAccountId); throw new IllegalArgumentException("GL Account cannot be the same as the DD Payable account (" @@ -268,12 +269,15 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI @Transactional public SponsorDDBatchIssueResult submitIssueRequestBatch(final Long inventoryId, final Long clientId, final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId, final Long currentAccountId, - final String chequeNumber, final String chequeBankName, final List lines) { + final String chequeNumber, final String chequeBankName, final Long glAccountId, final List lines) { final AppUser currentUser = this.context.authenticatedUser(); - if (!METHOD_ACCOUNT_DEBIT.equals(paymentMethod) && !METHOD_CHEQUE.equals(paymentMethod)) { - throw new IllegalArgumentException("A multi-DD slip only supports ACCOUNT_DEBIT or CHEQUE payment methods."); + // CASH alone is excluded: one counted pile of notes cannot be apportioned across drafts + // without the denomination total ceasing to prove what was actually taken at the counter. + if (!METHOD_ACCOUNT_DEBIT.equals(paymentMethod) && !METHOD_CHEQUE.equals(paymentMethod) + && !METHOD_GL.equals(paymentMethod)) { + throw new IllegalArgumentException("A multi-DD slip only supports ACCOUNT_DEBIT, CHEQUE or GL payment methods."); } if (lines == null || lines.isEmpty()) { throw new IllegalArgumentException("At least one DD line is required."); @@ -302,6 +306,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI validatePaymentMethodFields(paymentMethod, clientId, savingsAccountId, currentAccountId, chequeNumber, chequeBankName, null, null, null); + // A GL-funded slip is raised against no member at all, so clientId is legitimately null here + // (validatePaymentMethodFields refuses one being supplied). Every other method requires it. + if (!METHOD_GL.equals(paymentMethod) && clientId == null) { + throw new IllegalArgumentException("Client ID is required for this DD slip."); + } + final BigDecimal totalFaceValue = lines.stream().map(SponsorDDSlipLine::faceValue).reduce(BigDecimal.ZERO, BigDecimal::add); final BigDecimal totalCharges = lines.stream() .map(l -> l.chargesAmount() != null ? l.chargesAmount() : BigDecimal.ZERO) @@ -342,6 +352,23 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .map(l -> computeGst(l.chargesAmount() != null ? l.chargesAmount() : BigDecimal.ZERO, gstPercentage)) .reduce(BigDecimal.ZERO, BigDecimal::add); validateSufficientBalance(savingsAccountId, currentAccountId, totalFaceValue.add(totalCharges).add(totalGstForBalanceCheck)); + } else if (METHOD_GL.equals(paymentMethod)) { + // No client and no member account - an institutional slip funded straight from a GL + // account, mirroring the single-DD GL path. Debiting the same account the drafts are + // credited to (DD Payable) would be a self-cancelling entry with no real movement, so it + // is refused here exactly as it is for a single draft. A GL account carries no balance + // check, so there is nothing to validate the slip total against. + if (glAccountId == null) { + throw new IllegalArgumentException("GL Account is required for a GL-funded DD slip."); + } + // Read from the inventory's sponsor bank: the slip's leaflets have not been allocated + // yet at this point, and they all come from this one inventory anyway. + final Long ddPayableGlAccountId = findDdPayableGlAccountIdForInventory(inventoryId); + if (glAccountId.equals(ddPayableGlAccountId)) { + final Map ddPayable = this.voucherService.glAccount(ddPayableGlAccountId); + throw new IllegalArgumentException("GL Account cannot be the same as the DD Payable account (" + + ddPayable.get("gl_code") + ") these drafts are credited to — pick a different GL account to debit."); + } } else { validateClientExists(clientId); } @@ -366,11 +393,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "(leaflet_id, client_id, beneficiary_name, payable_at_branch, payable_at_city, face_value, " + "charges_amount, gst_amount, gst_percentage_applied, issuing_branch_id, issued_date, payment_method, " + "savings_account_id, current_account_id, cheque_number, cheque_bank_name, status, maker_id, " - + "created_date, batch_id) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + + "created_date, batch_id, gl_account_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", new String[] { "id" }); ps.setLong(1, leafletId); - ps.setLong(2, clientId); + // Null on a GL-funded slip, which is raised against no member — setLong would throw. + if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT); ps.setString(3, line.beneficiaryName()); ps.setString(4, line.payableAtBranch()); ps.setString(5, line.payableAtCity()); @@ -389,6 +417,9 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI ps.setLong(18, currentUser.getId()); ps.setObject(19, now); if (batchIdForThisRow != null) ps.setLong(20, batchIdForThisRow); else ps.setNull(20, java.sql.Types.BIGINT); + // The GL account this slip is funded from, read back by the checker to post the debit + // leg — the same column and the same meaning as on a single GL-funded draft. + if (glAccountId != null) ps.setLong(21, glAccountId); else ps.setNull(21, java.sql.Types.BIGINT); return ps; }, kh); final long newId = Objects.requireNonNull(kh.getKey()).longValue(); @@ -402,15 +433,40 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI return new SponsorDDBatchIssueResult(batchId, resultLines); } - /** Locks and returns the next `count` AVAILABLE leaflets in this inventory, oldest series first. */ + /** + * Locks and returns the next `count` leaflets in this inventory that are free to allocate, oldest + * series first. + * + * Free means AVAILABLE *and* not already spoken for by a pending request. A leaflet keeps its + * AVAILABLE status from submission right up to approval - nothing marks it reserved in between - + * so status alone would hand the same DD number to a second slip while the first was still + * waiting on a checker. Both would then be approved against one physical form, and the second + * customer's draft would carry a number already issued to somebody else. The single-DD path + * refuses a leaflet with a pending request against it for exactly this reason; a slip has to + * apply the same test, and does it here in the same locked statement that picks the run. + */ private List> allocateAvailableLeaflets(final Long inventoryId, final int count) { + // Ordered by the printed number, not by row id. + // + // A book's blanks have to be consumed in series order - that is what makes an unused number + // in the middle of a run a thing anyone notices, and it is the order the physical forms sit + // in the clerk's hand. Row id is insertion order, which matches the series only for the + // first book registered: a later book, or one whose rows were re-registered, allocates from + // the middle and hands out 1006, 1008, 1001 instead of 1001, 1002, 1003. + // + // The cast is guarded because a series need not be plain digits; a prefixed or hyphenated + // number sorts as text after the numeric ones, exactly as the leaflet register displays it + // (see LEAFLET_SERIES_ORDER in SponsorDDReadPlatformServiceImpl). final List> rows = this.jdbcTemplate.queryForList( - "SELECT id, dd_number FROM m_sponsor_dd_leaflet WHERE inventory_id = ? AND status = 'AVAILABLE' " - + "ORDER BY id ASC LIMIT ? FOR UPDATE", + "SELECT l.id, l.dd_number FROM m_sponsor_dd_leaflet l WHERE l.inventory_id = ? AND l.status = 'AVAILABLE' " + + "AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request r WHERE r.leaflet_id = l.id AND r.status = 'PENDING') " + + "ORDER BY CASE WHEN l.dd_number ~ '^[0-9]+$' THEN CAST(l.dd_number AS BIGINT) END ASC NULLS LAST, " + + "l.dd_number ASC LIMIT ? FOR UPDATE", inventoryId, count); if (rows.size() < count) { throw new IllegalStateException("Not enough available leaflets in this inventory: need " + count - + ", only " + rows.size() + " available. Register more inventory or reduce the number of drafts."); + + ", only " + rows.size() + " free to allocate (leaflets awaiting approval on another request are not" + + " counted). Register more inventory or reduce the number of drafts."); } return rows; } @@ -517,7 +573,9 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // gst_percentage, so approving a request never applies a different rate than the maker saw. final BigDecimal gstAmount = (BigDecimal) request.get("gst_amount"); final Map glMapping = findGlMappingOrThrow(paymentMethod, chargesAmount); - final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue(); + // Credited to the sponsor bank this leaflet is drawn on, not to a single shared payable — + // the draft is that bank's liability, and its balance is what settlement with them turns on. + final Long ddPayableGlAccountId = findDdPayableGlAccountIdForLeaflet(leafletId); final Long chargesGlAccountId = glMapping.get("charges_income_gl_account_id") != null ? ((Number) glMapping.get("charges_income_gl_account_id")).longValue() : null; final Long gstGlAccountId = glMapping.get("gst_payable_gl_account_id") != null @@ -740,11 +798,31 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI @Override @Transactional public CommandProcessingResult approveIssueRequestBatch(final Long batchId) { + return approveIssueRequestBatch(batchId, null); + } + + /** + * Approves a slip, optionally only the drafts the customer still wants. + * + * A customer who ordered three drafts and then asks for only two is a counter reality, not an + * exception: the slip is one funding instruction, so the answer is to debit for what is actually + * issued rather than to make the clerk cancel the slip and retype it. Passing null for + * approvedRequestIds approves every line, which is the ordinary case and what the no-argument + * overload does. + * + * The drafts left out are rejected in the same transaction. Their leaflets were never moved off + * AVAILABLE at submission - allocation only reserves the number, and issueDemandDraft is what + * marks it ISSUED - so a dropped draft's number returns to stock for the next customer with + * nothing further to undo, and no gap is punched in the series. + */ + @Override + @Transactional + public CommandProcessingResult approveIssueRequestBatch(final Long batchId, final List approvedRequestIds) { final AppUser currentUser = this.context.authenticatedUser(); validateCheckerRole(currentUser); - final List> requests = findBatchRequestsOrThrow(batchId); - for (final Map request : requests) { + final List> allRequests = findBatchRequestsOrThrow(batchId); + for (final Map request : allRequests) { if (!STATUS_PENDING.equals(request.get("status"))) { throw new IllegalStateException( "DD slip is not fully pending - request #" + request.get("id") + " is already " + request.get("status") + "."); @@ -755,6 +833,33 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI } } + // Which lines are actually being issued. A null selection means all of them; an explicit one + // is validated against this slip so a mistyped id cannot silently approve nothing. + final List> requests; + final List> droppedRequests = new java.util.ArrayList<>(); + if (approvedRequestIds == null) { + requests = allRequests; + } else { + final java.util.Set selected = new java.util.HashSet<>(approvedRequestIds); + final java.util.Set slipIds = allRequests.stream() + .map(r -> ((Number) r.get("id")).longValue()).collect(java.util.stream.Collectors.toSet()); + for (final Long id : selected) { + if (!slipIds.contains(id)) { + throw new IllegalArgumentException("Request #" + id + " is not part of this DD slip."); + } + } + requests = allRequests.stream() + .filter(r -> selected.contains(((Number) r.get("id")).longValue())) + .collect(java.util.stream.Collectors.toList()); + allRequests.stream() + .filter(r -> !selected.contains(((Number) r.get("id")).longValue())) + .forEach(droppedRequests::add); + if (requests.isEmpty()) { + throw new IllegalArgumentException( + "Select at least one draft to approve. To issue none of them, reject the slip instead."); + } + } + final Map first = requests.get(0); final String paymentMethod = (String) first.get("payment_method"); final Long issuingBranchId = ((Number) first.get("issuing_branch_id")).longValue(); @@ -782,7 +887,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI validateDdLimitForBatch(glMapping, entry.getKey(), entry.getValue(), issuedDate, batchId); } - final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue(); + // The sponsor bank's own payable, for the drafts actually being issued. Checked across all of + // them so a slip somehow spanning two banks is refused rather than crediting both to one. + final Long ddPayableGlAccountId = findDdPayableGlAccountIdForLeaflets(requests.stream() + .map(r -> ((Number) r.get("leaflet_id")).longValue()).collect(java.util.stream.Collectors.toList())); final Long chargesGlAccountId = glMapping.get("charges_income_gl_account_id") != null ? ((Number) glMapping.get("charges_income_gl_account_id")).longValue() : null; final Long gstGlAccountId = glMapping.get("gst_payable_gl_account_id") != null @@ -792,9 +900,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI .map(r -> (String) findLeafletOrThrow(((Number) r.get("leaflet_id")).longValue()).get("dd_number")) .collect(java.util.stream.Collectors.joining(", ")); - // ONE combined withdrawal for the whole slip - face value + charges + GST across every line - - // so the real account balance drops by exactly what the combined GL entry below debits the - // control account for. + // ONE combined withdrawal for the drafts actually being issued - face value + charges + GST + // across each of them - so the real account balance drops by exactly what the combined GL + // entry below debits the control account for. On a partial approval the dropped lines are + // absent from `requests`, so the customer is never debited for a draft they declined. final BigDecimal totalWithdrawal = totalFaceValue.add(totalCharges).add(totalGst); Long resultingSavingsTransactionId = null; Long resultingCurrentTransactionId = null; @@ -819,6 +928,16 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI throw new IllegalStateException( "Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping."); } + } else if (METHOD_GL.equals(paymentMethod)) { + // The GL account the slip named at submission, not a mapped clearing account: a + // GL-funded slip is debited from the account the clerk chose, exactly as a single + // GL-funded draft is. No withdrawal is executed because a GL account has no passbook - + // the journal entry below IS the movement. + final Object slipGlAccount = first.get("gl_account_id"); + if (slipGlAccount == null) { + throw new IllegalStateException("This GL-funded DD slip has no GL account recorded against it."); + } + drGlAccountId = ((Number) slipGlAccount).longValue(); } else { drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue(); } @@ -836,12 +955,30 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI } final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); - this.jdbcTemplate.update( - "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, " - + "resulting_savings_transaction_id = ?, resulting_current_transaction_id = ?, resulting_journal_entry_id = ? " - + "WHERE batch_id = ?", - STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, - resultingJournalEntryId, batchId); + // Marked by id rather than by batch_id: on a partial approval the slip's other rows are being + // rejected in the same breath, and "WHERE batch_id" would stamp them APPROVED as well - the + // customer would be issued drafts they had just declined. + for (final Map request : requests) { + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, " + + "resulting_savings_transaction_id = ?, resulting_current_transaction_id = ?, resulting_journal_entry_id = ? " + + "WHERE id = ?", + STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId, + resultingJournalEntryId, ((Number) request.get("id")).longValue()); + } + + // The drafts the customer decided against. Nothing to unwind: they were never issued and the + // debit above covered only the approved lines, so the leaflet simply stays AVAILABLE and goes + // to whoever asks next. The reason is recorded so the register shows why the number was freed + // rather than leaving an unexplained rejection. + for (final Map dropped : droppedRequests) { + this.jdbcTemplate.update( + "UPDATE m_sponsor_dd_issue_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? " + + "WHERE id = ?", + STATUS_REJECTED, currentUser.getId(), now, + "Not required by the customer — dropped when the rest of DD slip #" + batchId + " was approved.", + ((Number) dropped.get("id")).longValue()); + } return new CommandProcessingResultBuilder().withEntityId(batchId).build(); } @@ -1034,6 +1171,84 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI return rows.isEmpty() ? 1L : ((Number) rows.get(0).get("id")).longValue(); } + /** + * The DD Payable GL account for the sponsor bank a given leaflet is drawn on. + * + * A draft drawn on SBI is owed to SBI and one drawn on HDFC to HDFC, so each bank credits its own + * payable - that per-bank balance is what a settlement with the bank is reconciled against, and a + * single shared account would make it unanswerable. Configured per bank on Organization > Sponsor + * Banks. + * + * Required, not defaulted: a bank with no payable configured has its drafts refused here by name. + * Silently falling back to some other bank's account would credit SBI's liability with money owed + * to HDFC, and nothing downstream would reveal it - the ledger would balance either way. + */ + private Long findDdPayableGlAccountIdForLeaflet(final Long leafletId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT sb.id AS bank_id, sb.name AS bank_name, sb.dd_payable_gl_account_id " + + "FROM m_sponsor_dd_leaflet l " + + "JOIN m_sponsor_dd_inventory inv ON inv.id = l.inventory_id " + + "LEFT JOIN m_sponsor_bank sb ON sb.id = inv.sponsor_bank_id " + + "WHERE l.id = ?", leafletId); + if (rows.isEmpty() || rows.get(0).get("bank_id") == null) { + throw new IllegalStateException( + "This DD leaflet's inventory is not linked to a sponsor bank, so there is no DD Payable account to credit. " + + "Ask an administrator to check the inventory's sponsor bank in Organization > Sponsor DD Inventory."); + } + final Object payable = rows.get(0).get("dd_payable_gl_account_id"); + if (payable == null) { + throw new IllegalStateException("No DD Payable GL account is configured for sponsor bank '" + + rows.get(0).get("bank_name") + "'. Ask an administrator to set it in Organization > Sponsor Banks" + + " before issuing drafts drawn on this bank."); + } + return ((Number) payable).longValue(); + } + + /** + * The same thing by inventory, for a slip whose leaflets have not been allocated yet. + * + * An inventory is a book of blanks received from one sponsor bank, so the bank - and therefore + * the payable - is known before any leaflet is picked. + */ + private Long findDdPayableGlAccountIdForInventory(final Long inventoryId) { + final List> rows = this.jdbcTemplate.queryForList( + "SELECT sb.id AS bank_id, sb.name AS bank_name, sb.dd_payable_gl_account_id " + + "FROM m_sponsor_dd_inventory inv LEFT JOIN m_sponsor_bank sb ON sb.id = inv.sponsor_bank_id " + + "WHERE inv.id = ?", inventoryId); + if (rows.isEmpty() || rows.get(0).get("bank_id") == null) { + throw new IllegalStateException( + "This DD inventory is not linked to a sponsor bank, so there is no DD Payable account to credit. " + + "Ask an administrator to check it in Organization > Sponsor DD Inventory."); + } + final Object payable = rows.get(0).get("dd_payable_gl_account_id"); + if (payable == null) { + throw new IllegalStateException("No DD Payable GL account is configured for sponsor bank '" + + rows.get(0).get("bank_name") + "'. Ask an administrator to set it in Organization > Sponsor Banks" + + " before issuing drafts drawn on this bank."); + } + return ((Number) payable).longValue(); + } + + /** + * The same account for a whole slip, refusing a slip whose drafts are drawn on different banks. + * + * One slip posts one journal entry, which can credit only one payable. Leaflets are allocated + * from a single inventory and an inventory belongs to one bank, so a mixed slip should not arise + * - but it would silently credit every draft to the first leaflet's bank if it did. + */ + private Long findDdPayableGlAccountIdForLeaflets(final List leafletIds) { + final java.util.Set payables = new java.util.LinkedHashSet<>(); + for (final Long leafletId : leafletIds) { + payables.add(findDdPayableGlAccountIdForLeaflet(leafletId)); + } + if (payables.size() > 1) { + throw new IllegalStateException( + "This DD slip's drafts are drawn on sponsor banks with different DD Payable accounts, so they cannot share one" + + " posting. Raise a separate slip per sponsor bank."); + } + return payables.iterator().next(); + } + private Map findGlMappingOrThrow(final String paymentMethod, final BigDecimal chargesAmount) { final List> rows = this.jdbcTemplate.queryForList( "SELECT dd_payable_gl_account_id, cheque_clearing_gl_account_id, cash_gl_account_id, charges_income_gl_account_id, " @@ -1043,10 +1258,11 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI "DD GL mapping is not configured. Ask an administrator to configure the Sponsor DD GL accounts in Admin > Sponsor DD GST / GL Settings first."); } final Map mapping = rows.get(0); - if (mapping.get("dd_payable_gl_account_id") == null) { - throw new IllegalStateException( - "No Sponsor DD Payable GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts."); - } + // The payable is deliberately NOT checked here any more: it now belongs to the sponsor bank + // the draft is drawn on (see findDdPayableGlAccountIdForLeaflet), and this global row keeps + // only what is the same for every sponsor — this bank's own charges income, GST, cash and + // cheque-clearing accounts, the GST rate and the DD limit. The column is left in place for + // the banks seeded from it on upgrade. if (METHOD_CHEQUE.equals(paymentMethod) && mapping.get("cheque_clearing_gl_account_id") == null) { throw new IllegalStateException( "No cheque-clearing GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before accepting cheque payments."); diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 108e7421a..020cdbd2f 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} 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 0ad5ba659..545b6450b 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 @@ -114,4 +114,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0569_aml_alert_release_stale_under_review.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0569_aml_alert_release_stale_under_review.xml new file mode 100644 index 000000000..72a8fc279 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0569_aml_alert_release_stale_under_review.xml @@ -0,0 +1,49 @@ + + + + + + + + UPDATE m_aml_alert + SET status = 'RAISED', + reviewed_by = NULL, + reviewed_on = NULL + WHERE status = 'UNDER_REVIEW' + AND disposition IS NULL; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0570_add_dd_payable_gl_to_sponsor_bank.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0570_add_dd_payable_gl_to_sponsor_bank.xml new file mode 100644 index 000000000..661c58b49 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0570_add_dd_payable_gl_to_sponsor_bank.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_sponsor_bank + SET dd_payable_gl_account_id = ( + SELECT dd_payable_gl_account_id FROM m_sponsor_dd_gl_mapping + WHERE dd_payable_gl_account_id IS NOT NULL LIMIT 1 + ) + WHERE dd_payable_gl_account_id IS NULL; + + + +