Compare commits

...
3 Commits
Author SHA1 Message Date
nitesh e4fad8b648 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev-latest
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-09-18 13:11:10 +05:30
niteshandClaude Sonnet 5 e206536a6b 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>
2026-09-18 13:02:55 +05:30
niteshandClaude Sonnet 5 bf52c2cda1 feat(shares): year-safe voucher numbers, universal Share Collection visibility, unrestricted apply
Voucher numbers now embed the year (PREFIX/DDMMYYYY/NNNN) so the same day
and month a year apart can never produce the same voucher_no text, which
every lookup/dedup across the cash registers matches on literally.

/shareadvancecollection/unattributed now lists every unapplied Share
Collection receipt across every share account screen, not just ownerless
GL deposits -- filtered to rows whose current Entry Type is still Share
Collection even after adoption, with the linked member named for
transparency. Applying a receipt to a share account no longer blocks on a
client mismatch, per product decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 12:48:22 +05:30
8 changed files with 3029 additions and 79 deletions
@@ -196,11 +196,11 @@ public class ShareAdvanceCollectionApiResource {
// to search it up by hand -- it's pulled onto this list itself, the first time anyone asks
// for it. Anything not tagged this way (an older row, or one deliberately left General) still
// needs the by-voucher search below, same as before.
autoAdoptShareCollectionTagged(clientId);
autoAdoptShareCollectionTagged();
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ " cl.display_name AS client_name, "
+ " c.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,"
// The GL account this specific receipt's money actually sits in -- its own posting's
// credit leg, read back from acc_gl_journal_entry rather than guessed, so the GL
// Account picker above can be kept in sync with whichever receipt gets ticked.
@@ -222,6 +222,102 @@ public class ShareAdvanceCollectionApiResource {
// ══════════════════════════════════════════════════════════════════════
// GET — One unapplied receipt by the voucher number quoted by the member
// ══════════════════════════════════════════════════════════════════════
// GET /unattributed — share-collection receipts that name no member yet
// ══════════════════════════════════════════════════════════════════════
/**
* Approved cashier receipts tagged as a share collection that are still waiting to be attached to
* a member.
*
* <p>
* A receipt taken straight into a GL account names no member — a GL account is nobody's account —
* so it can never be auto-adopted onto whichever member's screen happens to be open. That rule
* stands. What it left behind, though, was a counter tagging a receipt "Share Collection" on
* purpose and then finding nothing on the Apply screen but "no unapplied receipt for this
* member", with no way to discover that the voucher they just raised is sitting right there.
*
* <p>
* This lists exactly those receipts so they can be seen and picked. Picking one still runs the
* same by-voucher adoption a typed voucher number does: a person at the counter deliberately
* attaching a named voucher to a member they can see. The list makes the voucher findable; it
* does not decide whose money it is.
*
* <p>
* Receipts already claimed — applied to a share account, or consumed by a Demand Draft — are left
* out, so nothing here is something a second member could take twice. A receipt that already
* names a member (a clerk collection resolved from the savings account it debited) is still
* listed here, not only on that member's own screen -- every branch counter runs the same Apply
* screen for whichever member is in front of them, so a voucher must be findable there regardless
* of which member's screen happened to raise or auto-adopt it first. Attaching it from a different
* member's screen still goes through the ordinary by-voucher lookup below, which refuses a
* mismatch rather than silently moving the money -- this list only makes the voucher visible, it
* does not reassign it.
*/
@GET
@Path("/unattributed")
public Response listUnattributed() {
this.context.authenticatedUser();
// Same eager conversion as listUnapplied() -- run unconditionally (no client filter) so a
// voucher tagged Share Collection becomes visible everywhere the first time ANY Apply screen
// is opened, not only once the specific member it names happens to be looked up.
autoAdoptShareCollectionTagged();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT d.id::text AS id, d.voucher_no AS \"voucherNo\","
+ " d.account_no AS \"glAccountNo\", d.client_name AS \"glAccountName\", d.amount,"
+ " to_char(d.txn_date, 'YYYY-MM-DD') AS \"txnDate\","
+ " d.description, d.cashier_name AS \"cashierName\", NULL::bigint AS \"linkedClientId\""
+ " FROM m_cashier_deposit_request d"
+ " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED'"
+ " AND d.client_id IS NULL AND d.share_account_id IS NULL"
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"
+ " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id)"
+ " UNION ALL "
+ " SELECT 'sac-' || c.id AS id, c.voucher_no AS \"voucherNo\","
+ " (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 \"glAccountNo\","
// The GL account's own name, not the member's -- "Deposited To" names an
// account on every other row of this list, and a receipt that already names a
// member still needs its own column to say whose it is (below), not to borrow
// this one and read as though a person were a GL account.
+ " (SELECT ga.name 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 \"glAccountName\","
+ " c.amount, to_char(c.collection_date, 'YYYY-MM-DD') AS \"txnDate\","
// Visibly names whose receipt this is -- listing it here does not attribute it
// (attaching it still runs the same by-voucher lookup, which refuses a
// mismatched member), but a counter deciding whether to attach it needs to see
// that up front rather than discovering it only after clicking Attach.
+ " COALESCE(c.note, 'Share collection - Voucher ' || c.voucher_no)"
+ " || ' (linked to ' || cl.display_name || ')' AS description,"
+ " c.collected_by_name AS \"cashierName\", c.client_id AS \"linkedClientId\""
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id"
// A voucher's own free-standing collection (raised straight through POST
// /shareadvancecollection, no cashier deposit or clerk request behind it) has
// no entry-type tag at all -- there is nothing else it could be, so it always
// qualifies. One that WAS adopted from a cashier deposit or clerk disbursement
// only stays listed here while that source row is still tagged Share
// Collection today -- a voucher whose entry type reads General (or was
// corrected to General after adoption) belongs on the ordinary voucher
// register, not on this Share Collection-only list, even though the earlier
// adoption already happened and cannot itself be undone here.
+ " LEFT JOIN m_cashier_deposit_request cdr ON cdr.voucher_no = c.voucher_no"
+ " LEFT JOIN m_clerk_disbursement_request clr ON clr.voucher_no = c.voucher_no"
+ " LEFT JOIN m_code_value cdr_cv ON cdr_cv.id = cdr.entry_type_cv_id"
+ " LEFT JOIN m_code_value clr_cv ON clr_cv.id = clr.entry_type_cv_id"
+ " WHERE c.status = 'UNAPPLIED'"
+ " AND ((cdr.id IS NULL AND clr.id IS NULL)"
+ " OR COALESCE(cdr_cv.code_value, clr_cv.code_value) = 'Share Collection')"
+ " ORDER BY \"txnDate\" DESC");
final Map<String, Object> out = new LinkedHashMap<>();
out.put("rows", rows);
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// The voucher arrives as a query parameter because numbers are formatted CR/2108/0001:
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
@@ -245,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,"
+ " c.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,"
+ " (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);
@@ -306,24 +410,15 @@ public class ShareAdvanceCollectionApiResource {
@Path("/glaccounts")
public Response glAccounts() {
this.context.authenticatedUser();
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT DISTINCT ga.id, ga.gl_code, ga.name"
+ " FROM acc_gl_account ga"
+ " WHERE ga.disabled = false AND ga.account_usage = 1"
+ " AND ("
+ " EXISTS (SELECT 1 FROM m_cashier_deposit_request d WHERE d.gl_account_id = ga.id"
+ " AND d.status = 'APPROVED' AND d.share_account_id IS NULL"
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"
+ " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id))"
+ " OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request d WHERE d.gl_account_id = ga.id"
+ " AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'"
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no))"
+ " )"
+ " ORDER BY ga.gl_code");
if (rows.isEmpty()) {
rows = jdbcTemplate.queryForList(
"SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
}
// Every postable GL account, same as before this endpoint was ever narrowed to "only
// accounts that currently hold an eligible receipt" -- that narrowing meant to help the
// counter, but it back-fired the moment a genuine voucher sat in a GL account no live
// receipt query happened to match (a fee posted somewhere unusual, a receipt taken
// yesterday), silently dropping it from the list with no way to pick it by hand. The full
// list is always safe to search against; nothing here is auto-picked for the counter (see
// ngOnInit in the dialog) unless it is genuinely the only option.
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
final Map<String, Object> out = new LinkedHashMap<>();
out.put("glAccounts", rows);
return Response.ok(gson.toJson(out)).build();
@@ -458,20 +553,10 @@ public class ShareAdvanceCollectionApiResource {
final Map<String, Object> acct = loadAccount(shareAccountId);
// Every receipt belongs to the member: applying one to someone else's account would move
// one member's money into another's share capital. Checked per receipt, since a settlement
// can now carry several.
final Long acctClientId = toLong(acct.get("client_id"));
for (final Map<String, Object> row : collections) {
if (!toLong(row.get("client_id")).equals(acctClientId)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no")
+ " belongs to a different member than share account " + acct.get("account_no") + ".");
}
}
if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt "
+ chargeColl.get("receipt_no") + " belongs to a different member than share account " + acct.get("account_no") + ".");
}
// No client-match check here by deliberate choice: any receipt tagged Share Collection can
// be applied to any share account, regardless of which member it was originally recorded
// against. The counter is trusted to attach the right voucher by hand -- nothing here blocks
// it if they choose otherwise.
final int statusEnum = toInt(acct.get("status_enum"));
if (statusEnum != STATUS_ACTIVE) {
@@ -697,18 +782,20 @@ public class ShareAdvanceCollectionApiResource {
* A cashier Receipt straight into a GL account (AC.TP=GL) names no member of its own --
* buildDepositPayload() in transaction-entry.component.ts sends clientId:null for every one of
* these, deliberately, since a GL account isn't anybody's account. Such a row is NEVER
* auto-adopted, for any client, under any circumstances -- attributing it to "whichever member's
* screen happened to be open when this ran" is a coincidence of timing, not evidence of whose
* money it is, and would silently hand one member's cash to a completely unrelated member simply
* because their Apply Share screen loaded first. A clientless receipt can only ever be claimed
* through the manual by-voucher search, where a person at the counter is actively vouching for it
* against a member they can see, by quoting the specific voucher number the member is holding --
* a deliberate, checkable act, not an automatic guess. A row that DOES carry its own client_id
* (a Clerk Collection, whose client comes from the savings account it actually debited -- real
* evidence, not a guess) is still only adopted against a matching client_id, never a different
* member than the one asking.
* auto-adopted for any client -- it can only ever be claimed through the manual by-voucher search
* (including a deliberate click in the Unattributed list), where a person at the counter is
* actively vouching for it against a member they can see.
*
* A row that DOES carry its own client_id (a Clerk Collection, whose client comes from the
* savings account it actually debited -- real evidence, not a guess) is converted here
* unconditionally, regardless of who is asking: the point of this pass is that the voucher
* becomes visible and findable everywhere the moment it is tagged and approved, not only on the
* one screen that happens to belong to the member it names. Converting it does not hand it to
* anybody -- {@link #findByVoucher} still refuses to apply a receipt to an account whose client_id
* does not match, so the member it actually names is protected the same way whether this ran for
* their own screen or a different one.
*/
private void autoAdoptShareCollectionTagged(final Long clientIdFilter) {
private void autoAdoptShareCollectionTagged() {
final List<Map<String, Object>> deposits = jdbcTemplate.queryForList(
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.office_id, d.currency_code, d.txn_date, d.txn_id,"
+ " d.cashier_id, d.cashier_name, d.description"
@@ -720,11 +807,6 @@ public class ShareAdvanceCollectionApiResource {
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)");
for (final Map<String, Object> dep : deposits) {
final Long resolvedClientId = toLong(dep.get("client_id"));
// Guaranteed non-null by the WHERE clause above, but a genuine client_id must still match
// who is asking -- never adopted for a different member than the one whose screen it is.
if (clientIdFilter != null && !clientIdFilter.equals(resolvedClientId)) {
continue;
}
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date
? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
@@ -759,9 +841,6 @@ public class ShareAdvanceCollectionApiResource {
}
final Long memberClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
savingsAccountId);
if (clientIdFilter != null && !clientIdFilter.equals(memberClientId)) {
continue;
}
final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(memberClientId);
final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR";
final Object actionedAt = req.get("actioned_at");
@@ -799,7 +878,7 @@ public class ShareAdvanceCollectionApiResource {
final List<Map<String, Object>> deposits = jdbcTemplate
.queryForList(
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id,"
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id, d.description,"
+ " cv.code_value AS entry_type"
+ " FROM m_cashier_deposit_request d"
+ " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
@@ -816,16 +895,19 @@ public class ShareAdvanceCollectionApiResource {
"Voucher " + voucherNo + " has already been applied to a share account.");
}
// The counter tags what a GL receipt was actually collected for at save time, from the
// GLEntryType code (Admin > System > Manage Codes), precisely so a manager here can never
// pick up money that was really meant for something else (a Demand Draft, say) just because
// it happens to sit in the same GL account. "General" and a missing tag (pre-existing rows
// from before this tag existed) are still adoptable -- only an explicit, different purpose
// blocks it.
// GLEntryType code (Admin > System > Manage Codes). Exact match required -- "General" and an
// untagged row used to be let through too, on the reasoning that untyped money carries no
// evidence of a conflicting purpose. That reasoning cuts both ways: untyped money is exactly
// as often share-collection cash the counter forgot to tag, and letting it through here
// quietly rewarded skipping the tag. A voucher only ever becomes reachable for a share
// collection by being tagged for one, on purpose, at save time.
final String depositEntryType = (String) dep.get("entry_type");
if (depositEntryType != null && !"General".equals(depositEntryType) && !"Share Collection".equals(depositEntryType)) {
if (!"Share Collection".equals(depositEntryType)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
"Voucher " + voucherNo + " was recorded as " + depositEntryType + ", not a share collection. "
+ "Choose a different voucher, or ask the cashier to correct the entry type.");
"Voucher " + voucherNo + " was recorded as "
+ (depositEntryType == null || depositEntryType.isBlank() ? "an untagged entry" : depositEntryType)
+ ", not Share Collection. Ask the cashier to correct the entry type at Cashier › Transaction Entry, "
+ "then search again.");
}
if (!jdbcTemplate.queryForList("SELECT id FROM m_sponsor_dd_issue_request WHERE cashier_deposit_request_id = ?",
toLong(dep.get("id"))).isEmpty()) {
@@ -846,18 +928,32 @@ public class ShareAdvanceCollectionApiResource {
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
// What the cashier actually typed at save time, when they typed anything -- same preference
// as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by
// auto-adoption or by this manual by-voucher lookup. Falls back to a plain label only for a
// deposit nobody described.
final String depDescription = (String) dep.get("description");
final String note = depDescription != null && !depDescription.isBlank() ? depDescription
: "Adopted from cashier deposit voucher " + voucherNo;
final String receiptNo = nextReceiptNo();
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency,
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"),
"Adopted from cashier deposit voucher " + voucherNo);
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"), note);
return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.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);
}
@@ -892,12 +988,15 @@ public class ShareAdvanceCollectionApiResource {
final Map<String, Object> req = requests.get(0);
// Same purpose guard as the cashier deposit adoption above -- a COLLECTION credits a GL
// account the counter picked by hand, so it carries the identical ambiguity.
// account the clerk picked by hand, so it carries the identical ambiguity, and the identical
// exact-match requirement: a voucher only becomes reachable for a share collection by being
// tagged Share Collection at save time, "General" and untagged included.
final String reqEntryType = (String) req.get("entry_type");
if (reqEntryType != null && !"General".equals(reqEntryType) && !"Share Collection".equals(reqEntryType)) {
if (!"Share Collection".equals(reqEntryType)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
"Voucher " + voucherNo + " was recorded as " + reqEntryType + ", not a share collection. "
+ "Choose a different voucher, or ask the clerk to correct the entry type.");
"Voucher " + voucherNo + " was recorded as "
+ (reqEntryType == null || reqEntryType.isBlank() ? "an untagged entry" : reqEntryType)
+ ", not Share Collection. Ask the clerk to correct the entry type on this request, then search again.");
}
final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
@@ -930,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,"
+ " c.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);
@@ -38,11 +38,18 @@ import org.springframework.stereotype.Service;
/**
* Central voucher register (m_voucher). Every maker-checker cash transaction
* (clerk withdrawal request, cashier deposit request) gets a voucher number in
* the form PREFIX/DDMM/NNNN — for example CR/2108/0001 for a deposit and
* DR/2108/0001 for a withdrawal on 21 August.
* the form PREFIX/DDMMYYYY/NNNN — for example CR/21082026/0001 for a deposit and
* DR/21082026/0001 for a withdrawal on 21 August 2026.
*
* The trailing counter restarts at 0001 every day, independently for each
* prefix, so a deposit and a withdrawal on the same day never share a number.
* The year is part of the printed number (not just of the underlying
* m_voucher_sequence.series_date counter) precisely so the same calendar
* day-of-year in two different years can never produce the same voucher_no
* text — every lookup and dedup in this system (share-advance adoption,
* "already applied" checks, unattributed-receipt listings) matches on that
* text, so a same-text collision a year apart would silently mismatch two
* completely unrelated vouchers.
* The series is bank-wide rather than per-branch: the number names the slip
* side and the day, and nothing else. The office that raised the voucher is
* still recorded, in m_voucher.branch_office_id, so a per-branch register
@@ -108,7 +115,7 @@ public class VoucherService {
/** A savings movement, keyed by its m_savings_account_transaction id. */
public static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION";
private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMM");
private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("ddMMyyyy");
private final JdbcTemplate jdbcTemplate;
@@ -188,7 +195,7 @@ public class VoucherService {
/**
* Creates a voucher row and returns the formatted voucher number, e.g.
* "CR/2108/0001". The office that handled the cash is taken from the acting
* "CR/21082026/0001". The office that handled the cash is taken from the acting
* user rather than from any branch the screen happens to have selected, and
* is stored alongside the voucher instead of being printed into its number.
*/
@@ -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>