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 // 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 // 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. // 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," final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ " cl.display_name AS client_name, " + " 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 // 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 // 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. // 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 — 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: // 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 // 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(); this.context.authenticatedUser();
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
// gl_code carries the same subquery listUnapplied() uses -- the GL Account picker on screen
// follows whichever receipt gets ticked (see toggleVoucher()/toggle() on the client), and a
// receipt resolved through this search must feed that picker exactly the same as one resolved
// from the member's own list, or the picker is silently left on "Select GL account…" for
// every voucher found this way.
final List<Map<String, Object>> rows = jdbcTemplate final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," .queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " 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,"
+ " c.applied_share_account_id " + " c.applied_share_account_id,"
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ " ORDER BY je.id LIMIT 1) AS gl_code"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id "
+ " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo); + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
@@ -306,24 +410,15 @@ public class ShareAdvanceCollectionApiResource {
@Path("/glaccounts") @Path("/glaccounts")
public Response glAccounts() { public Response glAccounts() {
this.context.authenticatedUser(); this.context.authenticatedUser();
List<Map<String, Object>> rows = jdbcTemplate.queryForList( // Every postable GL account, same as before this endpoint was ever narrowed to "only
"SELECT DISTINCT ga.id, ga.gl_code, ga.name" // accounts that currently hold an eligible receipt" -- that narrowing meant to help the
+ " FROM acc_gl_account ga" // counter, but it back-fired the moment a genuine voucher sat in a GL account no live
+ " WHERE ga.disabled = false AND ga.account_usage = 1" // receipt query happened to match (a fee posted somewhere unusual, a receipt taken
+ " AND (" // yesterday), silently dropping it from the list with no way to pick it by hand. The full
+ " EXISTS (SELECT 1 FROM m_cashier_deposit_request d WHERE d.gl_account_id = ga.id" // list is always safe to search against; nothing here is auto-picked for the counter (see
+ " AND d.status = 'APPROVED' AND d.share_account_id IS NULL" // ngOnInit in the dialog) unless it is genuinely the only option.
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)" final List<Map<String, Object>> rows = jdbcTemplate
+ " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id))" .queryForList("SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
+ " 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");
}
final Map<String, Object> out = new LinkedHashMap<>(); final Map<String, Object> out = new LinkedHashMap<>();
out.put("glAccounts", rows); out.put("glAccounts", rows);
return Response.ok(gson.toJson(out)).build(); return Response.ok(gson.toJson(out)).build();
@@ -458,20 +553,10 @@ public class ShareAdvanceCollectionApiResource {
final Map<String, Object> acct = loadAccount(shareAccountId); final Map<String, Object> acct = loadAccount(shareAccountId);
// Every receipt belongs to the member: applying one to someone else's account would move // No client-match check here by deliberate choice: any receipt tagged Share Collection can
// one member's money into another's share capital. Checked per receipt, since a settlement // be applied to any share account, regardless of which member it was originally recorded
// can now carry several. // against. The counter is trusted to attach the right voucher by hand -- nothing here blocks
final Long acctClientId = toLong(acct.get("client_id")); // it if they choose otherwise.
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") + ".");
}
final int statusEnum = toInt(acct.get("status_enum")); final int statusEnum = toInt(acct.get("status_enum"));
if (statusEnum != STATUS_ACTIVE) { 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 -- * 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 * 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 * 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 * auto-adopted for any client -- it can only ever be claimed through the manual by-voucher search
* screen happened to be open when this ran" is a coincidence of timing, not evidence of whose * (including a deliberate click in the Unattributed list), where a person at the counter is
* money it is, and would silently hand one member's cash to a completely unrelated member simply * actively vouching for it against a member they can see.
* 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 * A row that DOES carry its own client_id (a Clerk Collection, whose client comes from the
* against a member they can see, by quoting the specific voucher number the member is holding -- * savings account it actually debited -- real evidence, not a guess) is converted here
* a deliberate, checkable act, not an automatic guess. A row that DOES carry its own client_id * unconditionally, regardless of who is asking: the point of this pass is that the voucher
* (a Clerk Collection, whose client comes from the savings account it actually debited -- real * becomes visible and findable everywhere the moment it is tagged and approved, not only on the
* evidence, not a guess) is still only adopted against a matching client_id, never a different * one screen that happens to belong to the member it names. Converting it does not hand it to
* member than the one asking. * 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( 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," "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" + " 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)"); + " 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) { for (final Map<String, Object> dep : deposits) {
final Long resolvedClientId = toLong(dep.get("client_id")); 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 final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date
? ((java.sql.Date) dep.get("txn_date")).toLocalDate() ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); : 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, final Long memberClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class,
savingsAccountId); savingsAccountId);
if (clientIdFilter != null && !clientIdFilter.equals(memberClientId)) {
continue;
}
final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(memberClientId); 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 String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR";
final Object actionedAt = req.get("actioned_at"); final Object actionedAt = req.get("actioned_at");
@@ -799,7 +878,7 @@ public class ShareAdvanceCollectionApiResource {
final List<Map<String, Object>> deposits = jdbcTemplate final List<Map<String, Object>> deposits = jdbcTemplate
.queryForList( .queryForList(
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id," "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" + " cv.code_value AS entry_type"
+ " FROM m_cashier_deposit_request d" + " FROM m_cashier_deposit_request d"
+ " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id" + " 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."); "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 // 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 // GLEntryType code (Admin > System > Manage Codes). Exact match required -- "General" and an
// pick up money that was really meant for something else (a Demand Draft, say) just because // untagged row used to be let through too, on the reasoning that untyped money carries no
// it happens to sit in the same GL account. "General" and a missing tag (pre-existing rows // evidence of a conflicting purpose. That reasoning cuts both ways: untyped money is exactly
// from before this tag existed) are still adoptable -- only an explicit, different purpose // as often share-collection cash the counter forgot to tag, and letting it through here
// blocks it. // 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"); 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", throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
"Voucher " + voucherNo + " was recorded as " + depositEntryType + ", not a share collection. " "Voucher " + voucherNo + " was recorded as "
+ "Choose a different voucher, or ask the cashier to correct the entry type."); + (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 = ?", if (!jdbcTemplate.queryForList("SELECT id FROM m_sponsor_dd_issue_request WHERE cashier_deposit_request_id = ?",
toLong(dep.get("id"))).isEmpty()) { 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() final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); : 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(); final String receiptNo = nextReceiptNo();
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO m_share_advance_collection " "INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, " + "(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',?,?,?,?)", + " 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, 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"), dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"), note);
"Adopted from cashier deposit voucher " + voucherNo);
return jdbcTemplate.queryForMap( return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " 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 = ?", + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo); receiptNo);
} }
@@ -892,12 +988,15 @@ public class ShareAdvanceCollectionApiResource {
final Map<String, Object> req = requests.get(0); final Map<String, Object> req = requests.get(0);
// Same purpose guard as the cashier deposit adoption above -- a COLLECTION credits a GL // 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"); 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", throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose",
"Voucher " + voucherNo + " was recorded as " + reqEntryType + ", not a share collection. " "Voucher " + voucherNo + " was recorded as "
+ "Choose a different voucher, or ask the clerk to correct the entry type."); + (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")); final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries"));
@@ -930,7 +1029,14 @@ public class ShareAdvanceCollectionApiResource {
return jdbcTemplate.queryForMap( return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " 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 = ?", + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo); receiptNo);
} }
@@ -746,8 +746,14 @@ public class ClerkDisbursementRequestApiResource {
} else { } else {
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1); final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
if (cashVaultGlId != null) { if (cashVaultGlId != null) {
postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal, approvedGlTxnId = postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
officeId, currencyCode, txnDateStr, description); officeId, currencyCode, txnDateStr, description);
} else if (!withdrawalSavingsTxnRefs.isEmpty()) {
// No bridging entry was needed: the withdrawal's own auto-posted credit
// leg already lands on the target GL directly (the savings product's own
// payment-type mapping points there). That withdrawal's own transaction is
// therefore the real posting -- there is no second one to remember instead.
approvedGlTxnId = "S" + withdrawalSavingsTxnRefs.get(0);
} }
} }
} }
@@ -1440,7 +1446,15 @@ public class ClerkDisbursementRequestApiResource {
return null; return null;
} }
private void postGlOffsetEntry( /**
* @return the real {@code acc_gl_journal_entry.transaction_id} this posts, so a caller that needs
* to remember which posting was theirs — the collection fallback below, most importantly
* — is not left with nothing to record. Returning it here rather than discarding it, as
* this method used to, was the entire reason {@code gl_transaction_id} stayed blank on
* every clerk collection that fell back to this path: nothing downstream (Share Advance
* Collection's GL-detail popup among them) had anything to look the posting up by.
*/
private String postGlOffsetEntry(
final Long drGlAccountId, final Long crGlAccountId, final Long drGlAccountId, final Long crGlAccountId,
final BigDecimal amount, final Long officeId, final BigDecimal amount, final Long officeId,
final String currencyCode, final String txnDateStr, final String comments) { final String currencyCode, final String txnDateStr, final String comments) {
@@ -1476,7 +1490,9 @@ public class ClerkDisbursementRequestApiResource {
.build(); .build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null); fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
// The disbursement register raises its own voucher; suppress the journal command's own. // The disbursement register raises its own voucher; suppress the journal command's own.
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd)); final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
return result != null ? result.getTransactionId() : null;
} }
// ── Combined GL entry helpers ───────────────────────────────────────────── // ── Combined GL entry helpers ─────────────────────────────────────────────
@@ -0,0 +1,496 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.shareaccounts.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Share transfer between two members.
*
* <p>
* A transfer is not a sale to the society and a purchase from it — no money reaches the society and
* its share capital does not change, so no GL entry is posted. What changes is the register: one
* member's holding falls, another's rises, by the same number on the same day at the same price.
* That is recorded as the two share transactions the rest of the module already understands
* (a redemption on the transferor, a purchase on the transferee), tied together by the transfer row
* so a later reader can tell it was one act rather than two unrelated dealings.
*
* <p>
* Maker-checker throughout: a transfer moves ownership and with it voting rights, which a
* co-operative does not leave to one clerk. Nothing moves until a checker approves, and every rule
* is re-checked at approval rather than trusted from submission — holdings move while a request
* waits, and a transfer that was valid on Monday can be impossible by Wednesday.
*/
@Path("/v1/sharetransfers")
@Component
@Tag(name = "Share Transfers", description = "Transfer of shares from one member to another, under board resolution.")
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareTransferApiResource {
private static final int TYPE_PURCHASED = 500;
private static final int TYPE_REDEEMED = 600;
private static final int TXN_APPROVED = 300;
private static final int ACCOUNT_ACTIVE = 300;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ══════════════════════════════════════════════════════════════════════
// POST — a clerk records a transfer for approval
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
@Operation(summary = "Submit a share transfer for approval",
description = "Records a transfer of shares between two members. Nothing moves until a checker approves it.")
public Response submit(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
final JsonObject json = JsonParser.parseString(body == null || body.isBlank() ? "{}" : body).getAsJsonObject();
final Long fromAccountId = getLong(json, "fromShareAccountId");
final Long toAccountId = getLong(json, "toShareAccountId");
final Long shares = getLong(json, "shares");
final String transferDate = getString(json, "transferDate");
final String resolutionNumber = getString(json, "resolutionNumber");
final String meetingDate = getString(json, "meetingDate");
final String reason = getString(json, "reason");
if (fromAccountId == null || toAccountId == null) {
throw badRequest("error.msg.sharetransfer.accounts.required", "Both the transferring and receiving share accounts are required.");
}
if (shares == null || shares <= 0) {
throw badRequest("error.msg.sharetransfer.shares.invalid", "The number of shares to transfer must be greater than zero.");
}
// A co-operative transfers shares under a board resolution, so the resolution is part of the
// request rather than something captured afterwards — a transfer with no resolution behind it
// is the finding an auditor writes up.
if (resolutionNumber == null || resolutionNumber.isBlank()) {
throw badRequest("error.msg.sharetransfer.resolution.required",
"The board resolution authorising this transfer is required.");
}
if (meetingDate == null || meetingDate.isBlank()) {
throw badRequest("error.msg.sharetransfer.meeting.date.required", "The board meeting date is required.");
}
final LocalDate effectiveDate = transferDate == null || transferDate.isBlank()
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
: LocalDate.parse(transferDate);
final Map<String, Object> from = loadAccount(fromAccountId, "transferring");
final Map<String, Object> to = loadAccount(toAccountId, "receiving");
validateTransfer(from, to, shares);
final BigDecimal unitPrice = toDecimal(from.get("unit_price"));
final BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(shares));
final String reference = nextReference();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final Long id = this.jdbcTemplate.queryForObject("""
INSERT INTO m_share_transfer
(transfer_reference, from_share_account_id, to_share_account_id, shares, unit_price, transfer_amount,
transfer_date, resolution_number, meeting_date, reason, status, maker_id, maker_name, submitted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,'PENDING',?,?,?)
RETURNING id
""", Long.class, reference, fromAccountId, toAccountId, shares, unitPrice, amount,
java.sql.Date.valueOf(effectiveDate), resolutionNumber, java.sql.Date.valueOf(LocalDate.parse(meetingDate)), reason,
currentUser.getId(), currentUser.getDisplayName(), java.sql.Timestamp.valueOf(now));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", id);
out.put("transferReference", reference);
out.put("status", "PENDING");
out.put("shares", shares);
out.put("amount", amount);
out.put("message", "Transfer " + reference + " recorded and sent for approval. Nothing moves until it is approved.");
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — the transfer register
// ══════════════════════════════════════════════════════════════════════
@GET
@Operation(summary = "Share transfer register",
description = "Every share transfer with both members, the shares moved and the resolution behind it.")
public Response list(@QueryParam("status") final String status) {
this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("""
SELECT st.id AS "transferId",
st.transfer_reference AS "transferReference",
to_char(st.transfer_date, 'YYYY-MM-DD') AS "transferDate",
st.shares AS "shares",
st.unit_price AS "unitPrice",
st.transfer_amount AS "transferAmount",
st.status AS "status",
st.resolution_number AS "resolutionNumber",
to_char(st.meeting_date, 'YYYY-MM-DD') AS "meetingDate",
st.reason AS "reason",
st.maker_name AS "makerName",
to_char(st.submitted_at, 'YYYY-MM-DD') AS "submittedOn",
st.checker_name AS "checkerName",
to_char(st.actioned_at, 'YYYY-MM-DD') AS "actionedOn",
st.rejection_reason AS "rejectionReason",
fa.id AS "fromShareAccountId",
fa.account_no AS "fromAccountNo",
fc.id AS "fromClientId",
fc.display_name AS "fromClientName",
fc.account_no AS "fromCifNumber",
ta.id AS "toShareAccountId",
ta.account_no AS "toAccountNo",
tc.id AS "toClientId",
tc.display_name AS "toClientName",
tc.account_no AS "toCifNumber",
sp.name AS "productName",
fa.currency_code AS "currencyCode"
FROM m_share_transfer st
JOIN m_share_account fa ON fa.id = st.from_share_account_id
JOIN m_client fc ON fc.id = fa.client_id
JOIN m_share_account ta ON ta.id = st.to_share_account_id
JOIN m_client tc ON tc.id = ta.client_id
LEFT JOIN m_share_product sp ON sp.id = fa.product_id
WHERE 1 = 1
""");
final List<Object> args = new ArrayList<>();
if (status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status)) {
sql.append(" AND st.status = ? ");
args.add(status);
}
sql.append(" ORDER BY st.submitted_at DESC, st.id DESC ");
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
long transferredShares = 0L;
BigDecimal transferredValue = BigDecimal.ZERO;
int pending = 0;
for (final Map<String, Object> row : rows) {
if ("APPROVED".equals(row.get("status"))) {
transferredShares += toLong(row.get("shares"));
transferredValue = transferredValue.add(toDecimal(row.get("transferAmount")));
} else if ("PENDING".equals(row.get("status"))) {
pending++;
}
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("rows", rows);
final Map<String, Object> totals = new LinkedHashMap<>();
totals.put("transferCount", rows.size());
totals.put("pendingCount", pending);
totals.put("sharesTransferred", transferredShares);
totals.put("valueTransferred", transferredValue);
out.put("totals", totals);
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST /{id}/approve — the transfer actually happens here
// ══════════════════════════════════════════════════════════════════════
@POST
@Path("/{transferId}/approve")
@Transactional
@Operation(summary = "Approve a share transfer",
description = "Re-checks every rule against today's holdings, then moves the shares: a redemption on the transferor and a purchase on the transferee.")
public Response approve(@PathParam("transferId") final Long transferId) {
final AppUser currentUser = this.context.authenticatedUser();
final Map<String, Object> transfer = loadTransfer(transferId);
if (!"PENDING".equals(transfer.get("status"))) {
throw badRequest("error.msg.sharetransfer.not.pending",
"Transfer " + transfer.get("transfer_reference") + " is already " + transfer.get("status") + ".");
}
final Long fromAccountId = toLong(transfer.get("from_share_account_id"));
final Long toAccountId = toLong(transfer.get("to_share_account_id"));
final long shares = toLong(transfer.get("shares"));
// Re-checked, not trusted: holdings move while a request waits, and a transfer that was
// valid when it was raised can be impossible by the time it reaches a checker.
final Map<String, Object> from = loadAccount(fromAccountId, "transferring");
final Map<String, Object> to = loadAccount(toAccountId, "receiving");
validateTransfer(from, to, shares);
final BigDecimal unitPrice = toDecimal(transfer.get("unit_price"));
final BigDecimal amount = toDecimal(transfer.get("transfer_amount"));
final java.sql.Date effectiveDate = (java.sql.Date) transfer.get("transfer_date");
final Object meetingDate = transfer.get("meeting_date");
final String resolutionNumber = (String) transfer.get("resolution_number");
// The transferor is redeemed and the transferee purchases — the same two movements the rest
// of the share module already reads, so every balance, statement and report picks this up
// without knowing transfers exist.
final Long fromTxnId = this.jdbcTemplate.queryForObject("""
INSERT INTO m_share_account_transactions
(account_id, transaction_date, total_shares, unit_price, amount, status_enum, type_enum, is_active,
resolution_number, meeting_date)
VALUES (?,?,?,?,?,?,?,true,?,?)
RETURNING id
""", Long.class, fromAccountId, effectiveDate, shares, unitPrice, amount, TXN_APPROVED, TYPE_REDEEMED,
resolutionNumber, meetingDate);
final Long toTxnId = this.jdbcTemplate.queryForObject("""
INSERT INTO m_share_account_transactions
(account_id, transaction_date, total_shares, unit_price, amount, status_enum, type_enum, is_active,
resolution_number, meeting_date)
VALUES (?,?,?,?,?,?,?,true,?,?)
RETURNING id
""", Long.class, toAccountId, effectiveDate, shares, unitPrice, amount, TXN_APPROVED, TYPE_PURCHASED,
resolutionNumber, meetingDate);
// The account's own running total is kept in step with the transactions just written, since
// the rest of the platform reads it directly.
this.jdbcTemplate.update(
"UPDATE m_share_account SET total_approved_shares = COALESCE(total_approved_shares, 0) - ? WHERE id = ?", shares,
fromAccountId);
this.jdbcTemplate.update(
"UPDATE m_share_account SET total_approved_shares = COALESCE(total_approved_shares, 0) + ? WHERE id = ?", shares,
toAccountId);
this.jdbcTemplate.update("""
UPDATE m_share_transfer SET status = 'APPROVED', from_transaction_id = ?, to_transaction_id = ?,
checker_id = ?, checker_name = ?, actioned_at = ?
WHERE id = ?
""", fromTxnId, toTxnId, currentUser.getId(), currentUser.getDisplayName(),
java.sql.Timestamp.valueOf(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())), transferId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", transferId);
out.put("transferReference", transfer.get("transfer_reference"));
out.put("status", "APPROVED");
out.put("shares", shares);
out.put("amount", amount);
out.put("fromTransactionId", fromTxnId);
out.put("toTransactionId", toTxnId);
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST /{id}/reject
// ══════════════════════════════════════════════════════════════════════
@POST
@Path("/{transferId}/reject")
@Transactional
@Operation(summary = "Reject a share transfer")
public Response reject(@PathParam("transferId") final Long transferId, final String body) {
final AppUser currentUser = this.context.authenticatedUser();
final JsonObject json = JsonParser.parseString(body == null || body.isBlank() ? "{}" : body).getAsJsonObject();
final Map<String, Object> transfer = loadTransfer(transferId);
if (!"PENDING".equals(transfer.get("status"))) {
throw badRequest("error.msg.sharetransfer.not.pending",
"Transfer " + transfer.get("transfer_reference") + " is already " + transfer.get("status") + ".");
}
this.jdbcTemplate.update("""
UPDATE m_share_transfer SET status = 'REJECTED', rejection_reason = ?, checker_id = ?, checker_name = ?,
actioned_at = ?
WHERE id = ?
""", getString(json, "rejectionReason"), currentUser.getId(), currentUser.getDisplayName(),
java.sql.Timestamp.valueOf(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())), transferId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", transferId);
out.put("status", "REJECTED");
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
/**
* Every rule a transfer has to satisfy, applied identically at submission and at approval.
*
* <p>
* Holdings are derived from the transactions rather than read off the account's running total,
* for the same reason the balance report does it: the total is what the account currently says,
* and this needs what the movements actually add up to.
*/
private void validateTransfer(final Map<String, Object> from, final Map<String, Object> to, final long shares) {
final Long fromAccountId = toLong(from.get("id"));
final Long toAccountId = toLong(to.get("id"));
if (fromAccountId.equals(toAccountId)) {
throw badRequest("error.msg.sharetransfer.same.account", "A share account cannot transfer to itself.");
}
if (toLong(from.get("client_id")) == toLong(to.get("client_id"))) {
throw badRequest("error.msg.sharetransfer.same.member",
"Both accounts belong to the same member, so there is nothing to transfer between.");
}
// Different share classes carry different rights and prices; moving shares across them would
// be an issue and a redemption, not a transfer.
if (toLong(from.get("product_id")) != toLong(to.get("product_id"))) {
throw badRequest("error.msg.sharetransfer.product.mismatch",
"Shares can only be transferred within the same share class. " + from.get("product_name") + " and "
+ to.get("product_name") + " are different classes.");
}
if (toInt(from.get("status_enum")) != ACCOUNT_ACTIVE) {
throw badRequest("error.msg.sharetransfer.from.not.active",
"Share account " + from.get("account_no") + " is not active, so it cannot transfer shares.");
}
if (toInt(to.get("status_enum")) != ACCOUNT_ACTIVE) {
throw badRequest("error.msg.sharetransfer.to.not.active",
"Share account " + to.get("account_no") + " is not active, so it cannot receive shares.");
}
final long fromHeld = heldShares(fromAccountId);
final long toHeld = heldShares(toAccountId);
if (fromHeld < shares) {
throw badRequest("error.msg.sharetransfer.insufficient.shares", "Share account " + from.get("account_no") + " holds "
+ fromHeld + " shares, which is fewer than the " + shares + " being transferred.");
}
final Long maximum = toLongOrNull(to.get("maximum_client_shares"));
if (maximum != null && toHeld + shares > maximum) {
throw badRequest("error.msg.sharetransfer.above.maximum",
"The transfer would leave " + to.get("to_client_name") + " holding " + (toHeld + shares)
+ " shares, above the " + maximum + " this share class allows one member.");
}
// A transferor may go to nil — that is a member leaving — but may not be left holding less
// than the class's minimum, which is a membership the society cannot recognise.
final Long minimum = toLongOrNull(from.get("minimum_client_shares"));
final long remaining = fromHeld - shares;
if (minimum != null && remaining > 0 && remaining < minimum) {
throw badRequest("error.msg.sharetransfer.below.minimum",
"The transfer would leave " + from.get("account_no") + " holding " + remaining + " shares, below the "
+ minimum + " minimum this share class requires. Transfer all " + fromHeld
+ " shares, or leave at least " + minimum + ".");
}
}
/**
* What the account's settled movements actually add up to, ignoring its running total.
*
* <p>
* Only paid-up shares can be transferred. An opening tranche whose collection has never been
* applied is shares allotted against money the society never received — the member does not yet
* own them to give away, and transferring them would move an unpaid holding onto somebody else's
* name. The account's own {@code total_approved_shares} counts them, which is exactly why this
* walks the transactions instead.
*/
private long heldShares(final Long shareAccountId) {
final Long held = this.jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN t.type_enum = ? THEN COALESCE(t.total_shares, 0) ELSE 0 END)
- SUM(CASE WHEN t.type_enum = ? THEN COALESCE(t.total_shares, 0) ELSE 0 END), 0)
FROM m_share_account_transactions t
JOIN m_share_account pa ON pa.id = t.account_id
WHERE t.account_id = ? AND t.is_active = true AND t.status_enum = ? AND t.type_enum IN (?, ?)
AND (t.type_enum = ? OR t.funding_source IS NOT NULL OR pa.collection_applied = true)
""", Long.class, TYPE_PURCHASED, TYPE_REDEEMED, shareAccountId, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED,
TYPE_REDEEMED);
return held == null ? 0L : held;
}
private Map<String, Object> loadAccount(final Long shareAccountId, final String side) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code,
c.display_name AS to_client_name, c.account_no AS cif_number,
sp.name AS product_name, sp.unit_price, sp.minimum_client_shares, sp.maximum_client_shares
FROM m_share_account sa
JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_share_product sp ON sp.id = sa.product_id
WHERE sa.id = ?
""", shareAccountId);
if (rows.isEmpty()) {
throw badRequest("error.msg.sharetransfer.account.not.found",
"The " + side + " share account " + shareAccountId + " was not found.");
}
return rows.get(0);
}
private Map<String, Object> loadTransfer(final Long transferId) {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT * FROM m_share_transfer WHERE id = ?", transferId);
if (rows.isEmpty()) {
throw badRequest("error.msg.sharetransfer.not.found", "Transfer " + transferId + " was not found.");
}
return rows.get(0);
}
/** TRF-YYMMDD-NNNN, dated and serial, so a reference says when it was raised. */
private String nextReference() {
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String prefix = "TRF-" + today.format(java.time.format.DateTimeFormatter.ofPattern("yyMMdd")) + "-";
final Integer used = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_transfer WHERE transfer_reference LIKE ?", Integer.class, prefix + "%");
return prefix + String.format("%04d", (used == null ? 0 : used) + 1);
}
private PlatformDataIntegrityException badRequest(final String code, final String message) {
return new PlatformDataIntegrityException(code, message);
}
private Long getLong(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : null;
}
private String getString(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
}
private long toLong(final Object value) {
return value instanceof Number number ? number.longValue() : 0L;
}
private Long toLongOrNull(final Object value) {
return value instanceof Number number ? number.longValue() : null;
}
private int toInt(final Object value) {
return value instanceof Number number ? number.intValue() : 0;
}
private BigDecimal toDecimal(final Object value) {
if (value instanceof BigDecimal decimal) {
return decimal;
}
return value instanceof Number number ? BigDecimal.valueOf(number.doubleValue()) : BigDecimal.ZERO;
}
}
@@ -748,6 +748,16 @@ public class ShareAccountDataSerializer {
if (!account.status().equals(ShareAccountStatusType.ACTIVE.getValue())) { if (!account.status().equals(ShareAccountStatusType.ACTIVE.getValue())) {
baseDataValidator.failWithCodeNoParameterAddedToErrorCode("is.not.in.active.state"); baseDataValidator.failWithCodeNoParameterAddedToErrorCode("is.not.in.active.state");
} }
// An Active account can still be an unpaid application -- activation posts no GL of its own,
// only Apply Collection does. A further tranche on top of a first allotment nobody has paid
// for yet would grow a holding that does not exist: settle the original application first,
// then add to it. Read straight from the column rather than through the entity, which does
// not map it -- collection_applied lives outside JPA, set only by the collection endpoints.
final Boolean collectionApplied = this.jdbcTemplate
.queryForObject("SELECT collection_applied FROM m_share_account WHERE id = ?", Boolean.class, account.getId());
if (!Boolean.TRUE.equals(collectionApplied)) {
baseDataValidator.failWithCodeNoParameterAddedToErrorCode("original.application.not.yet.settled");
}
LocalDate requestedDate = this.fromApiJsonHelper.extractLocalDateNamed(ShareAccountApiConstants.requesteddate_paramname, element); LocalDate requestedDate = this.fromApiJsonHelper.extractLocalDateNamed(ShareAccountApiConstants.requesteddate_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname).value(requestedDate).notNull(); baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname).value(requestedDate).notNull();
final Long sharesRequested = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element); final Long sharesRequested = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element);
@@ -38,11 +38,18 @@ import org.springframework.stereotype.Service;
/** /**
* Central voucher register (m_voucher). Every maker-checker cash transaction * Central voucher register (m_voucher). Every maker-checker cash transaction
* (clerk withdrawal request, cashier deposit request) gets a voucher number in * (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 * the form PREFIX/DDMMYYYY/NNNN — for example CR/21082026/0001 for a deposit and
* DR/2108/0001 for a withdrawal on 21 August. * DR/21082026/0001 for a withdrawal on 21 August 2026.
* *
* The trailing counter restarts at 0001 every day, independently for each * 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. * 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 * 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 * 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 * 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. */ /** A savings movement, keyed by its m_savings_account_transaction id. */
public static final String REF_SAVINGS_TRANSACTION = "SAVINGS_TRANSACTION"; 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; private final JdbcTemplate jdbcTemplate;
@@ -188,7 +195,7 @@ public class VoucherService {
/** /**
* Creates a voucher row and returns the formatted voucher number, e.g. * 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 * 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. * 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/0564_add_entry_type_to_gl_requests.xml" relativeToChangelogFile="true"/>
<include file="parts/0565_convert_entry_type_to_code_value.xml" relativeToChangelogFile="true"/> <include file="parts/0565_convert_entry_type_to_code_value.xml" relativeToChangelogFile="true"/>
<include file="parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml" relativeToChangelogFile="true"/> <include file="parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0567_create_share_transfer.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Share transfer between members.
A transfer is two share transactions that must happen together or not at all: the transferor
is redeemed and the transferee purchases, same shares, same day, same price. Recording only
those two rows would leave nothing saying they were one act, so a later reader sees a member
who sold and, separately, a member who bought, with no way to tell it was a transfer rather
than two unrelated dealings.
m_share_transfer is that missing act. It also carries what a co-operative needs a transfer to
have but a share transaction has nowhere to put: the board resolution that approved it, the
reason, and the maker-checker trail, since a transfer moves ownership and voting rights
between two members and cannot be a single clerk's decision.
No GL entry is involved and none is recorded here: share capital does not change when it
moves between two members, only the register does.
-->
<changeSet id="0567_create_share_transfer" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_share_transfer"/></not>
</preConditions>
<createTable tableName="m_share_transfer">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_reference" type="VARCHAR(40)">
<constraints nullable="false" unique="true"/>
</column>
<column name="from_share_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="to_share_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="shares" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="unit_price" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="transfer_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="transfer_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="resolution_number" type="VARCHAR(100)"/>
<column name="meeting_date" type="DATE"/>
<column name="reason" type="VARCHAR(500)"/>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<!-- The two share transactions this transfer became, written only on approval. -->
<column name="from_transaction_id" type="BIGINT"/>
<column name="to_transaction_id" type="BIGINT"/>
<column name="maker_id" type="BIGINT"/>
<column name="maker_name" type="VARCHAR(150)"/>
<column name="submitted_at" type="TIMESTAMP"/>
<column name="checker_id" type="BIGINT"/>
<column name="checker_name" type="VARCHAR(150)"/>
<column name="actioned_at" type="TIMESTAMP"/>
<column name="rejection_reason" type="VARCHAR(500)"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_share_transfer" baseColumnNames="from_share_account_id"
constraintName="fk_share_transfer_from_account" referencedTableName="m_share_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_share_transfer" baseColumnNames="to_share_account_id"
constraintName="fk_share_transfer_to_account" referencedTableName="m_share_account" referencedColumnNames="id"/>
</changeSet>
<changeSet id="0567_index_share_transfer" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_share_transfer_status"/></not>
</preConditions>
<createIndex tableName="m_share_transfer" indexName="idx_share_transfer_status">
<column name="status"/>
</createIndex>
<createIndex tableName="m_share_transfer" indexName="idx_share_transfer_from">
<column name="from_share_account_id"/>
</createIndex>
<createIndex tableName="m_share_transfer" indexName="idx_share_transfer_to">
<column name="to_share_account_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>