Compare commits
3
Commits
934c11bf87
...
e4fad8b648
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4fad8b648 | ||
|
|
e206536a6b | ||
|
|
bf52c2cda1 |
+178
-72
@@ -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);
|
||||
}
|
||||
|
||||
+19
-3
@@ -746,8 +746,14 @@ public class ClerkDisbursementRequestApiResource {
|
||||
} else {
|
||||
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
|
||||
if (cashVaultGlId != null) {
|
||||
postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
|
||||
approvedGlTxnId = postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
|
||||
officeId, currencyCode, txnDateStr, description);
|
||||
} else if (!withdrawalSavingsTxnRefs.isEmpty()) {
|
||||
// No bridging entry was needed: the withdrawal's own auto-posted credit
|
||||
// leg already lands on the target GL directly (the savings product's own
|
||||
// payment-type mapping points there). That withdrawal's own transaction is
|
||||
// therefore the real posting -- there is no second one to remember instead.
|
||||
approvedGlTxnId = "S" + withdrawalSavingsTxnRefs.get(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1440,7 +1446,15 @@ public class ClerkDisbursementRequestApiResource {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void postGlOffsetEntry(
|
||||
/**
|
||||
* @return the real {@code acc_gl_journal_entry.transaction_id} this posts, so a caller that needs
|
||||
* to remember which posting was theirs — the collection fallback below, most importantly
|
||||
* — is not left with nothing to record. Returning it here rather than discarding it, as
|
||||
* this method used to, was the entire reason {@code gl_transaction_id} stayed blank on
|
||||
* every clerk collection that fell back to this path: nothing downstream (Share Advance
|
||||
* Collection's GL-detail popup among them) had anything to look the posting up by.
|
||||
*/
|
||||
private String postGlOffsetEntry(
|
||||
final Long drGlAccountId, final Long crGlAccountId,
|
||||
final BigDecimal amount, final Long officeId,
|
||||
final String currencyCode, final String txnDateStr, final String comments) {
|
||||
@@ -1476,7 +1490,9 @@ public class ClerkDisbursementRequestApiResource {
|
||||
.build();
|
||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||
// The disbursement register raises its own voucher; suppress the journal command's own.
|
||||
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||
return result != null ? result.getTransactionId() : null;
|
||||
}
|
||||
|
||||
// ── Combined GL entry helpers ─────────────────────────────────────────────
|
||||
|
||||
+2221
File diff suppressed because it is too large
Load Diff
+496
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.shareaccounts.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Share transfer between two members.
|
||||
*
|
||||
* <p>
|
||||
* A transfer is not a sale to the society and a purchase from it — no money reaches the society and
|
||||
* its share capital does not change, so no GL entry is posted. What changes is the register: one
|
||||
* member's holding falls, another's rises, by the same number on the same day at the same price.
|
||||
* That is recorded as the two share transactions the rest of the module already understands
|
||||
* (a redemption on the transferor, a purchase on the transferee), tied together by the transfer row
|
||||
* so a later reader can tell it was one act rather than two unrelated dealings.
|
||||
*
|
||||
* <p>
|
||||
* Maker-checker throughout: a transfer moves ownership and with it voting rights, which a
|
||||
* co-operative does not leave to one clerk. Nothing moves until a checker approves, and every rule
|
||||
* is re-checked at approval rather than trusted from submission — holdings move while a request
|
||||
* waits, and a transfer that was valid on Monday can be impossible by Wednesday.
|
||||
*/
|
||||
@Path("/v1/sharetransfers")
|
||||
@Component
|
||||
@Tag(name = "Share Transfers", description = "Transfer of shares from one member to another, under board resolution.")
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class ShareTransferApiResource {
|
||||
|
||||
private static final int TYPE_PURCHASED = 500;
|
||||
private static final int TYPE_REDEEMED = 600;
|
||||
private static final int TXN_APPROVED = 300;
|
||||
private static final int ACCOUNT_ACTIVE = 300;
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST — a clerk records a transfer for approval
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@POST
|
||||
@Transactional
|
||||
@Operation(summary = "Submit a share transfer for approval",
|
||||
description = "Records a transfer of shares between two members. Nothing moves until a checker approves it.")
|
||||
public Response submit(final String body) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final JsonObject json = JsonParser.parseString(body == null || body.isBlank() ? "{}" : body).getAsJsonObject();
|
||||
|
||||
final Long fromAccountId = getLong(json, "fromShareAccountId");
|
||||
final Long toAccountId = getLong(json, "toShareAccountId");
|
||||
final Long shares = getLong(json, "shares");
|
||||
final String transferDate = getString(json, "transferDate");
|
||||
final String resolutionNumber = getString(json, "resolutionNumber");
|
||||
final String meetingDate = getString(json, "meetingDate");
|
||||
final String reason = getString(json, "reason");
|
||||
|
||||
if (fromAccountId == null || toAccountId == null) {
|
||||
throw badRequest("error.msg.sharetransfer.accounts.required", "Both the transferring and receiving share accounts are required.");
|
||||
}
|
||||
if (shares == null || shares <= 0) {
|
||||
throw badRequest("error.msg.sharetransfer.shares.invalid", "The number of shares to transfer must be greater than zero.");
|
||||
}
|
||||
// A co-operative transfers shares under a board resolution, so the resolution is part of the
|
||||
// request rather than something captured afterwards — a transfer with no resolution behind it
|
||||
// is the finding an auditor writes up.
|
||||
if (resolutionNumber == null || resolutionNumber.isBlank()) {
|
||||
throw badRequest("error.msg.sharetransfer.resolution.required",
|
||||
"The board resolution authorising this transfer is required.");
|
||||
}
|
||||
if (meetingDate == null || meetingDate.isBlank()) {
|
||||
throw badRequest("error.msg.sharetransfer.meeting.date.required", "The board meeting date is required.");
|
||||
}
|
||||
|
||||
final LocalDate effectiveDate = transferDate == null || transferDate.isBlank()
|
||||
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
|
||||
: LocalDate.parse(transferDate);
|
||||
|
||||
final Map<String, Object> from = loadAccount(fromAccountId, "transferring");
|
||||
final Map<String, Object> to = loadAccount(toAccountId, "receiving");
|
||||
validateTransfer(from, to, shares);
|
||||
|
||||
final BigDecimal unitPrice = toDecimal(from.get("unit_price"));
|
||||
final BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(shares));
|
||||
final String reference = nextReference();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
|
||||
final Long id = this.jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_share_transfer
|
||||
(transfer_reference, from_share_account_id, to_share_account_id, shares, unit_price, transfer_amount,
|
||||
transfer_date, resolution_number, meeting_date, reason, status, maker_id, maker_name, submitted_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,'PENDING',?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, reference, fromAccountId, toAccountId, shares, unitPrice, amount,
|
||||
java.sql.Date.valueOf(effectiveDate), resolutionNumber, java.sql.Date.valueOf(LocalDate.parse(meetingDate)), reason,
|
||||
currentUser.getId(), currentUser.getDisplayName(), java.sql.Timestamp.valueOf(now));
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", id);
|
||||
out.put("transferReference", reference);
|
||||
out.put("status", "PENDING");
|
||||
out.put("shares", shares);
|
||||
out.put("amount", amount);
|
||||
out.put("message", "Transfer " + reference + " recorded and sent for approval. Nothing moves until it is approved.");
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GET — the transfer register
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@GET
|
||||
@Operation(summary = "Share transfer register",
|
||||
description = "Every share transfer with both members, the shares moved and the resolution behind it.")
|
||||
public Response list(@QueryParam("status") final String status) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT st.id AS "transferId",
|
||||
st.transfer_reference AS "transferReference",
|
||||
to_char(st.transfer_date, 'YYYY-MM-DD') AS "transferDate",
|
||||
st.shares AS "shares",
|
||||
st.unit_price AS "unitPrice",
|
||||
st.transfer_amount AS "transferAmount",
|
||||
st.status AS "status",
|
||||
st.resolution_number AS "resolutionNumber",
|
||||
to_char(st.meeting_date, 'YYYY-MM-DD') AS "meetingDate",
|
||||
st.reason AS "reason",
|
||||
st.maker_name AS "makerName",
|
||||
to_char(st.submitted_at, 'YYYY-MM-DD') AS "submittedOn",
|
||||
st.checker_name AS "checkerName",
|
||||
to_char(st.actioned_at, 'YYYY-MM-DD') AS "actionedOn",
|
||||
st.rejection_reason AS "rejectionReason",
|
||||
fa.id AS "fromShareAccountId",
|
||||
fa.account_no AS "fromAccountNo",
|
||||
fc.id AS "fromClientId",
|
||||
fc.display_name AS "fromClientName",
|
||||
fc.account_no AS "fromCifNumber",
|
||||
ta.id AS "toShareAccountId",
|
||||
ta.account_no AS "toAccountNo",
|
||||
tc.id AS "toClientId",
|
||||
tc.display_name AS "toClientName",
|
||||
tc.account_no AS "toCifNumber",
|
||||
sp.name AS "productName",
|
||||
fa.currency_code AS "currencyCode"
|
||||
FROM m_share_transfer st
|
||||
JOIN m_share_account fa ON fa.id = st.from_share_account_id
|
||||
JOIN m_client fc ON fc.id = fa.client_id
|
||||
JOIN m_share_account ta ON ta.id = st.to_share_account_id
|
||||
JOIN m_client tc ON tc.id = ta.client_id
|
||||
LEFT JOIN m_share_product sp ON sp.id = fa.product_id
|
||||
WHERE 1 = 1
|
||||
""");
|
||||
final List<Object> args = new ArrayList<>();
|
||||
if (status != null && !status.isBlank() && !"ALL".equalsIgnoreCase(status)) {
|
||||
sql.append(" AND st.status = ? ");
|
||||
args.add(status);
|
||||
}
|
||||
sql.append(" ORDER BY st.submitted_at DESC, st.id DESC ");
|
||||
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
|
||||
|
||||
long transferredShares = 0L;
|
||||
BigDecimal transferredValue = BigDecimal.ZERO;
|
||||
int pending = 0;
|
||||
for (final Map<String, Object> row : rows) {
|
||||
if ("APPROVED".equals(row.get("status"))) {
|
||||
transferredShares += toLong(row.get("shares"));
|
||||
transferredValue = transferredValue.add(toDecimal(row.get("transferAmount")));
|
||||
} else if ("PENDING".equals(row.get("status"))) {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("rows", rows);
|
||||
final Map<String, Object> totals = new LinkedHashMap<>();
|
||||
totals.put("transferCount", rows.size());
|
||||
totals.put("pendingCount", pending);
|
||||
totals.put("sharesTransferred", transferredShares);
|
||||
totals.put("valueTransferred", transferredValue);
|
||||
out.put("totals", totals);
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST /{id}/approve — the transfer actually happens here
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@POST
|
||||
@Path("/{transferId}/approve")
|
||||
@Transactional
|
||||
@Operation(summary = "Approve a share transfer",
|
||||
description = "Re-checks every rule against today's holdings, then moves the shares: a redemption on the transferor and a purchase on the transferee.")
|
||||
public Response approve(@PathParam("transferId") final Long transferId) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
|
||||
final Map<String, Object> transfer = loadTransfer(transferId);
|
||||
if (!"PENDING".equals(transfer.get("status"))) {
|
||||
throw badRequest("error.msg.sharetransfer.not.pending",
|
||||
"Transfer " + transfer.get("transfer_reference") + " is already " + transfer.get("status") + ".");
|
||||
}
|
||||
|
||||
final Long fromAccountId = toLong(transfer.get("from_share_account_id"));
|
||||
final Long toAccountId = toLong(transfer.get("to_share_account_id"));
|
||||
final long shares = toLong(transfer.get("shares"));
|
||||
|
||||
// Re-checked, not trusted: holdings move while a request waits, and a transfer that was
|
||||
// valid when it was raised can be impossible by the time it reaches a checker.
|
||||
final Map<String, Object> from = loadAccount(fromAccountId, "transferring");
|
||||
final Map<String, Object> to = loadAccount(toAccountId, "receiving");
|
||||
validateTransfer(from, to, shares);
|
||||
|
||||
final BigDecimal unitPrice = toDecimal(transfer.get("unit_price"));
|
||||
final BigDecimal amount = toDecimal(transfer.get("transfer_amount"));
|
||||
final java.sql.Date effectiveDate = (java.sql.Date) transfer.get("transfer_date");
|
||||
final Object meetingDate = transfer.get("meeting_date");
|
||||
final String resolutionNumber = (String) transfer.get("resolution_number");
|
||||
|
||||
// The transferor is redeemed and the transferee purchases — the same two movements the rest
|
||||
// of the share module already reads, so every balance, statement and report picks this up
|
||||
// without knowing transfers exist.
|
||||
final Long fromTxnId = this.jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_share_account_transactions
|
||||
(account_id, transaction_date, total_shares, unit_price, amount, status_enum, type_enum, is_active,
|
||||
resolution_number, meeting_date)
|
||||
VALUES (?,?,?,?,?,?,?,true,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, fromAccountId, effectiveDate, shares, unitPrice, amount, TXN_APPROVED, TYPE_REDEEMED,
|
||||
resolutionNumber, meetingDate);
|
||||
|
||||
final Long toTxnId = this.jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_share_account_transactions
|
||||
(account_id, transaction_date, total_shares, unit_price, amount, status_enum, type_enum, is_active,
|
||||
resolution_number, meeting_date)
|
||||
VALUES (?,?,?,?,?,?,?,true,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, toAccountId, effectiveDate, shares, unitPrice, amount, TXN_APPROVED, TYPE_PURCHASED,
|
||||
resolutionNumber, meetingDate);
|
||||
|
||||
// The account's own running total is kept in step with the transactions just written, since
|
||||
// the rest of the platform reads it directly.
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE m_share_account SET total_approved_shares = COALESCE(total_approved_shares, 0) - ? WHERE id = ?", shares,
|
||||
fromAccountId);
|
||||
this.jdbcTemplate.update(
|
||||
"UPDATE m_share_account SET total_approved_shares = COALESCE(total_approved_shares, 0) + ? WHERE id = ?", shares,
|
||||
toAccountId);
|
||||
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_share_transfer SET status = 'APPROVED', from_transaction_id = ?, to_transaction_id = ?,
|
||||
checker_id = ?, checker_name = ?, actioned_at = ?
|
||||
WHERE id = ?
|
||||
""", fromTxnId, toTxnId, currentUser.getId(), currentUser.getDisplayName(),
|
||||
java.sql.Timestamp.valueOf(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())), transferId);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", transferId);
|
||||
out.put("transferReference", transfer.get("transfer_reference"));
|
||||
out.put("status", "APPROVED");
|
||||
out.put("shares", shares);
|
||||
out.put("amount", amount);
|
||||
out.put("fromTransactionId", fromTxnId);
|
||||
out.put("toTransactionId", toTxnId);
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST /{id}/reject
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@POST
|
||||
@Path("/{transferId}/reject")
|
||||
@Transactional
|
||||
@Operation(summary = "Reject a share transfer")
|
||||
public Response reject(@PathParam("transferId") final Long transferId, final String body) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final JsonObject json = JsonParser.parseString(body == null || body.isBlank() ? "{}" : body).getAsJsonObject();
|
||||
|
||||
final Map<String, Object> transfer = loadTransfer(transferId);
|
||||
if (!"PENDING".equals(transfer.get("status"))) {
|
||||
throw badRequest("error.msg.sharetransfer.not.pending",
|
||||
"Transfer " + transfer.get("transfer_reference") + " is already " + transfer.get("status") + ".");
|
||||
}
|
||||
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_share_transfer SET status = 'REJECTED', rejection_reason = ?, checker_id = ?, checker_name = ?,
|
||||
actioned_at = ?
|
||||
WHERE id = ?
|
||||
""", getString(json, "rejectionReason"), currentUser.getId(), currentUser.getDisplayName(),
|
||||
java.sql.Timestamp.valueOf(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())), transferId);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", transferId);
|
||||
out.put("status", "REJECTED");
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Every rule a transfer has to satisfy, applied identically at submission and at approval.
|
||||
*
|
||||
* <p>
|
||||
* Holdings are derived from the transactions rather than read off the account's running total,
|
||||
* for the same reason the balance report does it: the total is what the account currently says,
|
||||
* and this needs what the movements actually add up to.
|
||||
*/
|
||||
private void validateTransfer(final Map<String, Object> from, final Map<String, Object> to, final long shares) {
|
||||
final Long fromAccountId = toLong(from.get("id"));
|
||||
final Long toAccountId = toLong(to.get("id"));
|
||||
|
||||
if (fromAccountId.equals(toAccountId)) {
|
||||
throw badRequest("error.msg.sharetransfer.same.account", "A share account cannot transfer to itself.");
|
||||
}
|
||||
if (toLong(from.get("client_id")) == toLong(to.get("client_id"))) {
|
||||
throw badRequest("error.msg.sharetransfer.same.member",
|
||||
"Both accounts belong to the same member, so there is nothing to transfer between.");
|
||||
}
|
||||
// Different share classes carry different rights and prices; moving shares across them would
|
||||
// be an issue and a redemption, not a transfer.
|
||||
if (toLong(from.get("product_id")) != toLong(to.get("product_id"))) {
|
||||
throw badRequest("error.msg.sharetransfer.product.mismatch",
|
||||
"Shares can only be transferred within the same share class. " + from.get("product_name") + " and "
|
||||
+ to.get("product_name") + " are different classes.");
|
||||
}
|
||||
if (toInt(from.get("status_enum")) != ACCOUNT_ACTIVE) {
|
||||
throw badRequest("error.msg.sharetransfer.from.not.active",
|
||||
"Share account " + from.get("account_no") + " is not active, so it cannot transfer shares.");
|
||||
}
|
||||
if (toInt(to.get("status_enum")) != ACCOUNT_ACTIVE) {
|
||||
throw badRequest("error.msg.sharetransfer.to.not.active",
|
||||
"Share account " + to.get("account_no") + " is not active, so it cannot receive shares.");
|
||||
}
|
||||
|
||||
final long fromHeld = heldShares(fromAccountId);
|
||||
final long toHeld = heldShares(toAccountId);
|
||||
if (fromHeld < shares) {
|
||||
throw badRequest("error.msg.sharetransfer.insufficient.shares", "Share account " + from.get("account_no") + " holds "
|
||||
+ fromHeld + " shares, which is fewer than the " + shares + " being transferred.");
|
||||
}
|
||||
|
||||
final Long maximum = toLongOrNull(to.get("maximum_client_shares"));
|
||||
if (maximum != null && toHeld + shares > maximum) {
|
||||
throw badRequest("error.msg.sharetransfer.above.maximum",
|
||||
"The transfer would leave " + to.get("to_client_name") + " holding " + (toHeld + shares)
|
||||
+ " shares, above the " + maximum + " this share class allows one member.");
|
||||
}
|
||||
|
||||
// A transferor may go to nil — that is a member leaving — but may not be left holding less
|
||||
// than the class's minimum, which is a membership the society cannot recognise.
|
||||
final Long minimum = toLongOrNull(from.get("minimum_client_shares"));
|
||||
final long remaining = fromHeld - shares;
|
||||
if (minimum != null && remaining > 0 && remaining < minimum) {
|
||||
throw badRequest("error.msg.sharetransfer.below.minimum",
|
||||
"The transfer would leave " + from.get("account_no") + " holding " + remaining + " shares, below the "
|
||||
+ minimum + " minimum this share class requires. Transfer all " + fromHeld
|
||||
+ " shares, or leave at least " + minimum + ".");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the account's settled movements actually add up to, ignoring its running total.
|
||||
*
|
||||
* <p>
|
||||
* Only paid-up shares can be transferred. An opening tranche whose collection has never been
|
||||
* applied is shares allotted against money the society never received — the member does not yet
|
||||
* own them to give away, and transferring them would move an unpaid holding onto somebody else's
|
||||
* name. The account's own {@code total_approved_shares} counts them, which is exactly why this
|
||||
* walks the transactions instead.
|
||||
*/
|
||||
private long heldShares(final Long shareAccountId) {
|
||||
final Long held = this.jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN t.type_enum = ? THEN COALESCE(t.total_shares, 0) ELSE 0 END)
|
||||
- SUM(CASE WHEN t.type_enum = ? THEN COALESCE(t.total_shares, 0) ELSE 0 END), 0)
|
||||
FROM m_share_account_transactions t
|
||||
JOIN m_share_account pa ON pa.id = t.account_id
|
||||
WHERE t.account_id = ? AND t.is_active = true AND t.status_enum = ? AND t.type_enum IN (?, ?)
|
||||
AND (t.type_enum = ? OR t.funding_source IS NOT NULL OR pa.collection_applied = true)
|
||||
""", Long.class, TYPE_PURCHASED, TYPE_REDEEMED, shareAccountId, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED,
|
||||
TYPE_REDEEMED);
|
||||
return held == null ? 0L : held;
|
||||
}
|
||||
|
||||
private Map<String, Object> loadAccount(final Long shareAccountId, final String side) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||
SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code,
|
||||
c.display_name AS to_client_name, c.account_no AS cif_number,
|
||||
sp.name AS product_name, sp.unit_price, sp.minimum_client_shares, sp.maximum_client_shares
|
||||
FROM m_share_account sa
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_share_product sp ON sp.id = sa.product_id
|
||||
WHERE sa.id = ?
|
||||
""", shareAccountId);
|
||||
if (rows.isEmpty()) {
|
||||
throw badRequest("error.msg.sharetransfer.account.not.found",
|
||||
"The " + side + " share account " + shareAccountId + " was not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private Map<String, Object> loadTransfer(final Long transferId) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||
.queryForList("SELECT * FROM m_share_transfer WHERE id = ?", transferId);
|
||||
if (rows.isEmpty()) {
|
||||
throw badRequest("error.msg.sharetransfer.not.found", "Transfer " + transferId + " was not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
/** TRF-YYMMDD-NNNN, dated and serial, so a reference says when it was raised. */
|
||||
private String nextReference() {
|
||||
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String prefix = "TRF-" + today.format(java.time.format.DateTimeFormatter.ofPattern("yyMMdd")) + "-";
|
||||
final Integer used = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_share_transfer WHERE transfer_reference LIKE ?", Integer.class, prefix + "%");
|
||||
return prefix + String.format("%04d", (used == null ? 0 : used) + 1);
|
||||
}
|
||||
|
||||
private PlatformDataIntegrityException badRequest(final String code, final String message) {
|
||||
return new PlatformDataIntegrityException(code, message);
|
||||
}
|
||||
|
||||
private Long getLong(final JsonObject json, final String key) {
|
||||
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : null;
|
||||
}
|
||||
|
||||
private String getString(final JsonObject json, final String key) {
|
||||
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
|
||||
}
|
||||
|
||||
private long toLong(final Object value) {
|
||||
return value instanceof Number number ? number.longValue() : 0L;
|
||||
}
|
||||
|
||||
private Long toLongOrNull(final Object value) {
|
||||
return value instanceof Number number ? number.longValue() : null;
|
||||
}
|
||||
|
||||
private int toInt(final Object value) {
|
||||
return value instanceof Number number ? number.intValue() : 0;
|
||||
}
|
||||
|
||||
private BigDecimal toDecimal(final Object value) {
|
||||
if (value instanceof BigDecimal decimal) {
|
||||
return decimal;
|
||||
}
|
||||
return value instanceof Number number ? BigDecimal.valueOf(number.doubleValue()) : BigDecimal.ZERO;
|
||||
}
|
||||
}
|
||||
+10
@@ -748,6 +748,16 @@ public class ShareAccountDataSerializer {
|
||||
if (!account.status().equals(ShareAccountStatusType.ACTIVE.getValue())) {
|
||||
baseDataValidator.failWithCodeNoParameterAddedToErrorCode("is.not.in.active.state");
|
||||
}
|
||||
// An Active account can still be an unpaid application -- activation posts no GL of its own,
|
||||
// only Apply Collection does. A further tranche on top of a first allotment nobody has paid
|
||||
// for yet would grow a holding that does not exist: settle the original application first,
|
||||
// then add to it. Read straight from the column rather than through the entity, which does
|
||||
// not map it -- collection_applied lives outside JPA, set only by the collection endpoints.
|
||||
final Boolean collectionApplied = this.jdbcTemplate
|
||||
.queryForObject("SELECT collection_applied FROM m_share_account WHERE id = ?", Boolean.class, account.getId());
|
||||
if (!Boolean.TRUE.equals(collectionApplied)) {
|
||||
baseDataValidator.failWithCodeNoParameterAddedToErrorCode("original.application.not.yet.settled");
|
||||
}
|
||||
LocalDate requestedDate = this.fromApiJsonHelper.extractLocalDateNamed(ShareAccountApiConstants.requesteddate_paramname, element);
|
||||
baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname).value(requestedDate).notNull();
|
||||
final Long sharesRequested = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element);
|
||||
|
||||
+11
-4
@@ -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>
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Share transfer between members.
|
||||
|
||||
A transfer is two share transactions that must happen together or not at all: the transferor
|
||||
is redeemed and the transferee purchases, same shares, same day, same price. Recording only
|
||||
those two rows would leave nothing saying they were one act, so a later reader sees a member
|
||||
who sold and, separately, a member who bought, with no way to tell it was a transfer rather
|
||||
than two unrelated dealings.
|
||||
|
||||
m_share_transfer is that missing act. It also carries what a co-operative needs a transfer to
|
||||
have but a share transaction has nowhere to put: the board resolution that approved it, the
|
||||
reason, and the maker-checker trail, since a transfer moves ownership and voting rights
|
||||
between two members and cannot be a single clerk's decision.
|
||||
|
||||
No GL entry is involved and none is recorded here: share capital does not change when it
|
||||
moves between two members, only the register does.
|
||||
-->
|
||||
|
||||
<changeSet id="0567_create_share_transfer" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_share_transfer"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_share_transfer">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="transfer_reference" type="VARCHAR(40)">
|
||||
<constraints nullable="false" unique="true"/>
|
||||
</column>
|
||||
<column name="from_share_account_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="to_share_account_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="shares" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="unit_price" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="transfer_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="transfer_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="resolution_number" type="VARCHAR(100)"/>
|
||||
<column name="meeting_date" type="DATE"/>
|
||||
<column name="reason" type="VARCHAR(500)"/>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- The two share transactions this transfer became, written only on approval. -->
|
||||
<column name="from_transaction_id" type="BIGINT"/>
|
||||
<column name="to_transaction_id" type="BIGINT"/>
|
||||
<column name="maker_id" type="BIGINT"/>
|
||||
<column name="maker_name" type="VARCHAR(150)"/>
|
||||
<column name="submitted_at" type="TIMESTAMP"/>
|
||||
<column name="checker_id" type="BIGINT"/>
|
||||
<column name="checker_name" type="VARCHAR(150)"/>
|
||||
<column name="actioned_at" type="TIMESTAMP"/>
|
||||
<column name="rejection_reason" type="VARCHAR(500)"/>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_share_transfer" baseColumnNames="from_share_account_id"
|
||||
constraintName="fk_share_transfer_from_account" referencedTableName="m_share_account" referencedColumnNames="id"/>
|
||||
<addForeignKeyConstraint baseTableName="m_share_transfer" baseColumnNames="to_share_account_id"
|
||||
constraintName="fk_share_transfer_to_account" referencedTableName="m_share_account" referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0567_index_share_transfer" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_share_transfer_status"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_share_transfer" indexName="idx_share_transfer_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_share_transfer" indexName="idx_share_transfer_from">
|
||||
<column name="from_share_account_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_share_transfer" indexName="idx_share_transfer_to">
|
||||
<column name="to_share_account_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user