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:
+25
-3
@@ -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);
|
||||
}
|
||||
|
||||
+19
-3
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
+2221
File diff suppressed because it is too large
Load Diff
+496
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
@@ -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>
|
||||
|
||||
+93
@@ -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>
|
||||
Reference in New Issue
Block a user