diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index bff7f2df1..d2a8a5be9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -341,10 +341,18 @@ public class ShareAdvanceCollectionApiResource { this.context.authenticatedUser(); // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. + // gl_code carries the same subquery listUnapplied() uses -- the GL Account picker on screen + // follows whichever receipt gets ticked (see toggleVoucher()/toggle() on the client), and a + // receipt resolved through this search must feed that picker exactly the same as one resolved + // from the member's own list, or the picker is silently left on "Select GL account…" for + // every voucher found this way. final List> rows = jdbcTemplate .queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," - + " c.applied_share_account_id " + + " c.applied_share_account_id," + + " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false" + + " ORDER BY je.id LIMIT 1) AS gl_code" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo); @@ -938,7 +946,14 @@ public class ShareAdvanceCollectionApiResource { return jdbcTemplate.queryForMap( "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id," + // Same GL-account subquery as listUnapplied()/findByVoucher() -- a freshly + // adopted voucher must feed the on-screen GL Account picker exactly like one + // that was already sitting on the member's list, or the picker is silently + // left on "Select GL account…" for every voucher adopted this way. + + " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false" + + " ORDER BY je.id LIMIT 1) AS gl_code" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", receiptNo); } @@ -1014,7 +1029,14 @@ public class ShareAdvanceCollectionApiResource { return jdbcTemplate.queryForMap( "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id," + // Same GL-account subquery as listUnapplied()/findByVoucher() -- a freshly + // adopted voucher must feed the on-screen GL Account picker exactly like one + // that was already sitting on the member's list, or the picker is silently + // left on "Select GL account…" for every voucher adopted this way. + + " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false" + + " ORDER BY je.id LIMIT 1) AS gl_code" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", receiptNo); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index a250b0e45..08d596d26 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -746,8 +746,14 @@ public class ClerkDisbursementRequestApiResource { } else { final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1); if (cashVaultGlId != null) { - postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal, + approvedGlTxnId = postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal, officeId, currencyCode, txnDateStr, description); + } else if (!withdrawalSavingsTxnRefs.isEmpty()) { + // No bridging entry was needed: the withdrawal's own auto-posted credit + // leg already lands on the target GL directly (the savings product's own + // payment-type mapping points there). That withdrawal's own transaction is + // therefore the real posting -- there is no second one to remember instead. + approvedGlTxnId = "S" + withdrawalSavingsTxnRefs.get(0); } } } @@ -1440,7 +1446,15 @@ public class ClerkDisbursementRequestApiResource { return null; } - private void postGlOffsetEntry( + /** + * @return the real {@code acc_gl_journal_entry.transaction_id} this posts, so a caller that needs + * to remember which posting was theirs — the collection fallback below, most importantly + * — is not left with nothing to record. Returning it here rather than discarding it, as + * this method used to, was the entire reason {@code gl_transaction_id} stayed blank on + * every clerk collection that fell back to this path: nothing downstream (Share Advance + * Collection's GL-detail popup among them) had anything to look the posting up by. + */ + private String postGlOffsetEntry( final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final Long officeId, final String currencyCode, final String txnDateStr, final String comments) { @@ -1476,7 +1490,9 @@ public class ClerkDisbursementRequestApiResource { .build(); fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); // The disbursement register raises its own voucher; suppress the journal command's own. - voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd)); + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd)); + return result != null ? result.getTransactionId() : null; } // ── Combined GL entry helpers ───────────────────────────────────────────── diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java new file mode 100644 index 000000000..1ac70b446 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java @@ -0,0 +1,2221 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * The share reporting set the Share menu opens: a member's closing share capital, one account's + * statement, what was allotted in a period, and where every account currently stands. + * + *

+ * All four read the same two tables the rest of the share module writes to - {@code m_share_account} + * and {@code m_share_account_transactions} - so a figure here is the figure the ledger has, never a + * separately-maintained total that can drift from it. Shares held is always derived by walking the + * transactions (purchases less redemptions) rather than reading + * {@code m_share_account.total_approved_shares}, because that column is a running total the account + * carries today and cannot answer "as on" a date in the past, which is the whole point of a balance + * report. + * + *

+ * Only settled movements count: a transaction is included when it is approved + * ({@code status_enum = 300}) and still active, so a tranche a checker has not approved yet never + * inflates a member's holding or the society's issued capital. + * + *

+ * Read-only; nothing here writes. + */ +@Path("/v1/sharereports") +@Component +@Tag(name = "Share Reports", description = "Share capital balances, account statements, allotments and status summaries.") +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareReportsApiResource { + + // ── m_share_account_transactions.type_enum (PurchasedSharesStatusType) ── + private static final int TYPE_PURCHASED = 500; + private static final int TYPE_REDEEMED = 600; + + // ── m_share_account_transactions.status_enum ─────────────────────────── + private static final int TXN_APPROVED = 300; + + // ── m_share_account.status_enum ──────────────────────────────────────── + private static final int ACCOUNT_REJECTED = 500; + private static final int ACCOUNT_CLOSED = 600; + + /** + * Why a share account's holding reads the way it does, as a value rather than something a reader + * has to infer from a zero. + * + *

+ * A nil holding is the most over-loaded figure on any share register: a closed account, an active + * account whose money was never collected, and an account redeemed down to nothing all print the + * same "0", and they mean completely different things to whoever is reading. Deriving the reason + * once here, in SQL, keeps every report telling the member the same story, and stops a closed + * account from looking open on one screen and closed on the next. + * + *

+ * + *

+ * Expects {@code sa} to be the share account and {@code bal.shares_held} the paid-up holding the + * query has already worked out, so it reads the same balance the row prints rather than a second + * opinion of its own. + */ + private static final String HOLDING_STATE_SQL = """ + CASE + WHEN sa.status_enum = %d THEN 'REJECTED' + WHEN sa.status_enum = %d THEN 'CLOSED' + WHEN sa.status_enum <> 300 THEN 'NOT_ACTIVE' + WHEN COALESCE(bal.shares_held, 0) > 0 THEN 'HOLDING' + WHEN COALESCE(sa.total_approved_shares, 0) > 0 + AND COALESCE(sa.collection_applied, false) = false THEN 'UNPAID' + ELSE 'NIL' + END AS "holdingState", + CASE + WHEN sa.status_enum = 300 AND COALESCE(sa.collection_applied, false) = false + THEN COALESCE(sa.total_approved_shares, 0) + ELSE 0 + END AS "unpaidShares", + """.formatted(ACCOUNT_REJECTED, ACCOUNT_CLOSED); + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // ══════════════════════════════════════════════════════════════════════ + // GET /final-balance — closing share capital per member, as on a date + // ══════════════════════════════════════════════════════════════════════ + /** + * Every member's shareholding and its value as on a date, which is what a balance sheet's Share + * Capital line has to reconcile to. + * + * @param asOnDate + * counts only movements up to and including this date; omit for the position today + * @param officeId + * a single branch, for a branch-scoped login + * @param productId + * a single share class, since a society can issue more than one + * @param includeZero + * keeps members whose holding has fallen to nil (fully redeemed, or closed) on the + * report; off by default, because a closing-balance report is normally read as the + * list of who currently holds capital + */ + @GET + @Path("/final-balance") + @Operation(summary = "Share capital balance per member as on a date", + description = "Shares held and their value per share account, derived from settled purchases less redemptions up to the given date.") + public Response finalBalance(@QueryParam("asOnDate") final String asOnDate, @QueryParam("officeId") final Long officeId, + @QueryParam("productId") final Long productId, @QueryParam("includeZero") final Boolean includeZero) { + this.context.authenticatedUser(); + + final List args = new ArrayList<>(); + final StringBuilder movement = new StringBuilder(""" + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_purchased, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_redeemed, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.amount, 0) ELSE 0 END) AS amount_subscribed, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.amount, 0) ELSE 0 END) AS amount_redeemed, + MIN(t.transaction_date) AS first_movement, + MAX(t.transaction_date) AS last_movement + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TYPE_REDEEMED, + TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + if (asOnDate != null && !asOnDate.isBlank()) { + movement.append(" AND t.transaction_date <= CAST(? AS date) "); + args.add(asOnDate); + } + movement.append(" GROUP BY t.account_id "); + + final StringBuilder sql = new StringBuilder(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.id AS "officeId", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + """ + HOLDING_STATE_SQL + """ + sp.unit_price AS "unitPrice", + COALESCE(bal.shares_held, 0) AS "sharesHeld", + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS "shareValue", + COALESCE(bal.shares_purchased, 0) AS "sharesPurchased", + COALESCE(bal.shares_redeemed, 0) AS "sharesRedeemed", + COALESCE(bal.amount_subscribed, 0) AS "amountSubscribed", + COALESCE(bal.amount_redeemed, 0) AS "amountRedeemed", + to_char(bal.first_movement, 'YYYY-MM-DD') AS "firstMovementDate", + to_char(bal.last_movement, 'YYYY-MM-DD') AS "lastMovementDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + """); + sql.append(movement).append(""" + ) bal ON bal.account_id = sa.id + WHERE 1 = 1 + """); + + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + if (!Boolean.TRUE.equals(includeZero)) { + sql.append(" AND COALESCE(bal.shares_held, 0) > 0 "); + } + sql.append(" ORDER BY o.name, c.display_name, sa.account_no "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + // Totalled here rather than on the client: the client only ever holds the page it is + // showing, and a Share Capital total that silently means "of the rows on screen" is exactly + // the number someone would carry into a balance sheet by mistake. + long totalShares = 0L; + java.math.BigDecimal totalValue = java.math.BigDecimal.ZERO; + java.math.BigDecimal totalSubscribed = java.math.BigDecimal.ZERO; + java.math.BigDecimal totalRedeemed = java.math.BigDecimal.ZERO; + for (final Map row : rows) { + totalShares += toLong(row.get("sharesHeld")); + totalValue = totalValue.add(toDecimal(row.get("shareValue"))); + totalSubscribed = totalSubscribed.add(toDecimal(row.get("amountSubscribed"))); + totalRedeemed = totalRedeemed.add(toDecimal(row.get("amountRedeemed"))); + } + + final Map out = new LinkedHashMap<>(); + // Allotted but never paid for. Excluded from the balance above, because share capital is + // what the society has received, not what it has promised to issue — but counted and + // reported here, since a total that silently drops accounts leaves a reader wondering which + // ones, and an unpaid allotment sitting for months is itself the thing worth chasing. + final Map unpaid = this.jdbcTemplate.queryForMap(""" + SELECT COUNT(*) AS "accountCount", + COALESCE(SUM(COALESCE(t.total_shares, 0)), 0) AS "shares", + COALESCE(SUM(COALESCE(t.amount, 0)), 0) AS "amount" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum = %d + AND t.funding_source IS NULL AND COALESCE(sa.collection_applied, false) = false + AND sa.status_enum <> 500 + """.formatted(TXN_APPROVED, TYPE_PURCHASED)); + + out.put("asOnDate", asOnDate); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("memberCount", rows.size()); + totals.put("sharesHeld", totalShares); + totals.put("shareValue", totalValue); + totals.put("amountSubscribed", totalSubscribed); + totals.put("amountRedeemed", totalRedeemed); + out.put("totals", totals); + out.put("stateCounts", holdingStateCounts(rows)); + out.put("unpaidAllotments", unpaid); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /account-statement/{shareAccountId} — one account, movement by movement + // ══════════════════════════════════════════════════════════════════════ + /** + * One share account's statement: every settled movement in date order with the holding it left + * behind, plus the header a printed statement needs. + * + *

+ * The running balance is computed over the whole life of the account and only then filtered to + * the requested period, so the first line of a mid-year statement opens at the holding the + * member actually had on that date rather than at zero. + */ + @GET + @Path("/account-statement/{shareAccountId}") + @Operation(summary = "Share account statement", + description = "Header plus every settled purchase and redemption in date order, with the running shareholding after each.") + public Response accountStatement(@PathParam("shareAccountId") final Long shareAccountId, + @QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) { + this.context.authenticatedUser(); + + final List> headers = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + sp.unit_price AS "unitPrice", + sa.resolution_number AS "resolutionNumber", + to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate", + sav.account_no AS "linkedSavingsAccountNo" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN m_savings_account sav ON sav.id = sa.savings_account_id + WHERE sa.id = ? + """, shareAccountId); + if (headers.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.account.not.found", + "Share account " + shareAccountId + " was not found."); + } + + final List> lines = this.jdbcTemplate.queryForList(""" + SELECT * FROM ( + SELECT t.id AS "transactionId", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "transactionDate", + t.type_enum AS "typeEnum", + t.status_enum AS "statusEnum", + CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END AS "sharesIn", + CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END AS "sharesOut", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.charge_amount AS "chargeAmount", + t.amount_paid AS "amountPaid", + t.resolution_number AS "resolutionNumber", + to_char(t.meeting_date, 'YYYY-MM-DD') AS "meetingDate", + t.funding_source AS "fundingSource", + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) + WHEN t.type_enum = %d THEN -COALESCE(t.total_shares, 0) + ELSE 0 END) + OVER (ORDER BY t.transaction_date, t.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS "balanceShares", + t.transaction_date AS raw_date + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.account_id = ? AND t.is_active = true AND t.status_enum = %d + AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + ) s + WHERE 1 = 1 + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, + TYPE_REDEEMED) + periodFilter(fromDate, toDate) + " ORDER BY raw_date, \"transactionId\" ", + periodArgs(shareAccountId, fromDate, toDate)); + + // The value a line leaves behind, at the unit price the account's product carries. Computed + // here so the statement and the balance report cannot disagree about what a holding is worth. + final java.math.BigDecimal unitPrice = toDecimal(headers.get(0).get("unitPrice")); + for (final Map line : lines) { + line.remove("raw_date"); + line.put("balanceValue", java.math.BigDecimal.valueOf(toLong(line.get("balanceShares"))).multiply(unitPrice)); + } + + final Map out = new LinkedHashMap<>(); + out.put("account", headers.get(0)); + out.put("fromDate", fromDate); + out.put("toDate", toDate); + out.put("lines", lines); + final Map totals = new LinkedHashMap<>(); + long in = 0L; + long outShares = 0L; + java.math.BigDecimal amountIn = java.math.BigDecimal.ZERO; + java.math.BigDecimal amountOut = java.math.BigDecimal.ZERO; + for (final Map line : lines) { + final long sharesIn = toLong(line.get("sharesIn")); + final long sharesOut = toLong(line.get("sharesOut")); + in += sharesIn; + outShares += sharesOut; + if (sharesIn > 0) { + amountIn = amountIn.add(toDecimal(line.get("amount"))); + } + if (sharesOut > 0) { + amountOut = amountOut.add(toDecimal(line.get("amount"))); + } + } + totals.put("sharesIn", in); + totals.put("sharesOut", outShares); + totals.put("amountIn", amountIn); + totals.put("amountOut", amountOut); + totals.put("closingShares", lines.isEmpty() ? 0L : toLong(lines.get(lines.size() - 1).get("balanceShares"))); + totals.put("closingValue", + java.math.BigDecimal.valueOf(lines.isEmpty() ? 0L : toLong(lines.get(lines.size() - 1).get("balanceShares"))) + .multiply(unitPrice)); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /share-issued — allotments made in a period + // ══════════════════════════════════════════════════════════════════════ + /** + * The allotment register: every tranche of shares issued in a period, with the resolution that + * authorised it and how the member paid for it. + * + *

+ * One row per tranche rather than per member, because a member who bought three times in the + * year was allotted three times and the register records allotments. + */ + @GET + @Path("/share-issued") + @Operation(summary = "Shares issued in a period", + description = "One row per allotment tranche: member, shares, value, board resolution and funding route.") + public Response shareIssued(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("officeId") final Long officeId, @QueryParam("productId") final Long productId, + @QueryParam("status") final Integer status) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT t.id AS "transactionId", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "issueDate", + sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.id AS "officeId", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + t.total_shares AS "shares", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.charge_amount AS "chargeAmount", + t.status_enum AS "statusEnum", + t.resolution_number AS "resolutionNumber", + to_char(t.meeting_date, 'YYYY-MM-DD') AS "meetingDate", + t.funding_source AS "fundingSource", + sa.status_enum AS "accountStatusEnum", + -- Allotted is not the same as paid for. An opening tranche is only paid-up + -- once the member's collection has been applied to the account; an + -- additional tranche was paid for at its own approval. An unpaid allotment + -- still belongs in the register -- it was allotted -- but it is not capital + -- the society has received, so it is flagged rather than quietly counted. + (t.funding_source IS NOT NULL OR sa.collection_applied = true) AS "paidUp" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + WHERE t.is_active = true AND t.type_enum = %d + """.formatted(TYPE_PURCHASED)); + + final List args = new ArrayList<>(); + if (fromDate != null && !fromDate.isBlank()) { + sql.append(" AND t.transaction_date >= CAST(? AS date) "); + args.add(fromDate); + } + if (toDate != null && !toDate.isBlank()) { + sql.append(" AND t.transaction_date <= CAST(? AS date) "); + args.add(toDate); + } + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + // Defaults to settled allotments only. A pending tranche is a request, not an issue, and + // counting it here would overstate issued capital -- but it is reachable on purpose, since + // "what is waiting to be allotted" is a question this register is also asked. + if (status != null) { + sql.append(" AND t.status_enum = ? "); + args.add(status); + } else { + sql.append(" AND t.status_enum = ").append(TXN_APPROVED).append(" "); + } + sql.append(" ORDER BY t.transaction_date DESC, t.id DESC "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long totalShares = 0L; + long unpaidShares = 0L; + java.math.BigDecimal totalAmount = java.math.BigDecimal.ZERO; + java.math.BigDecimal unpaidAmount = java.math.BigDecimal.ZERO; + java.math.BigDecimal totalCharges = java.math.BigDecimal.ZERO; + final java.util.Set members = new java.util.HashSet<>(); + for (final Map row : rows) { + totalShares += toLong(row.get("shares")); + totalCharges = totalCharges.add(toDecimal(row.get("chargeAmount"))); + members.add(row.get("clientId")); + // Capital raised counts only what was actually paid for. An allotment whose collection + // has never been applied is shares issued on paper against money the society has not + // received, and adding it here is how a Share Capital total drifts above what the + // ledger says. + if (Boolean.TRUE.equals(row.get("paidUp"))) { + totalAmount = totalAmount.add(toDecimal(row.get("amount"))); + } else { + unpaidShares += toLong(row.get("shares")); + unpaidAmount = unpaidAmount.add(toDecimal(row.get("amount"))); + } + } + + final Map out = new LinkedHashMap<>(); + out.put("fromDate", fromDate); + out.put("toDate", toDate); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("trancheCount", rows.size()); + totals.put("memberCount", members.size()); + totals.put("unpaidShares", unpaidShares); + totals.put("unpaidAmount", unpaidAmount); + totals.put("shares", totalShares); + totals.put("amount", totalAmount); + totals.put("charges", totalCharges); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /status-summary — where every share account stands + // ══════════════════════════════════════════════════════════════════════ + /** + * Share accounts counted by status and share class, with the accounts themselves alongside, so + * the summary can be read and then drilled into without a second screen. + */ + @GET + @Path("/status-summary") + @Operation(summary = "Share accounts by status", + description = "Account and member counts, shares and value grouped by account status and share product, plus the underlying accounts.") + public Response statusSummary(@QueryParam("officeId") final Long officeId, @QueryParam("productId") final Long productId) { + this.context.authenticatedUser(); + + final StringBuilder where = new StringBuilder(" WHERE 1 = 1 "); + final List args = new ArrayList<>(); + if (officeId != null) { + where.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + where.append(" AND sp.id = ? "); + args.add(productId); + } + + final List> groups = this.jdbcTemplate.queryForList(""" + SELECT sa.status_enum AS "statusEnum", + sp.id AS "productId", + sp.name AS "productName", + COUNT(*) AS "accountCount", + COUNT(DISTINCT sa.client_id) AS "memberCount", + SUM(COALESCE(sa.total_approved_shares, 0)) AS "approvedShares", + SUM(COALESCE(sa.total_pending_shares, 0)) AS "pendingShares", + SUM(COALESCE(sa.total_approved_shares, 0) * COALESCE(sp.unit_price, 0)) AS "shareValue" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + """ + where + """ + GROUP BY sa.status_enum, sp.id, sp.name + ORDER BY sa.status_enum, sp.name + """, args.toArray()); + + final List> accounts = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + COALESCE(sa.total_approved_shares, 0) AS "approvedShares", + COALESCE(sa.total_pending_shares, 0) AS "pendingShares", + COALESCE(sa.total_approved_shares, 0) * COALESCE(sp.unit_price, 0) AS "shareValue", + to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate", + to_char(sa.approved_date, 'YYYY-MM-DD') AS "approvedDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.rejected_date, 'YYYY-MM-DD') AS "rejectedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + """ + where + """ + ORDER BY sa.status_enum, c.display_name, sa.account_no + """, args.toArray()); + + final Map out = new LinkedHashMap<>(); + out.put("groups", groups); + out.put("accounts", accounts); + final Map totals = new LinkedHashMap<>(); + long shares = 0L; + java.math.BigDecimal value = java.math.BigDecimal.ZERO; + final java.util.Set members = new java.util.HashSet<>(); + for (final Map row : accounts) { + shares += toLong(row.get("approvedShares")); + value = value.add(toDecimal(row.get("shareValue"))); + members.add(row.get("clientId")); + } + totals.put("accountCount", accounts.size()); + totals.put("memberCount", members.size()); + totals.put("approvedShares", shares); + totals.put("shareValue", value); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /nominal-shares — holdings against the share class's own rules + // ══════════════════════════════════════════════════════════════════════ + /** + * Nominal Share Report: every member's holding measured against the three numbers the share + * product itself sets — nominal, minimum and maximum shares per client — and valued at face. + * + *

+ * All three readings of "nominal" a co-operative uses are answered together, because they are + * the same figure looked at from different sides and splitting them across screens would mean + * reading two to answer one question: + * + *

    + *
  • Nominal (face) value — shares held at the product's unit price, which is what the + * Share Capital line is stated at.
  • + *
  • Against the product's nominal holding — the shares the class expects a member to + * take, and the shortfall or excess against it.
  • + *
  • Nominal vs regular membership — a member holding less than the nominal subscription + * has not taken up full membership, which is what "nominal member" means in a co-operative and + * what decides whether they vote.
  • + *
+ * + *

+ * Compliance is the point of the report, so a holding outside the class's own minimum or maximum + * is flagged rather than left for the reader to spot by comparing two columns. + */ + @GET + @Path("/nominal-shares") + @Operation(summary = "Nominal share holdings against product rules", + description = "Shares held per member at face value, measured against the share class's nominal, minimum and maximum holding, with membership class and any breach flagged.") + public Response nominalShares(@QueryParam("asOnDate") final String asOnDate, @QueryParam("officeId") final Long officeId, + @QueryParam("productId") final Long productId) { + this.context.authenticatedUser(); + + final List args = new ArrayList<>(); + final StringBuilder movement = new StringBuilder(""" + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + if (asOnDate != null && !asOnDate.isBlank()) { + movement.append(" AND t.transaction_date <= CAST(? AS date) "); + args.add(asOnDate); + } + movement.append(" GROUP BY t.account_id "); + + final StringBuilder sql = new StringBuilder(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + """ + HOLDING_STATE_SQL + """ + sp.unit_price AS "faceValue", + sp.nominal_client_shares AS "nominalShares", + sp.minimum_client_shares AS "minimumShares", + sp.maximum_client_shares AS "maximumShares", + COALESCE(bal.shares_held, 0) AS "sharesHeld", + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS "nominalValue", + COALESCE(bal.shares_held, 0) - COALESCE(sp.nominal_client_shares, 0) AS "againstNominal", + -- Compliance and membership class are judgements about a live member, so an + -- account that is closed, rejected or not yet active is excused from both + -- rather than reported as breaching a minimum it is no longer subject to. + CASE + WHEN sa.status_enum <> 300 THEN 'NOT_APPLICABLE' + WHEN COALESCE(bal.shares_held, 0) = 0 THEN 'NO_HOLDING' + WHEN sp.minimum_client_shares IS NOT NULL + AND COALESCE(bal.shares_held, 0) < sp.minimum_client_shares THEN 'BELOW_MINIMUM' + WHEN sp.maximum_client_shares IS NOT NULL + AND COALESCE(bal.shares_held, 0) > sp.maximum_client_shares THEN 'ABOVE_MAXIMUM' + ELSE 'WITHIN_LIMITS' + END AS "complianceStatus", + CASE + WHEN sa.status_enum <> 300 THEN 'NOT_APPLICABLE' + WHEN COALESCE(bal.shares_held, 0) = 0 THEN 'NONE' + WHEN sp.nominal_client_shares IS NOT NULL + AND COALESCE(bal.shares_held, 0) < sp.nominal_client_shares THEN 'NOMINAL' + ELSE 'REGULAR' + END AS "membershipClass", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + """); + sql.append(movement).append(""" + ) bal ON bal.account_id = sa.id + WHERE 1 = 1 + """); + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + sql.append(" ORDER BY c.display_name, sa.account_no "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long held = 0L; + java.math.BigDecimal nominalValue = java.math.BigDecimal.ZERO; + int belowMinimum = 0; + int aboveMaximum = 0; + int nominalMembers = 0; + int regularMembers = 0; + for (final Map row : rows) { + held += toLong(row.get("sharesHeld")); + nominalValue = nominalValue.add(toDecimal(row.get("nominalValue"))); + final Object compliance = row.get("complianceStatus"); + if ("BELOW_MINIMUM".equals(compliance)) { + belowMinimum++; + } else if ("ABOVE_MAXIMUM".equals(compliance)) { + aboveMaximum++; + } + final Object membership = row.get("membershipClass"); + if ("NOMINAL".equals(membership)) { + nominalMembers++; + } else if ("REGULAR".equals(membership)) { + regularMembers++; + } + } + + final Map out = new LinkedHashMap<>(); + out.put("asOnDate", asOnDate); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("accountCount", rows.size()); + totals.put("sharesHeld", held); + totals.put("nominalValue", nominalValue); + totals.put("belowMinimum", belowMinimum); + totals.put("aboveMaximum", aboveMaximum); + totals.put("nominalMembers", nominalMembers); + totals.put("regularMembers", regularMembers); + out.put("totals", totals); + out.put("stateCounts", holdingStateCounts(rows)); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /share-wise — how the shareholding is spread + // ══════════════════════════════════════════════════════════════════════ + /** + * Share Wise Report: the shareholding pattern — how many members sit in each holding band, and + * how the capital splits by share class. + * + *

+ * The bands are what a board or the Registrar reads to see concentration: a society where three + * members hold half the capital reads very differently from one where five hundred hold it + * evenly, and neither is visible from a members-and-total figure alone. + * + *

+ * Bands are fixed rather than configurable on purpose — a distribution that moves its own + * boundaries between runs cannot be compared with last year's, which is most of what it is for. + */ + @GET + @Path("/share-wise") + @Operation(summary = "Shareholding pattern by band and share class", + description = "Members and capital grouped into holding bands, plus the per-product split, with each band's share of total capital.") + public Response shareWise(@QueryParam("asOnDate") final String asOnDate, @QueryParam("officeId") final Long officeId, + @QueryParam("productId") final Long productId) { + this.context.authenticatedUser(); + + final List args = new ArrayList<>(); + final StringBuilder movement = new StringBuilder(""" + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + if (asOnDate != null && !asOnDate.isBlank()) { + movement.append(" AND t.transaction_date <= CAST(? AS date) "); + args.add(asOnDate); + } + movement.append(" GROUP BY t.account_id "); + + final StringBuilder holdings = new StringBuilder(""" + SELECT sa.id AS account_id, + sa.client_id, + sp.id AS product_id, + sp.name AS product_name, + COALESCE(bal.shares_held, 0) AS shares_held, + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS share_value + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + """); + holdings.append(movement).append(""" + ) bal ON bal.account_id = sa.id + WHERE COALESCE(bal.shares_held, 0) > 0 + """); + if (officeId != null) { + holdings.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + holdings.append(" AND sp.id = ? "); + args.add(productId); + } + + // Bands are assigned in SQL and counted in SQL, so an empty band still has to be added back + // in Java below: a distribution that silently drops "0 members hold 501-1000" reads as if + // the band did not exist rather than as if nobody is in it. + final List> bands = this.jdbcTemplate.queryForList(""" + WITH held AS ( + """ + holdings + """ + ) + SELECT CASE + WHEN shares_held <= 50 THEN '1 - 50' + WHEN shares_held <= 100 THEN '51 - 100' + WHEN shares_held <= 500 THEN '101 - 500' + WHEN shares_held <= 1000 THEN '501 - 1000' + ELSE '1001 and above' + END AS "band", + CASE + WHEN shares_held <= 50 THEN 1 + WHEN shares_held <= 100 THEN 2 + WHEN shares_held <= 500 THEN 3 + WHEN shares_held <= 1000 THEN 4 + ELSE 5 + END AS "bandOrder", + COUNT(*) AS "accountCount", + COUNT(DISTINCT client_id) AS "memberCount", + SUM(shares_held) AS "shares", + SUM(share_value) AS "shareValue" + FROM held + GROUP BY 1, 2 + ORDER BY 2 + """, args.toArray()); + + final List> byProduct = this.jdbcTemplate.queryForList(""" + WITH held AS ( + """ + holdings + """ + ) + SELECT product_id AS "productId", + product_name AS "productName", + COUNT(*) AS "accountCount", + COUNT(DISTINCT client_id) AS "memberCount", + SUM(shares_held) AS "shares", + SUM(share_value) AS "shareValue" + FROM held + GROUP BY product_id, product_name + ORDER BY product_name + """, args.toArray()); + + long totalShares = 0L; + java.math.BigDecimal totalValue = java.math.BigDecimal.ZERO; + for (final Map row : byProduct) { + totalShares += toLong(row.get("shares")); + totalValue = totalValue.add(toDecimal(row.get("shareValue"))); + } + + // Every band, present or not, in order — and each one's share of capital, computed here + // because a percentage of a total the client only partly holds is a percentage of nothing. + final String[] bandNames = { "1 - 50", "51 - 100", "101 - 500", "501 - 1000", "1001 and above" }; + final List> fullBands = new ArrayList<>(); + for (int i = 0; i < bandNames.length; i++) { + final String name = bandNames[i]; + Map found = null; + for (final Map band : bands) { + if (name.equals(band.get("band"))) { + found = band; + break; + } + } + final Map band = new LinkedHashMap<>(); + band.put("band", name); + band.put("bandOrder", i + 1); + band.put("accountCount", found == null ? 0 : found.get("accountCount")); + band.put("memberCount", found == null ? 0 : found.get("memberCount")); + band.put("shares", found == null ? 0 : found.get("shares")); + band.put("shareValue", found == null ? java.math.BigDecimal.ZERO : found.get("shareValue")); + final java.math.BigDecimal bandValue = found == null ? java.math.BigDecimal.ZERO : toDecimal(found.get("shareValue")); + band.put("capitalShare", totalValue.signum() == 0 ? java.math.BigDecimal.ZERO + : bandValue.multiply(java.math.BigDecimal.valueOf(100)).divide(totalValue, 2, java.math.RoundingMode.HALF_UP)); + fullBands.add(band); + } + + final Map out = new LinkedHashMap<>(); + out.put("asOnDate", asOnDate); + out.put("bands", fullBands); + out.put("byProduct", byProduct); + final Map totals = new LinkedHashMap<>(); + totals.put("shares", totalShares); + totals.put("shareValue", totalValue); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /guarantor-detail — who stands surety, and what they hold + // ══════════════════════════════════════════════════════════════════════ + /** + * Guarantor Detail: every guarantor standing on a live loan, with the shareholding they hold in + * the society. + * + *

+ * The two belong on one line because a co-operative accepts a member as surety on the strength + * of their stake in it: a guarantor whose own shareholding has since been redeemed is exactly + * the exposure this report exists to surface, and neither the loan screen nor the share screen + * shows it alone. + * + *

+ * External guarantors (a non-member standing surety) are included and marked as such, with no + * shareholding to show - they are part of the exposure whether or not they hold shares. + */ + @GET + @Path("/guarantor-detail") + @Operation(summary = "Loan guarantors and their shareholding", + description = "Every active guarantor with the loan they stand on and the shares they hold, member and external alike.") + public Response guarantorDetail(@QueryParam("officeId") final Long officeId, @QueryParam("onlyMembers") final Boolean onlyMembers) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT g.id AS "guarantorId", + g.loan_id AS "loanId", + l.account_no AS "loanAccountNo", + l.loan_status_id AS "loanStatusId", + l.principal_amount AS "loanPrincipal", + COALESCE(l.principal_outstanding_derived, 0) AS "principalOutstanding", + COALESCE(l.total_outstanding_derived, 0) AS "totalOutstanding", + borrower.display_name AS "borrowerName", + borrower.account_no AS "borrowerCif", + g.type_enum AS "guarantorTypeEnum", + CASE WHEN g.type_enum = 1 THEN 'MEMBER' ELSE 'EXTERNAL' END AS "guarantorType", + COALESCE(gc.display_name, TRIM(CONCAT(g.firstname, ' ', g.lastname))) AS "guarantorName", + gc.account_no AS "guarantorCif", + gc.id AS "guarantorClientId", + go.name AS "guarantorOfficeName", + g.mobile_number AS "guarantorMobile", + sh.share_account_id AS "shareAccountId", + sh.account_no AS "shareAccountNo", + sh.product_name AS "shareProductName", + COALESCE(sh.shares_held, 0) AS "sharesHeld", + COALESCE(sh.share_value, 0) AS "shareValue" + FROM m_guarantor g + JOIN m_loan l ON l.id = g.loan_id + JOIN m_client borrower ON borrower.id = l.client_id + LEFT JOIN m_client gc ON gc.id = g.entity_id AND g.type_enum = 1 + LEFT JOIN m_office go ON go.id = gc.office_id + LEFT JOIN ( + SELECT sa.client_id, + MIN(sa.id) AS share_account_id, + MIN(sa.account_no) AS account_no, + MIN(sp.name) AS product_name, + SUM(COALESCE(bal.shares_held, 0)) AS shares_held, + SUM(COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0)) AS share_value + FROM m_share_account sa + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + GROUP BY sa.client_id + ) sh ON sh.client_id = gc.id + WHERE g.is_active = true + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + + final List args = new ArrayList<>(); + if (officeId != null) { + sql.append(" AND go.id = ? "); + args.add(officeId); + } + if (Boolean.TRUE.equals(onlyMembers)) { + sql.append(" AND g.type_enum = 1 "); + } + sql.append(" ORDER BY borrower.display_name, l.account_no, g.id "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + int memberGuarantors = 0; + int externalGuarantors = 0; + int withoutShares = 0; + java.math.BigDecimal guaranteedOutstanding = java.math.BigDecimal.ZERO; + java.math.BigDecimal guarantorShareValue = java.math.BigDecimal.ZERO; + for (final Map row : rows) { + if ("MEMBER".equals(row.get("guarantorType"))) { + memberGuarantors++; + if (toLong(row.get("sharesHeld")) == 0L) { + withoutShares++; + } + } else { + externalGuarantors++; + } + guaranteedOutstanding = guaranteedOutstanding.add(toDecimal(row.get("totalOutstanding"))); + guarantorShareValue = guarantorShareValue.add(toDecimal(row.get("shareValue"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("guarantorCount", rows.size()); + totals.put("memberGuarantors", memberGuarantors); + totals.put("externalGuarantors", externalGuarantors); + totals.put("membersWithoutShares", withoutShares); + totals.put("guaranteedOutstanding", guaranteedOutstanding); + totals.put("guarantorShareValue", guarantorShareValue); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /member-loan-interest — interest earned from the members who hold shares + // ══════════════════════════════════════════════════════════════════════ + /** + * Total Interest Received and Receivable, for the members who hold share capital. + * + *

+ * Shares themselves earn dividend, not interest - this report is the other direction: what the + * society has earned in interest from the borrowings of the members who fund it. A co-operative + * reads the two together, because a member's stake and what their lending returns is the case + * for admitting and keeping them. + * + *

+ * Received and receivable are kept as separate columns rather than summed: interest charged but + * not yet collected is not income in hand, and a single "interest" figure hides exactly that. + */ + @GET + @Path("/member-loan-interest") + @Operation(summary = "Interest received and receivable from shareholding members", + description = "Per member: shares held, and across their loans the interest charged, received, outstanding, waived and written off.") + public Response memberLoanInterest(@QueryParam("officeId") final Long officeId, + @QueryParam("includeClosedLoans") final Boolean includeClosedLoans) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT c.id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.name AS "officeName", + COALESCE(sh.shares_held, 0) AS "sharesHeld", + COALESCE(sh.share_value, 0) AS "shareValue", + COUNT(l.id) AS "loanCount", + COALESCE(SUM(l.principal_amount), 0) AS "principalLent", + COALESCE(SUM(l.principal_outstanding_derived), 0) AS "principalOutstanding", + COALESCE(SUM(l.interest_charged_derived), 0) AS "interestCharged", + COALESCE(SUM(l.interest_repaid_derived), 0) AS "interestReceived", + COALESCE(SUM(l.interest_outstanding_derived), 0) AS "interestReceivable", + COALESCE(SUM(l.interest_waived_derived), 0) AS "interestWaived", + COALESCE(SUM(l.interest_writtenoff_derived), 0) AS "interestWrittenOff" + FROM m_client c + JOIN ( + SELECT sa.client_id, + SUM(COALESCE(bal.shares_held, 0)) AS shares_held, + SUM(COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0)) AS share_value + FROM m_share_account sa + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + GROUP BY sa.client_id + ) sh ON sh.client_id = c.id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_loan l ON l.client_id = c.id + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + + // Live loans only by default (300 = active, 700 = overpaid). A closed loan's interest is + // history rather than a position, and mixing the two makes "receivable" unreadable. + if (!Boolean.TRUE.equals(includeClosedLoans)) { + sql.append(" AND l.loan_status_id IN (300, 700) "); + } + sql.append(" WHERE 1 = 1 "); + + final List args = new ArrayList<>(); + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + sql.append(""" + GROUP BY c.id, c.account_no, c.display_name, o.name, sh.shares_held, sh.share_value + ORDER BY c.display_name + """); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + java.math.BigDecimal charged = java.math.BigDecimal.ZERO; + java.math.BigDecimal received = java.math.BigDecimal.ZERO; + java.math.BigDecimal receivable = java.math.BigDecimal.ZERO; + java.math.BigDecimal waived = java.math.BigDecimal.ZERO; + java.math.BigDecimal writtenOff = java.math.BigDecimal.ZERO; + java.math.BigDecimal shareValue = java.math.BigDecimal.ZERO; + long borrowers = 0L; + for (final Map row : rows) { + charged = charged.add(toDecimal(row.get("interestCharged"))); + received = received.add(toDecimal(row.get("interestReceived"))); + receivable = receivable.add(toDecimal(row.get("interestReceivable"))); + waived = waived.add(toDecimal(row.get("interestWaived"))); + writtenOff = writtenOff.add(toDecimal(row.get("interestWrittenOff"))); + shareValue = shareValue.add(toDecimal(row.get("shareValue"))); + if (toLong(row.get("loanCount")) > 0L) { + borrowers++; + } + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("memberCount", rows.size()); + totals.put("borrowingMembers", borrowers); + totals.put("interestCharged", charged); + totals.put("interestReceived", received); + totals.put("interestReceivable", receivable); + totals.put("interestWaived", waived); + totals.put("interestWrittenOff", writtenOff); + totals.put("shareValue", shareValue); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /enquiry — one member's whole share position, on one screen + // ══════════════════════════════════════════════════════════════════════ + /** + * Share Enquiry: everything about one member's share position in a single answer - their + * accounts, every movement, the dividends declared to them, and the savings account their money + * moves through. + * + *

+ * Assembled server-side rather than by the client calling four endpoints, so what the counter + * reads out to a member standing in front of them is one consistent picture taken at one moment, + * not four responses that can disagree if a posting lands between them. + */ + @GET + @Path("/enquiry") + @Operation(summary = "One member's complete share position", + description = "Share accounts, every settled movement, dividends and linked savings for a single member, found by name, membership number or share account number.") + public Response enquiry(@QueryParam("clientId") final Long clientId, @QueryParam("search") final String search) { + this.context.authenticatedUser(); + + Long resolvedClientId = clientId; + if (resolvedClientId == null) { + if (search == null || search.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.share.enquiry.search.required", + "Search for a member by name, membership number or share account number."); + } + final String like = "%" + search.trim().toLowerCase() + "%"; + final List> matches = this.jdbcTemplate.queryForList(""" + SELECT DISTINCT c.id AS "clientId", c.display_name AS "clientName", c.account_no AS "cifNumber" + FROM m_client c + JOIN m_share_account sa ON sa.client_id = c.id + WHERE LOWER(c.display_name) LIKE ? OR LOWER(c.account_no) LIKE ? OR LOWER(sa.account_no) LIKE ? + ORDER BY c.display_name + LIMIT 25 + """, like, like, like); + if (matches.isEmpty()) { + final Map empty = new LinkedHashMap<>(); + empty.put("matches", matches); + empty.put("member", null); + return Response.ok(gson.toJson(empty)).build(); + } + // More than one member answers the search — the caller picks, rather than this guessing + // and showing somebody else's shareholding to whoever is at the counter. + if (matches.size() > 1) { + final Map choose = new LinkedHashMap<>(); + choose.put("matches", matches); + choose.put("member", null); + return Response.ok(gson.toJson(choose)).build(); + } + resolvedClientId = toLong(matches.get(0).get("clientId")); + } + + final List> members = this.jdbcTemplate.queryForList(""" + SELECT c.id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + c.mobile_no AS "mobileNo", + o.name AS "officeName", + to_char(c.activation_date, 'YYYY-MM-DD') AS "memberSince" + FROM m_client c + LEFT JOIN m_office o ON o.id = c.office_id + WHERE c.id = ? + """, resolvedClientId); + if (members.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.enquiry.member.not.found", + "Member " + resolvedClientId + " was not found."); + } + + final List> accounts = this.jdbcTemplate.queryForList((""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + """ + HOLDING_STATE_SQL + """ + sp.unit_price AS "unitPrice", + COALESCE(bal.shares_held, 0) AS "sharesHeld", + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS "shareValue", + COALESCE(sa.total_pending_shares, 0) AS "pendingShares", + sa.resolution_number AS "resolutionNumber", + to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate", + sav.account_no AS "linkedSavingsAccountNo", + sav.account_balance_derived AS "linkedSavingsBalance" + FROM m_share_account sa + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN m_savings_account sav ON sav.id = sa.savings_account_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + WHERE sa.client_id = ? + ORDER BY sa.account_no + """).formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED), resolvedClientId); + + final List> movements = this.jdbcTemplate.queryForList(""" + SELECT t.id AS "transactionId", + sa.account_no AS "accountNo", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "transactionDate", + t.type_enum AS "typeEnum", + t.status_enum AS "statusEnum", + t.total_shares AS "shares", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.charge_amount AS "chargeAmount", + t.resolution_number AS "resolutionNumber", + t.funding_source AS "fundingSource", + (t.funding_source IS NOT NULL OR sa.collection_applied = true) AS "paidUp" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + WHERE sa.client_id = ? AND t.is_active = true AND t.type_enum IN (%d, %d) + ORDER BY t.transaction_date DESC, t.id DESC + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED), resolvedClientId); + + final List> dividends = this.jdbcTemplate.queryForList(""" + SELECT dd.id AS "dividendDetailId", + sa.account_no AS "accountNo", + sp.name AS "productName", + po.id AS "dividendId", + to_char(po.dividend_period_start_date, 'YYYY-MM-DD') AS "periodStart", + to_char(po.dividend_period_end_date, 'YYYY-MM-DD') AS "periodEnd", + po.rate_percentage AS "ratePercentage", + dd.amount AS "amount", + dd.status AS "statusEnum", + dd.savings_transaction_id AS "savingsTransactionId" + FROM m_share_account_dividend_details dd + JOIN m_share_account sa ON sa.id = dd.account_id + JOIN m_share_product_dividend_pay_out po ON po.id = dd.dividend_pay_out_id + LEFT JOIN m_share_product sp ON sp.id = po.product_id + WHERE sa.client_id = ? + ORDER BY po.dividend_period_end_date DESC, dd.id DESC + """, resolvedClientId); + + long sharesHeld = 0L; + java.math.BigDecimal shareValue = java.math.BigDecimal.ZERO; + for (final Map account : accounts) { + sharesHeld += toLong(account.get("sharesHeld")); + shareValue = shareValue.add(toDecimal(account.get("shareValue"))); + } + java.math.BigDecimal dividendTotal = java.math.BigDecimal.ZERO; + for (final Map dividend : dividends) { + dividendTotal = dividendTotal.add(toDecimal(dividend.get("amount"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("matches", List.of()); + out.put("member", members.get(0)); + out.put("accounts", accounts); + out.put("movements", movements); + out.put("dividends", dividends); + final Map totals = new LinkedHashMap<>(); + totals.put("accountCount", accounts.size()); + totals.put("sharesHeld", sharesHeld); + totals.put("shareValue", shareValue); + totals.put("movementCount", movements.size()); + totals.put("dividendTotal", dividendTotal); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /payment-certificate/{shareAccountId} — proof the shares were paid for + // ══════════════════════════════════════════════════════════════════════ + /** + * Share Payment Certificate: what a member actually paid for their shares, and against which + * receipt. + * + *

+ * Distinct from a share certificate, which certifies ownership. This certifies payment — the + * document a member is asked for when the money, not the holding, is what has to be proved, and + * it therefore leads with amounts, dates and the voucher each one was received on. + * + *

+ * Every tranche is listed rather than a single total: a member who paid in instalments paid on + * several dates against several receipts, and a certificate showing one figure cannot be + * reconciled against any of them. + */ + @GET + @Path("/payment-certificate/{shareAccountId}") + @Operation(summary = "Share payment certificate", + description = "Account and member header, every payment made for shares with its receipt, and the entry fee, for printing as a certificate.") + public Response paymentCertificate(@PathParam("shareAccountId") final Long shareAccountId) { + this.context.authenticatedUser(); + + final List> headers = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + c.mobile_no AS "mobileNo", + o.name AS "officeName", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + sp.unit_price AS "unitPrice", + sa.resolution_number AS "resolutionNumber", + to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + COALESCE(sa.total_approved_shares, 0) AS "sharesHeld" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + """, shareAccountId); + if (headers.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.account.not.found", + "Share account " + shareAccountId + " was not found."); + } + + // Each purchase with the receipt the money arrived on. A cash tranche names its advance + // collection; a savings-funded one names no voucher because none was raised — the money came + // off the member's own account, which the funding column says instead of leaving a blank + // that reads like a missing record. + final List> payments = this.jdbcTemplate.queryForList(""" + SELECT t.id AS "transactionId", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "paymentDate", + t.total_shares AS "shares", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.charge_amount AS "chargeAmount", + t.funding_source AS "fundingSource", + t.resolution_number AS "resolutionNumber", + t.status_enum AS "statusEnum", + col.receipt_no AS "receiptNo", + col.voucher_no AS "voucherNo", + to_char(col.collection_date, 'YYYY-MM-DD') AS "receiptDate", + col.collected_by_name AS "collectedByName", + (t.funding_source IS NOT NULL OR sa.collection_applied = true) AS "paidUp" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + LEFT JOIN m_share_advance_collection col ON col.id = t.advance_collection_id + WHERE t.account_id = ? AND t.is_active = true AND t.type_enum = %d AND t.status_enum = %d + ORDER BY t.transaction_date, t.id + """.formatted(TYPE_PURCHASED, TXN_APPROVED), shareAccountId); + + final List> charges = this.jdbcTemplate.queryForList(""" + SELECT ch.name AS "chargeName", + sac.amount AS "amount", + COALESCE(sac.amount_paid_derived, 0) AS "amountPaid", + COALESCE(sac.amount_outstanding_derived, 0) AS "amountOutstanding", + sac.is_paid_derived AS "paid" + FROM m_share_account_charge sac + JOIN m_charge ch ON ch.id = sac.charge_id + WHERE sac.account_id = ? AND sac.is_active = true + ORDER BY ch.name + """, shareAccountId); + + long shares = 0L; + java.math.BigDecimal paidForShares = java.math.BigDecimal.ZERO; + java.math.BigDecimal paidCharges = java.math.BigDecimal.ZERO; + for (final Map payment : payments) { + shares += toLong(payment.get("shares")); + paidForShares = paidForShares.add(toDecimal(payment.get("amount"))); + paidCharges = paidCharges.add(toDecimal(payment.get("chargeAmount"))); + } + for (final Map charge : charges) { + paidCharges = paidCharges.add(toDecimal(charge.get("amountPaid"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("account", headers.get(0)); + out.put("payments", payments); + out.put("charges", charges); + final Map totals = new LinkedHashMap<>(); + totals.put("sharesPaidFor", shares); + totals.put("paidForShares", paidForShares); + totals.put("paidCharges", paidCharges); + totals.put("totalPaid", paidForShares.add(paidCharges)); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /share-detail — the full register, every column + // ══════════════════════════════════════════════════════════════════════ + /** + * Share Detail: the complete share register — every account with its member, its class and the + * terms it was opened under, in one table. + * + *

+ * Where the Share Holder list answers "who holds shares", this answers "what exactly does this + * account say" — lock-in, minimum active period, linked savings, charges, tranche count and the + * governing resolution, which are otherwise spread across the account's own tabs one at a time. + */ + @GET + @Path("/share-detail") + @Operation(summary = "Full share account register", + description = "Every share account with member, class, terms, lock-in, linked savings, charges and tranche count.") + public Response shareDetail(@QueryParam("officeId") final Long officeId, @QueryParam("productId") final Long productId, + @QueryParam("status") final Integer status) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder((""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.external_id AS "externalId", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + c.mobile_no AS "mobileNo", + o.id AS "officeId", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + sp.short_name AS "productShortName", + sa.currency_code AS "currencyCode", + sa.status_enum AS "statusEnum", + """ + HOLDING_STATE_SQL + """ + sp.unit_price AS "unitPrice", + COALESCE(bal.shares_held, 0) AS "sharesHeld", + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS "shareValue", + COALESCE(sa.total_pending_shares, 0) AS "pendingShares", + COALESCE(bal.tranche_count, 0) AS "trancheCount", + sa.resolution_number AS "resolutionNumber", + to_char(sa.resolution_date, 'YYYY-MM-DD') AS "resolutionDate", + sa.lockin_period_frequency AS "lockinPeriod", + sa.lockin_period_frequency_enum AS "lockinPeriodEnum", + sa.minimum_active_period_frequency AS "minimumActivePeriod", + sa.minimum_active_period_frequency_enum AS "minimumActivePeriodEnum", + sa.allow_dividends_inactive_clients AS "allowDividendsInactive", + sav.account_no AS "linkedSavingsAccountNo", + COALESCE(chg.charge_total, 0) AS "chargeTotal", + COALESCE(chg.charge_outstanding, 0) AS "chargeOutstanding", + to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate", + to_char(sa.approved_date, 'YYYY-MM-DD') AS "approvedDate", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN m_savings_account sav ON sav.id = sa.savings_account_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held, + COUNT(*) FILTER (WHERE t.type_enum = %d) AS tranche_count + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + LEFT JOIN ( + SELECT sac.account_id, + SUM(COALESCE(sac.amount, 0)) AS charge_total, + SUM(COALESCE(sac.amount_outstanding_derived, 0)) AS charge_outstanding + FROM m_share_account_charge sac + WHERE sac.is_active = true + GROUP BY sac.account_id + ) chg ON chg.account_id = sa.id + WHERE 1 = 1 + """).formatted(TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + + final List args = new ArrayList<>(); + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + if (status != null) { + sql.append(" AND sa.status_enum = ? "); + args.add(status); + } + sql.append(" ORDER BY c.display_name, sa.account_no "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long shares = 0L; + java.math.BigDecimal value = java.math.BigDecimal.ZERO; + java.math.BigDecimal chargesOutstanding = java.math.BigDecimal.ZERO; + for (final Map row : rows) { + shares += toLong(row.get("sharesHeld")); + value = value.add(toDecimal(row.get("shareValue"))); + chargesOutstanding = chargesOutstanding.add(toDecimal(row.get("chargeOutstanding"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("accountCount", rows.size()); + totals.put("sharesHeld", shares); + totals.put("shareValue", value); + totals.put("chargeOutstanding", chargesOutstanding); + out.put("totals", totals); + out.put("stateCounts", holdingStateCounts(rows)); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /dividend-statement — what a member has been declared, year by year + // ══════════════════════════════════════════════════════════════════════ + /** + * Dividend Statement: the dividends declared to one member across every year, with what has been + * paid and what is still to come. + * + *

+ * Read to a member at the counter and handed over as their record, so it is scoped to one member + * and always shows the period, the rate and the status side by side: "declared" and "paid" are + * different answers to "have I had my dividend", and a statement that shows only an amount + * cannot distinguish them. + */ + @GET + @Path("/dividend-statement") + @Operation(summary = "One member's dividend statement", + description = "Every dividend declared to a member, by period and share class, with rate, amount and payment status.") + public Response dividendStatement(@QueryParam("clientId") final Long clientId, @QueryParam("search") final String search) { + this.context.authenticatedUser(); + + Long resolvedClientId = clientId; + if (resolvedClientId == null) { + if (search == null || search.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.share.dividend.statement.search.required", + "Search for a member by name, membership number or share account number."); + } + final String like = "%" + search.trim().toLowerCase() + "%"; + final List> matches = this.jdbcTemplate.queryForList(""" + SELECT DISTINCT c.id AS "clientId", c.display_name AS "clientName", c.account_no AS "cifNumber" + FROM m_client c + JOIN m_share_account sa ON sa.client_id = c.id + WHERE LOWER(c.display_name) LIKE ? OR LOWER(c.account_no) LIKE ? OR LOWER(sa.account_no) LIKE ? + ORDER BY c.display_name + LIMIT 25 + """, like, like, like); + if (matches.size() != 1) { + final Map choose = new LinkedHashMap<>(); + choose.put("matches", matches); + choose.put("member", null); + return Response.ok(gson.toJson(choose)).build(); + } + resolvedClientId = toLong(matches.get(0).get("clientId")); + } + + final List> members = this.jdbcTemplate.queryForList(""" + SELECT c.id AS "clientId", c.account_no AS "cifNumber", c.display_name AS "clientName", + o.name AS "officeName" + FROM m_client c LEFT JOIN m_office o ON o.id = c.office_id WHERE c.id = ? + """, resolvedClientId); + if (members.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.dividend.statement.member.not.found", + "Member " + resolvedClientId + " was not found."); + } + + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT dd.id AS "dividendDetailId", + po.id AS "dividendId", + sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sp.name AS "productName", + to_char(po.dividend_period_start_date, 'YYYY-MM-DD') AS "periodStart", + to_char(po.dividend_period_end_date, 'YYYY-MM-DD') AS "periodEnd", + po.rate_percentage AS "ratePercentage", + dd.amount AS "amount", + dd.status AS "statusEnum", + dd.savings_transaction_id AS "savingsTransactionId", + COALESCE(bal.shares_held, 0) AS "sharesHeldNow" + FROM m_share_account_dividend_details dd + JOIN m_share_account sa ON sa.id = dd.account_id + JOIN m_share_product_dividend_pay_out po ON po.id = dd.dividend_pay_out_id + LEFT JOIN m_share_product sp ON sp.id = po.product_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + WHERE sa.client_id = ? + ORDER BY po.dividend_period_end_date DESC, dd.id DESC + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED), resolvedClientId); + + java.math.BigDecimal declared = java.math.BigDecimal.ZERO; + java.math.BigDecimal paid = java.math.BigDecimal.ZERO; + for (final Map row : rows) { + final java.math.BigDecimal amount = toDecimal(row.get("amount")); + declared = declared.add(amount); + // 600 is the posted/paid state on a dividend detail; anything earlier is still owed. + if (toLong(row.get("statusEnum")) == 600L) { + paid = paid.add(amount); + } + } + + final Map out = new LinkedHashMap<>(); + out.put("matches", List.of()); + out.put("member", members.get(0)); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("declarationCount", rows.size()); + totals.put("declared", declared); + totals.put("paid", paid); + totals.put("outstanding", declared.subtract(paid)); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /folio-statement — the member's folio in the share register + // ══════════════════════════════════════════════════════════════════════ + /** + * Folio Statement: one member's folio in the share register — every share account they hold and + * every movement across all of them, in one running sequence. + * + *

+ * Where the Account Statement is one account, a folio is the member: someone holding two share + * classes has two accounts but one folio, and the register is kept by folio. The running balance + * is therefore across accounts, which is the figure the register itself carries. + */ + @GET + @Path("/folio-statement") + @Operation(summary = "One member's folio statement", + description = "Every share account a member holds and every movement across all of them, with the folio's running holding.") + public Response folioStatement(@QueryParam("clientId") final Long clientId, @QueryParam("search") final String search, + @QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) { + this.context.authenticatedUser(); + + Long resolvedClientId = clientId; + if (resolvedClientId == null) { + if (search == null || search.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.share.folio.search.required", + "Search for a member by name, membership number or share account number."); + } + final String like = "%" + search.trim().toLowerCase() + "%"; + final List> matches = this.jdbcTemplate.queryForList(""" + SELECT DISTINCT c.id AS "clientId", c.display_name AS "clientName", c.account_no AS "cifNumber" + FROM m_client c + JOIN m_share_account sa ON sa.client_id = c.id + WHERE LOWER(c.display_name) LIKE ? OR LOWER(c.account_no) LIKE ? OR LOWER(sa.account_no) LIKE ? + ORDER BY c.display_name + LIMIT 25 + """, like, like, like); + if (matches.size() != 1) { + final Map choose = new LinkedHashMap<>(); + choose.put("matches", matches); + choose.put("folio", null); + return Response.ok(gson.toJson(choose)).build(); + } + resolvedClientId = toLong(matches.get(0).get("clientId")); + } + + final List> folios = this.jdbcTemplate.queryForList(""" + SELECT c.id AS "clientId", + c.account_no AS "folioNumber", + c.display_name AS "clientName", + c.mobile_no AS "mobileNo", + o.name AS "officeName", + to_char(c.activation_date, 'YYYY-MM-DD') AS "memberSince" + FROM m_client c LEFT JOIN m_office o ON o.id = c.office_id WHERE c.id = ? + """, resolvedClientId); + if (folios.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.share.folio.member.not.found", + "Member " + resolvedClientId + " was not found."); + } + + final List> accounts = this.jdbcTemplate.queryForList((""" + SELECT sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sp.name AS "productName", + sa.status_enum AS "statusEnum", + """ + HOLDING_STATE_SQL + """ + sp.unit_price AS "unitPrice", + COALESCE(bal.shares_held, 0) AS "sharesHeld", + COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0) AS "shareValue", + to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate", + to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate" + FROM m_share_account sa + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + WHERE sa.client_id = ? + ORDER BY sa.account_no + """).formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED), resolvedClientId); + + // Running balance across every account the folio holds, then filtered to the period — so a + // mid-year folio statement opens at the holding the member actually had, exactly as the + // single-account statement does. + final StringBuilder lineSql = new StringBuilder(""" + SELECT * FROM ( + SELECT t.id AS "transactionId", + sa.account_no AS "accountNo", + sp.name AS "productName", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "transactionDate", + t.type_enum AS "typeEnum", + CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END AS "sharesIn", + CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END AS "sharesOut", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.resolution_number AS "resolutionNumber", + t.funding_source AS "fundingSource", + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) + WHEN t.type_enum = %d THEN -COALESCE(t.total_shares, 0) + ELSE 0 END) + OVER (ORDER BY t.transaction_date, t.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS "folioBalance", + t.transaction_date AS raw_date + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + WHERE sa.client_id = ? AND t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR sa.collection_applied = true) + ) s WHERE 1 = 1 + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, + TYPE_REDEEMED)); + final List lineArgs = new ArrayList<>(); + lineArgs.add(resolvedClientId); + if (fromDate != null && !fromDate.isBlank()) { + lineSql.append(" AND s.raw_date >= CAST(? AS date) "); + lineArgs.add(fromDate); + } + if (toDate != null && !toDate.isBlank()) { + lineSql.append(" AND s.raw_date <= CAST(? AS date) "); + lineArgs.add(toDate); + } + lineSql.append(" ORDER BY raw_date, \"transactionId\" "); + + final List> lines = this.jdbcTemplate.queryForList(lineSql.toString(), lineArgs.toArray()); + for (final Map line : lines) { + line.remove("raw_date"); + } + + long held = 0L; + java.math.BigDecimal value = java.math.BigDecimal.ZERO; + for (final Map account : accounts) { + held += toLong(account.get("sharesHeld")); + value = value.add(toDecimal(account.get("shareValue"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("matches", List.of()); + out.put("folio", folios.get(0)); + out.put("accounts", accounts); + out.put("lines", lines); + out.put("fromDate", fromDate); + out.put("toDate", toDate); + final Map totals = new LinkedHashMap<>(); + totals.put("accountCount", accounts.size()); + totals.put("sharesHeld", held); + totals.put("shareValue", value); + totals.put("movementCount", lines.size()); + totals.put("closingFolioBalance", lines.isEmpty() ? held : toLong(lines.get(lines.size() - 1).get("folioBalance"))); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /distinctive-numbers — which numbered shares each member actually holds + // ══════════════════════════════════════════════════════════════════════ + /** + * Share Distinctive Number Detail: the numbered range of shares each allotment covers, per + * member and per share class. + * + *

+ * A share certificate names the distinctive numbers it covers — "shares 501 to 550" — and the + * register has to be able to say which member holds which numbers. There is no distinctive-number + * column anywhere in the schema, so rather than inventing one and back-filling a guess, the + * range is derived the way the society itself would have allotted them: shares are numbered in + * the order they were issued, per share class, so each paid-up allotment takes the next block. + * + *

+ * That derivation is stable for a given history — the same allotments always produce the same + * ranges — and self-evidently correct as long as the society has never renumbered. It is + * presented as derived rather than recorded, so nobody reads it as an independently-kept number. + * + *

+ * Redemptions are deliberately not renumbered: shares that come back leave a gap in the sequence + * rather than shuffling everybody else's certificates, which is exactly what a real register + * does. + */ + @GET + @Path("/distinctive-numbers") + @Operation(summary = "Share distinctive number register", + description = "The numbered share range each allotment covers, per member and share class, derived from issue order.") + public Response distinctiveNumbers(@QueryParam("productId") final Long productId, @QueryParam("clientId") final Long clientId) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT t.id AS "transactionId", + to_char(t.transaction_date, 'YYYY-MM-DD') AS "allotmentDate", + sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.account_no AS "cifNumber", + c.display_name AS "clientName", + o.name AS "officeName", + sp.id AS "productId", + sp.name AS "productName", + t.total_shares AS "shares", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.resolution_number AS "resolutionNumber", + t.funding_source AS "fundingSource", + -- Numbered in issue order within the share class: this allotment starts one + -- after everything issued before it, and runs for its own share count. The + -- very first allotment in a class has nothing before it, and the window sums + -- to NULL there rather than zero — coalesced, or share number one would come + -- out blank on exactly the row a reader checks first. + COALESCE(SUM(COALESCE(t.total_shares, 0)) OVER ( + PARTITION BY sa.product_id + ORDER BY t.transaction_date, t.id + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), 0) + 1 AS "distinctiveFrom", + SUM(COALESCE(t.total_shares, 0)) OVER ( + PARTITION BY sa.product_id + ORDER BY t.transaction_date, t.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS "distinctiveTo" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum = %d + AND (t.funding_source IS NOT NULL OR sa.collection_applied = true) + """.formatted(TXN_APPROVED, TYPE_PURCHASED)); + + final List args = new ArrayList<>(); + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + if (clientId != null) { + sql.append(" AND sa.client_id = ? "); + args.add(clientId); + } + sql.append(" ORDER BY sp.name, t.transaction_date, t.id "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long issued = 0L; + for (final Map row : rows) { + issued += toLong(row.get("shares")); + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("allotmentCount", rows.size()); + totals.put("sharesNumbered", issued); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /area-wise — where the society's members and its capital sit + // ══════════════════════════════════════════════════════════════════════ + /** + * Area Wise: members and share capital grouped by where the members live. + * + *

+ * A co-operative's area of operation is part of its licence, so "how much of our capital comes + * from which area" is a question the board and the Registrar both ask. Grouped on the member's + * own recorded address rather than their branch, since the two answer different questions and it + * is the address a licence is written against. + * + *

+ * Members with no address on file are their own group rather than being dropped — an + * unaddressable member is a gap in the register worth seeing, not a row to hide. + */ + @GET + @Path("/area-wise") + @Operation(summary = "Members and share capital by area", + description = "Shareholding grouped by the member's recorded city or area, with the members behind each group.") + public Response areaWise(@QueryParam("productId") final Long productId) { + this.context.authenticatedUser(); + + final List args = new ArrayList<>(); + final StringBuilder holdings = new StringBuilder(""" + SELECT c.id AS client_id, + c.account_no AS cif_number, + c.display_name AS client_name, + c.mobile_no AS mobile_no, + o.name AS office_name, + COALESCE(NULLIF(TRIM(addr.city), ''), + NULLIF(TRIM(addr.town_village), ''), + NULLIF(TRIM(addr.county_district), ''), + 'Not recorded') AS area, + addr.postal_code AS postal_code, + SUM(COALESCE(bal.shares_held, 0)) AS shares_held, + SUM(COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0)) AS share_value + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + SELECT a.client_id, + MIN(a.city) AS city, + MIN(a.town_village) AS town_village, + MIN(a.county_district) AS county_district, + MIN(a.postal_code) AS postal_code + FROM m_address a + WHERE COALESCE(a.is_active, true) = true AND COALESCE(a.is_deleted, false) = false + GROUP BY a.client_id + ) addr ON addr.client_id = c.id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + WHERE 1 = 1 + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + if (productId != null) { + holdings.append(" AND sp.id = ? "); + args.add(productId); + } + holdings.append(""" + GROUP BY c.id, c.account_no, c.display_name, c.mobile_no, o.name, addr.city, addr.town_village, + addr.county_district, addr.postal_code + HAVING SUM(COALESCE(bal.shares_held, 0)) > 0 + """); + + final List> areas = this.jdbcTemplate.queryForList(""" + WITH held AS ( + """ + holdings + """ + ) + SELECT area AS "area", + COUNT(*) AS "memberCount", + SUM(shares_held) AS "shares", + SUM(share_value) AS "shareValue" + FROM held + GROUP BY area + ORDER BY SUM(share_value) DESC, area + """, args.toArray()); + + final List> members = this.jdbcTemplate.queryForList(""" + WITH held AS ( + """ + holdings + """ + ) + SELECT client_id AS "clientId", + cif_number AS "cifNumber", + client_name AS "clientName", + mobile_no AS "mobileNo", + office_name AS "officeName", + area AS "area", + postal_code AS "postalCode", + shares_held AS "sharesHeld", + share_value AS "shareValue" + FROM held + ORDER BY area, client_name + """, args.toArray()); + + long shares = 0L; + java.math.BigDecimal value = java.math.BigDecimal.ZERO; + for (final Map row : members) { + shares += toLong(row.get("sharesHeld")); + value = value.add(toDecimal(row.get("shareValue"))); + } + + final Map out = new LinkedHashMap<>(); + out.put("areas", areas); + out.put("members", members); + final Map totals = new LinkedHashMap<>(); + totals.put("areaCount", areas.size()); + totals.put("memberCount", members.size()); + totals.put("shares", shares); + totals.put("shareValue", value); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET /mailing-list — what a label and a letter both need + // ══════════════════════════════════════════════════════════════════════ + /** + * The address list behind Share Label and Share Letter: every shareholding member with the + * address to reach them at and the holding to quote. + * + *

+ * One endpoint for both, because a label and a letter want exactly the same rows — name, + * address, folio, holding — and differ only in what is printed around them. Splitting it would + * mean two queries that have to be kept saying the same thing. + * + *

+ * Members with no usable address are returned too, flagged: a mailing run has to know who it + * cannot reach, and silently shortening the list is how those members never hear from the + * society again. + */ + @GET + @Path("/mailing-list") + @Operation(summary = "Shareholding members with addresses", + description = "Name, address, folio and holding for every shareholding member — the rows behind share labels and letters.") + public Response mailingList(@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId, + @QueryParam("area") final String area) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT c.id AS "clientId", + c.account_no AS "folioNumber", + c.display_name AS "clientName", + c.mobile_no AS "mobileNo", + o.name AS "officeName", + addr.address_line_1 AS "addressLine1", + addr.address_line_2 AS "addressLine2", + addr.street AS "street", + COALESCE(NULLIF(TRIM(addr.city), ''), NULLIF(TRIM(addr.town_village), '')) AS "city", + addr.county_district AS "district", + addr.postal_code AS "postalCode", + COALESCE(NULLIF(TRIM(addr.city), ''), + NULLIF(TRIM(addr.town_village), ''), + NULLIF(TRIM(addr.county_district), ''), + 'Not recorded') AS "area", + SUM(COALESCE(bal.shares_held, 0)) AS "sharesHeld", + SUM(COALESCE(bal.shares_held, 0) * COALESCE(sp.unit_price, 0)) AS "shareValue", + STRING_AGG(sa.account_no, ', ' ORDER BY sa.account_no) AS "shareAccountNos", + (addr.address_line_1 IS NOT NULL OR addr.street IS NOT NULL + OR addr.city IS NOT NULL OR addr.town_village IS NOT NULL) AS "addressable" + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + LEFT JOIN ( + SELECT a.client_id, + MIN(a.address_line_1) AS address_line_1, + MIN(a.address_line_2) AS address_line_2, + MIN(a.street) AS street, + MIN(a.city) AS city, + MIN(a.town_village) AS town_village, + MIN(a.county_district) AS county_district, + MIN(a.postal_code) AS postal_code + FROM m_address a + WHERE COALESCE(a.is_active, true) = true AND COALESCE(a.is_deleted, false) = false + GROUP BY a.client_id + ) addr ON addr.client_id = c.id + LEFT JOIN ( + SELECT t.account_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares_held + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + GROUP BY t.account_id + ) bal ON bal.account_id = sa.id + WHERE 1 = 1 + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED)); + + final List args = new ArrayList<>(); + if (productId != null) { + sql.append(" AND sp.id = ? "); + args.add(productId); + } + if (officeId != null) { + sql.append(" AND o.id = ? "); + args.add(officeId); + } + sql.append(""" + GROUP BY c.id, c.account_no, c.display_name, c.mobile_no, o.name, addr.address_line_1, + addr.address_line_2, addr.street, addr.city, addr.town_village, addr.county_district, + addr.postal_code + HAVING SUM(COALESCE(bal.shares_held, 0)) > 0 + """); + if (area != null && !area.isBlank()) { + // Applied after grouping because the area is itself a coalesced expression. + sql.append(" AND COALESCE(NULLIF(TRIM(addr.city), ''), NULLIF(TRIM(addr.town_village), '')," + + " NULLIF(TRIM(addr.county_district), ''), 'Not recorded') = ? "); + args.add(area); + } + sql.append(" ORDER BY c.display_name "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long shares = 0L; + java.math.BigDecimal value = java.math.BigDecimal.ZERO; + int unaddressable = 0; + for (final Map row : rows) { + shares += toLong(row.get("sharesHeld")); + value = value.add(toDecimal(row.get("shareValue"))); + if (!Boolean.TRUE.equals(row.get("addressable"))) { + unaddressable++; + } + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("memberCount", rows.size()); + totals.put("unaddressable", unaddressable); + totals.put("shares", shares); + totals.put("shareValue", value); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════ + + /** Period predicate applied outside the window function, so the running balance opens correctly. */ + private String periodFilter(final String fromDate, final String toDate) { + final StringBuilder filter = new StringBuilder(); + if (fromDate != null && !fromDate.isBlank()) { + filter.append(" AND s.raw_date >= CAST(? AS date) "); + } + if (toDate != null && !toDate.isBlank()) { + filter.append(" AND s.raw_date <= CAST(? AS date) "); + } + return filter.toString(); + } + + private Object[] periodArgs(final Long shareAccountId, final String fromDate, final String toDate) { + final List args = new ArrayList<>(); + args.add(shareAccountId); + if (fromDate != null && !fromDate.isBlank()) { + args.add(fromDate); + } + if (toDate != null && !toDate.isBlank()) { + args.add(toDate); + } + return args.toArray(); + } + + private long toLong(final Object value) { + return value instanceof Number number ? number.longValue() : 0L; + } + + /** + * How many rows sit in each holding state, and how many shares are allotted but unpaid. + * + *

+ * Reported alongside the money so a reader is told "two of these are closed and one was never + * collected" up front, rather than being left to work out from a column of zeroes why the list is + * longer than the capital suggests. Every state is present even at zero, so a report that has no + * closed accounts still says so instead of going quiet about it. + */ + private Map holdingStateCounts(final List> rows) { + final Map counts = new LinkedHashMap<>(); + for (final String state : List.of("HOLDING", "UNPAID", "NIL", "CLOSED", "NOT_ACTIVE", "REJECTED")) { + counts.put(state, 0L); + } + long unpaidShares = 0L; + for (final Map row : rows) { + final Object state = row.get("holdingState"); + if (state != null) { + counts.put(String.valueOf(state), toLong(counts.get(String.valueOf(state))) + 1L); + } + unpaidShares += toLong(row.get("unpaidShares")); + } + counts.put("unpaidShares", unpaidShares); + return counts; + } + + private java.math.BigDecimal toDecimal(final Object value) { + if (value instanceof java.math.BigDecimal decimal) { + return decimal; + } + return value instanceof Number number ? java.math.BigDecimal.valueOf(number.doubleValue()) : java.math.BigDecimal.ZERO; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareTransferApiResource.java new file mode 100644 index 000000000..b3f69b206 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareTransferApiResource.java @@ -0,0 +1,496 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.Operation; +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.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Share transfer between two members. + * + *

+ * A transfer is not a sale to the society and a purchase from it — no money reaches the society and + * its share capital does not change, so no GL entry is posted. What changes is the register: one + * member's holding falls, another's rises, by the same number on the same day at the same price. + * That is recorded as the two share transactions the rest of the module already understands + * (a redemption on the transferor, a purchase on the transferee), tied together by the transfer row + * so a later reader can tell it was one act rather than two unrelated dealings. + * + *

+ * Maker-checker throughout: a transfer moves ownership and with it voting rights, which a + * co-operative does not leave to one clerk. Nothing moves until a checker approves, and every rule + * is re-checked at approval rather than trusted from submission — holdings move while a request + * waits, and a transfer that was valid on Monday can be impossible by Wednesday. + */ +@Path("/v1/sharetransfers") +@Component +@Tag(name = "Share Transfers", description = "Transfer of shares from one member to another, under board resolution.") +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareTransferApiResource { + + private static final int TYPE_PURCHASED = 500; + private static final int TYPE_REDEEMED = 600; + private static final int TXN_APPROVED = 300; + private static final int ACCOUNT_ACTIVE = 300; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // ══════════════════════════════════════════════════════════════════════ + // POST — a clerk records a transfer for approval + // ══════════════════════════════════════════════════════════════════════ + @POST + @Transactional + @Operation(summary = "Submit a share transfer for approval", + description = "Records a transfer of shares between two members. Nothing moves until a checker approves it.") + public Response submit(final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + final JsonObject json = JsonParser.parseString(body == null || body.isBlank() ? "{}" : body).getAsJsonObject(); + + final Long fromAccountId = getLong(json, "fromShareAccountId"); + final Long toAccountId = getLong(json, "toShareAccountId"); + final Long shares = getLong(json, "shares"); + final String transferDate = getString(json, "transferDate"); + final String resolutionNumber = getString(json, "resolutionNumber"); + final String meetingDate = getString(json, "meetingDate"); + final String reason = getString(json, "reason"); + + if (fromAccountId == null || toAccountId == null) { + throw badRequest("error.msg.sharetransfer.accounts.required", "Both the transferring and receiving share accounts are required."); + } + if (shares == null || shares <= 0) { + throw badRequest("error.msg.sharetransfer.shares.invalid", "The number of shares to transfer must be greater than zero."); + } + // A co-operative transfers shares under a board resolution, so the resolution is part of the + // request rather than something captured afterwards — a transfer with no resolution behind it + // is the finding an auditor writes up. + if (resolutionNumber == null || resolutionNumber.isBlank()) { + throw badRequest("error.msg.sharetransfer.resolution.required", + "The board resolution authorising this transfer is required."); + } + if (meetingDate == null || meetingDate.isBlank()) { + throw badRequest("error.msg.sharetransfer.meeting.date.required", "The board meeting date is required."); + } + + final LocalDate effectiveDate = transferDate == null || transferDate.isBlank() + ? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + : LocalDate.parse(transferDate); + + final Map from = loadAccount(fromAccountId, "transferring"); + final Map to = loadAccount(toAccountId, "receiving"); + validateTransfer(from, to, shares); + + final BigDecimal unitPrice = toDecimal(from.get("unit_price")); + final BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(shares)); + final String reference = nextReference(); + final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + + final Long id = this.jdbcTemplate.queryForObject(""" + INSERT INTO m_share_transfer + (transfer_reference, from_share_account_id, to_share_account_id, shares, unit_price, transfer_amount, + transfer_date, resolution_number, meeting_date, reason, status, maker_id, maker_name, submitted_at) + VALUES (?,?,?,?,?,?,?,?,?,?,'PENDING',?,?,?) + RETURNING id + """, Long.class, reference, fromAccountId, toAccountId, shares, unitPrice, amount, + java.sql.Date.valueOf(effectiveDate), resolutionNumber, java.sql.Date.valueOf(LocalDate.parse(meetingDate)), reason, + currentUser.getId(), currentUser.getDisplayName(), java.sql.Timestamp.valueOf(now)); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", id); + out.put("transferReference", reference); + out.put("status", "PENDING"); + out.put("shares", shares); + out.put("amount", amount); + out.put("message", "Transfer " + reference + " recorded and sent for approval. Nothing moves until it is approved."); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET — the transfer register + // ══════════════════════════════════════════════════════════════════════ + @GET + @Operation(summary = "Share transfer register", + description = "Every share transfer with both members, the shares moved and the resolution behind it.") + public Response list(@QueryParam("status") final String status) { + this.context.authenticatedUser(); + + final StringBuilder sql = new StringBuilder(""" + SELECT st.id AS "transferId", + st.transfer_reference AS "transferReference", + to_char(st.transfer_date, 'YYYY-MM-DD') AS "transferDate", + st.shares AS "shares", + st.unit_price AS "unitPrice", + st.transfer_amount AS "transferAmount", + st.status AS "status", + st.resolution_number AS "resolutionNumber", + to_char(st.meeting_date, 'YYYY-MM-DD') AS "meetingDate", + st.reason AS "reason", + st.maker_name AS "makerName", + to_char(st.submitted_at, 'YYYY-MM-DD') AS "submittedOn", + st.checker_name AS "checkerName", + to_char(st.actioned_at, 'YYYY-MM-DD') AS "actionedOn", + st.rejection_reason AS "rejectionReason", + fa.id AS "fromShareAccountId", + fa.account_no AS "fromAccountNo", + fc.id AS "fromClientId", + fc.display_name AS "fromClientName", + fc.account_no AS "fromCifNumber", + ta.id AS "toShareAccountId", + ta.account_no AS "toAccountNo", + tc.id AS "toClientId", + tc.display_name AS "toClientName", + tc.account_no AS "toCifNumber", + sp.name AS "productName", + fa.currency_code AS "currencyCode" + FROM m_share_transfer st + JOIN m_share_account fa ON fa.id = st.from_share_account_id + JOIN m_client fc ON fc.id = fa.client_id + JOIN m_share_account ta ON ta.id = st.to_share_account_id + JOIN m_client tc ON tc.id = ta.client_id + LEFT JOIN m_share_product sp ON sp.id = fa.product_id + WHERE 1 = 1 + """); + final List args = new ArrayList<>(); + if (status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status)) { + sql.append(" AND st.status = ? "); + args.add(status); + } + sql.append(" ORDER BY st.submitted_at DESC, st.id DESC "); + + final List> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + + long transferredShares = 0L; + BigDecimal transferredValue = BigDecimal.ZERO; + int pending = 0; + for (final Map row : rows) { + if ("APPROVED".equals(row.get("status"))) { + transferredShares += toLong(row.get("shares")); + transferredValue = transferredValue.add(toDecimal(row.get("transferAmount"))); + } else if ("PENDING".equals(row.get("status"))) { + pending++; + } + } + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + final Map totals = new LinkedHashMap<>(); + totals.put("transferCount", rows.size()); + totals.put("pendingCount", pending); + totals.put("sharesTransferred", transferredShares); + totals.put("valueTransferred", transferredValue); + out.put("totals", totals); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // POST /{id}/approve — the transfer actually happens here + // ══════════════════════════════════════════════════════════════════════ + @POST + @Path("/{transferId}/approve") + @Transactional + @Operation(summary = "Approve a share transfer", + description = "Re-checks every rule against today's holdings, then moves the shares: a redemption on the transferor and a purchase on the transferee.") + public Response approve(@PathParam("transferId") final Long transferId) { + final AppUser currentUser = this.context.authenticatedUser(); + + final Map transfer = loadTransfer(transferId); + if (!"PENDING".equals(transfer.get("status"))) { + throw badRequest("error.msg.sharetransfer.not.pending", + "Transfer " + transfer.get("transfer_reference") + " is already " + transfer.get("status") + "."); + } + + final Long fromAccountId = toLong(transfer.get("from_share_account_id")); + final Long toAccountId = toLong(transfer.get("to_share_account_id")); + final long shares = toLong(transfer.get("shares")); + + // Re-checked, not trusted: holdings move while a request waits, and a transfer that was + // valid when it was raised can be impossible by the time it reaches a checker. + final Map from = loadAccount(fromAccountId, "transferring"); + final Map to = loadAccount(toAccountId, "receiving"); + validateTransfer(from, to, shares); + + final BigDecimal unitPrice = toDecimal(transfer.get("unit_price")); + final BigDecimal amount = toDecimal(transfer.get("transfer_amount")); + final java.sql.Date effectiveDate = (java.sql.Date) transfer.get("transfer_date"); + final Object meetingDate = transfer.get("meeting_date"); + final String resolutionNumber = (String) transfer.get("resolution_number"); + + // The transferor is redeemed and the transferee purchases — the same two movements the rest + // of the share module already reads, so every balance, statement and report picks this up + // without knowing transfers exist. + final Long fromTxnId = this.jdbcTemplate.queryForObject(""" + INSERT INTO m_share_account_transactions + (account_id, transaction_date, total_shares, unit_price, amount, status_enum, type_enum, is_active, + resolution_number, meeting_date) + VALUES (?,?,?,?,?,?,?,true,?,?) + RETURNING id + """, Long.class, fromAccountId, effectiveDate, shares, unitPrice, amount, TXN_APPROVED, TYPE_REDEEMED, + resolutionNumber, meetingDate); + + final Long toTxnId = this.jdbcTemplate.queryForObject(""" + INSERT INTO m_share_account_transactions + (account_id, transaction_date, total_shares, unit_price, amount, status_enum, type_enum, is_active, + resolution_number, meeting_date) + VALUES (?,?,?,?,?,?,?,true,?,?) + RETURNING id + """, Long.class, toAccountId, effectiveDate, shares, unitPrice, amount, TXN_APPROVED, TYPE_PURCHASED, + resolutionNumber, meetingDate); + + // The account's own running total is kept in step with the transactions just written, since + // the rest of the platform reads it directly. + this.jdbcTemplate.update( + "UPDATE m_share_account SET total_approved_shares = COALESCE(total_approved_shares, 0) - ? WHERE id = ?", shares, + fromAccountId); + this.jdbcTemplate.update( + "UPDATE m_share_account SET total_approved_shares = COALESCE(total_approved_shares, 0) + ? WHERE id = ?", shares, + toAccountId); + + this.jdbcTemplate.update(""" + UPDATE m_share_transfer SET status = 'APPROVED', from_transaction_id = ?, to_transaction_id = ?, + checker_id = ?, checker_name = ?, actioned_at = ? + WHERE id = ? + """, fromTxnId, toTxnId, currentUser.getId(), currentUser.getDisplayName(), + java.sql.Timestamp.valueOf(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())), transferId); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", transferId); + out.put("transferReference", transfer.get("transfer_reference")); + out.put("status", "APPROVED"); + out.put("shares", shares); + out.put("amount", amount); + out.put("fromTransactionId", fromTxnId); + out.put("toTransactionId", toTxnId); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // POST /{id}/reject + // ══════════════════════════════════════════════════════════════════════ + @POST + @Path("/{transferId}/reject") + @Transactional + @Operation(summary = "Reject a share transfer") + public Response reject(@PathParam("transferId") final Long transferId, final String body) { + final AppUser currentUser = this.context.authenticatedUser(); + final JsonObject json = JsonParser.parseString(body == null || body.isBlank() ? "{}" : body).getAsJsonObject(); + + final Map transfer = loadTransfer(transferId); + if (!"PENDING".equals(transfer.get("status"))) { + throw badRequest("error.msg.sharetransfer.not.pending", + "Transfer " + transfer.get("transfer_reference") + " is already " + transfer.get("status") + "."); + } + + this.jdbcTemplate.update(""" + UPDATE m_share_transfer SET status = 'REJECTED', rejection_reason = ?, checker_id = ?, checker_name = ?, + actioned_at = ? + WHERE id = ? + """, getString(json, "rejectionReason"), currentUser.getId(), currentUser.getDisplayName(), + java.sql.Timestamp.valueOf(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())), transferId); + + final Map out = new LinkedHashMap<>(); + out.put("resourceId", transferId); + out.put("status", "REJECTED"); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════ + + /** + * Every rule a transfer has to satisfy, applied identically at submission and at approval. + * + *

+ * Holdings are derived from the transactions rather than read off the account's running total, + * for the same reason the balance report does it: the total is what the account currently says, + * and this needs what the movements actually add up to. + */ + private void validateTransfer(final Map from, final Map to, final long shares) { + final Long fromAccountId = toLong(from.get("id")); + final Long toAccountId = toLong(to.get("id")); + + if (fromAccountId.equals(toAccountId)) { + throw badRequest("error.msg.sharetransfer.same.account", "A share account cannot transfer to itself."); + } + if (toLong(from.get("client_id")) == toLong(to.get("client_id"))) { + throw badRequest("error.msg.sharetransfer.same.member", + "Both accounts belong to the same member, so there is nothing to transfer between."); + } + // Different share classes carry different rights and prices; moving shares across them would + // be an issue and a redemption, not a transfer. + if (toLong(from.get("product_id")) != toLong(to.get("product_id"))) { + throw badRequest("error.msg.sharetransfer.product.mismatch", + "Shares can only be transferred within the same share class. " + from.get("product_name") + " and " + + to.get("product_name") + " are different classes."); + } + if (toInt(from.get("status_enum")) != ACCOUNT_ACTIVE) { + throw badRequest("error.msg.sharetransfer.from.not.active", + "Share account " + from.get("account_no") + " is not active, so it cannot transfer shares."); + } + if (toInt(to.get("status_enum")) != ACCOUNT_ACTIVE) { + throw badRequest("error.msg.sharetransfer.to.not.active", + "Share account " + to.get("account_no") + " is not active, so it cannot receive shares."); + } + + final long fromHeld = heldShares(fromAccountId); + final long toHeld = heldShares(toAccountId); + if (fromHeld < shares) { + throw badRequest("error.msg.sharetransfer.insufficient.shares", "Share account " + from.get("account_no") + " holds " + + fromHeld + " shares, which is fewer than the " + shares + " being transferred."); + } + + final Long maximum = toLongOrNull(to.get("maximum_client_shares")); + if (maximum != null && toHeld + shares > maximum) { + throw badRequest("error.msg.sharetransfer.above.maximum", + "The transfer would leave " + to.get("to_client_name") + " holding " + (toHeld + shares) + + " shares, above the " + maximum + " this share class allows one member."); + } + + // A transferor may go to nil — that is a member leaving — but may not be left holding less + // than the class's minimum, which is a membership the society cannot recognise. + final Long minimum = toLongOrNull(from.get("minimum_client_shares")); + final long remaining = fromHeld - shares; + if (minimum != null && remaining > 0 && remaining < minimum) { + throw badRequest("error.msg.sharetransfer.below.minimum", + "The transfer would leave " + from.get("account_no") + " holding " + remaining + " shares, below the " + + minimum + " minimum this share class requires. Transfer all " + fromHeld + + " shares, or leave at least " + minimum + "."); + } + } + + /** + * What the account's settled movements actually add up to, ignoring its running total. + * + *

+ * Only paid-up shares can be transferred. An opening tranche whose collection has never been + * applied is shares allotted against money the society never received — the member does not yet + * own them to give away, and transferring them would move an unpaid holding onto somebody else's + * name. The account's own {@code total_approved_shares} counts them, which is exactly why this + * walks the transactions instead. + */ + private long heldShares(final Long shareAccountId) { + final Long held = this.jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN t.type_enum = ? THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = ? THEN COALESCE(t.total_shares, 0) ELSE 0 END), 0) + FROM m_share_account_transactions t + JOIN m_share_account pa ON pa.id = t.account_id + WHERE t.account_id = ? AND t.is_active = true AND t.status_enum = ? AND t.type_enum IN (?, ?) + AND (t.type_enum = ? OR t.funding_source IS NOT NULL OR pa.collection_applied = true) + """, Long.class, TYPE_PURCHASED, TYPE_REDEEMED, shareAccountId, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED, + TYPE_REDEEMED); + return held == null ? 0L : held; + } + + private Map loadAccount(final Long shareAccountId, final String side) { + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code, + c.display_name AS to_client_name, c.account_no AS cif_number, + sp.name AS product_name, sp.unit_price, sp.minimum_client_shares, sp.maximum_client_shares + FROM m_share_account sa + JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_share_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + """, shareAccountId); + if (rows.isEmpty()) { + throw badRequest("error.msg.sharetransfer.account.not.found", + "The " + side + " share account " + shareAccountId + " was not found."); + } + return rows.get(0); + } + + private Map loadTransfer(final Long transferId) { + final List> rows = this.jdbcTemplate + .queryForList("SELECT * FROM m_share_transfer WHERE id = ?", transferId); + if (rows.isEmpty()) { + throw badRequest("error.msg.sharetransfer.not.found", "Transfer " + transferId + " was not found."); + } + return rows.get(0); + } + + /** TRF-YYMMDD-NNNN, dated and serial, so a reference says when it was raised. */ + private String nextReference() { + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final String prefix = "TRF-" + today.format(java.time.format.DateTimeFormatter.ofPattern("yyMMdd")) + "-"; + final Integer used = this.jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_share_transfer WHERE transfer_reference LIKE ?", Integer.class, prefix + "%"); + return prefix + String.format("%04d", (used == null ? 0 : used) + 1); + } + + private PlatformDataIntegrityException badRequest(final String code, final String message) { + return new PlatformDataIntegrityException(code, message); + } + + private Long getLong(final JsonObject json, final String key) { + return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : null; + } + + private String getString(final JsonObject json, final String key) { + return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null; + } + + private long toLong(final Object value) { + return value instanceof Number number ? number.longValue() : 0L; + } + + private Long toLongOrNull(final Object value) { + return value instanceof Number number ? number.longValue() : null; + } + + private int toInt(final Object value) { + return value instanceof Number number ? number.intValue() : 0; + } + + private BigDecimal toDecimal(final Object value) { + if (value instanceof BigDecimal decimal) { + return decimal; + } + return value instanceof Number number ? BigDecimal.valueOf(number.doubleValue()) : BigDecimal.ZERO; + } +} 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 d07a28c4e..c3f1fc7a8 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 @@ -748,6 +748,16 @@ public class ShareAccountDataSerializer { if (!account.status().equals(ShareAccountStatusType.ACTIVE.getValue())) { baseDataValidator.failWithCodeNoParameterAddedToErrorCode("is.not.in.active.state"); } + // An Active account can still be an unpaid application -- activation posts no GL of its own, + // only Apply Collection does. A further tranche on top of a first allotment nobody has paid + // for yet would grow a holding that does not exist: settle the original application first, + // then add to it. Read straight from the column rather than through the entity, which does + // not map it -- collection_applied lives outside JPA, set only by the collection endpoints. + final Boolean collectionApplied = this.jdbcTemplate + .queryForObject("SELECT collection_applied FROM m_share_account WHERE id = ?", Boolean.class, account.getId()); + if (!Boolean.TRUE.equals(collectionApplied)) { + baseDataValidator.failWithCodeNoParameterAddedToErrorCode("original.application.not.yet.settled"); + } LocalDate requestedDate = this.fromApiJsonHelper.extractLocalDateNamed(ShareAccountApiConstants.requesteddate_paramname, element); baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname).value(requestedDate).notNull(); final Long sharesRequested = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element); 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 4c0994bd0..730b2fdd7 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 @@ -112,4 +112,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0567_create_share_transfer.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0567_create_share_transfer.xml new file mode 100644 index 000000000..d5e130e85 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0567_create_share_transfer.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +