feat(shares): reports suite, GL transaction fixes, strict entry-type enforcement

- Add ShareReportsApiResource (final balance, account statement, issued/status/
  nominal/share-wise/guarantor/member-interest reports, enquiry, payment
  certificate, share detail, dividend statement, folio statement, distinctive
  numbers, area-wise, mailing list) and ShareTransferApiResource, applying the
  paid-up-capital predicate everywhere so an unsettled application never
  counts as issued capital
- Add share transfer migration (0567)
- Enforce the original application's collection before a further tranche can
  be applied (ShareAccountDataSerializer), matching the same gate on screen
- Fix postGlOffsetEntry silently discarding the real GL transaction id on the
  clerk-collection fallback posting path, leaving gl_transaction_id blank and
  the receipt's GL-detail popup empty
- Add gl_code to the by-voucher and adoption responses so the GL Account
  picker on screen follows whichever receipt was found or adopted
- Require an exact "Share Collection" entry-type tag to adopt a voucher --
  "General" and untagged rows no longer qualify

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 13:02:55 +05:30
co-authored by Claude Sonnet 5
parent bf52c2cda1
commit e206536a6b
7 changed files with 2865 additions and 6 deletions
@@ -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<Map<String, Object>> 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);
}
@@ -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 ─────────────────────────────────────────────
@@ -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.
*
* <p>
* 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.
*
* <p>
* 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<String, Object> from = loadAccount(fromAccountId, "transferring");
final Map<String, Object> 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<String, Object> 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<Object> 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<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
long transferredShares = 0L;
BigDecimal transferredValue = BigDecimal.ZERO;
int pending = 0;
for (final Map<String, Object> 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<String, Object> out = new LinkedHashMap<>();
out.put("rows", rows);
final Map<String, Object> 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<String, Object> 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<String, Object> from = loadAccount(fromAccountId, "transferring");
final Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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.
*
* <p>
* 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<String, Object> from, final Map<String, Object> 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.
*
* <p>
* 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<String, Object> loadAccount(final Long shareAccountId, final String side) {
final List<Map<String, Object>> 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<String, Object> loadTransfer(final Long transferId) {
final List<Map<String, Object>> 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;
}
}
@@ -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);
@@ -112,4 +112,5 @@
<include file="parts/0564_add_entry_type_to_gl_requests.xml" relativeToChangelogFile="true"/>
<include file="parts/0565_convert_entry_type_to_code_value.xml" relativeToChangelogFile="true"/>
<include file="parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0567_create_share_transfer.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Share transfer between members.
A transfer is two share transactions that must happen together or not at all: the transferor
is redeemed and the transferee purchases, same shares, same day, same price. Recording only
those two rows would leave nothing saying they were one act, so a later reader sees a member
who sold and, separately, a member who bought, with no way to tell it was a transfer rather
than two unrelated dealings.
m_share_transfer is that missing act. It also carries what a co-operative needs a transfer to
have but a share transaction has nowhere to put: the board resolution that approved it, the
reason, and the maker-checker trail, since a transfer moves ownership and voting rights
between two members and cannot be a single clerk's decision.
No GL entry is involved and none is recorded here: share capital does not change when it
moves between two members, only the register does.
-->
<changeSet id="0567_create_share_transfer" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_share_transfer"/></not>
</preConditions>
<createTable tableName="m_share_transfer">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_reference" type="VARCHAR(40)">
<constraints nullable="false" unique="true"/>
</column>
<column name="from_share_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="to_share_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="shares" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="unit_price" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="transfer_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="transfer_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="resolution_number" type="VARCHAR(100)"/>
<column name="meeting_date" type="DATE"/>
<column name="reason" type="VARCHAR(500)"/>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<!-- The two share transactions this transfer became, written only on approval. -->
<column name="from_transaction_id" type="BIGINT"/>
<column name="to_transaction_id" type="BIGINT"/>
<column name="maker_id" type="BIGINT"/>
<column name="maker_name" type="VARCHAR(150)"/>
<column name="submitted_at" type="TIMESTAMP"/>
<column name="checker_id" type="BIGINT"/>
<column name="checker_name" type="VARCHAR(150)"/>
<column name="actioned_at" type="TIMESTAMP"/>
<column name="rejection_reason" type="VARCHAR(500)"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_share_transfer" baseColumnNames="from_share_account_id"
constraintName="fk_share_transfer_from_account" referencedTableName="m_share_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_share_transfer" baseColumnNames="to_share_account_id"
constraintName="fk_share_transfer_to_account" referencedTableName="m_share_account" referencedColumnNames="id"/>
</changeSet>
<changeSet id="0567_index_share_transfer" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_share_transfer_status"/></not>
</preConditions>
<createIndex tableName="m_share_transfer" indexName="idx_share_transfer_status">
<column name="status"/>
</createIndex>
<createIndex tableName="m_share_transfer" indexName="idx_share_transfer_from">
<column name="from_share_account_id"/>
</createIndex>
<createIndex tableName="m_share_transfer" indexName="idx_share_transfer_to">
<column name="to_share_account_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>