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(); this.context.authenticatedUser();
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. // 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 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," .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," + " 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 " + " 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); + " 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( return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," "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 = ?", + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo); receiptNo);
} }
@@ -1014,7 +1029,14 @@ public class ShareAdvanceCollectionApiResource {
return jdbcTemplate.queryForMap( return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," "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 = ?", + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo); receiptNo);
} }
@@ -746,8 +746,14 @@ public class ClerkDisbursementRequestApiResource {
} else { } else {
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1); final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
if (cashVaultGlId != null) { if (cashVaultGlId != null) {
postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal, approvedGlTxnId = postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
officeId, currencyCode, txnDateStr, description); 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; 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 Long drGlAccountId, final Long crGlAccountId,
final BigDecimal amount, final Long officeId, final BigDecimal amount, final Long officeId,
final String currencyCode, final String txnDateStr, final String comments) { final String currencyCode, final String txnDateStr, final String comments) {
@@ -1476,7 +1490,9 @@ public class ClerkDisbursementRequestApiResource {
.build(); .build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
// The disbursement register raises its own voucher; suppress the journal command's own. // 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 ───────────────────────────────────────────── // ── 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())) { if (!account.status().equals(ShareAccountStatusType.ACTIVE.getValue())) {
baseDataValidator.failWithCodeNoParameterAddedToErrorCode("is.not.in.active.state"); 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); LocalDate requestedDate = this.fromApiJsonHelper.extractLocalDateNamed(ShareAccountApiConstants.requesteddate_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname).value(requestedDate).notNull(); baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname).value(requestedDate).notNull();
final Long sharesRequested = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element); 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/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/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/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0567_create_share_transfer.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </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>