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>
This commit is contained in:
2026-09-18 12:48:22 +05:30
co-authored by Claude Sonnet 5
parent 4c0512f154
commit bf52c2cda1
2 changed files with 166 additions and 75 deletions
@@ -196,11 +196,11 @@ public class ShareAdvanceCollectionApiResource {
// to search it up by hand -- it's pulled onto this list itself, the first time anyone asks
// for it. Anything not tagged this way (an older row, or one deliberately left General) still
// needs the by-voucher search below, same as before.
autoAdoptShareCollectionTagged(clientId);
autoAdoptShareCollectionTagged();
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ " cl.display_name AS client_name, "
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
+ " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name,"
// The GL account this specific receipt's money actually sits in -- its own posting's
// credit leg, read back from acc_gl_journal_entry rather than guessed, so the GL
// Account picker above can be kept in sync with whichever receipt gets ticked.
@@ -222,6 +222,102 @@ public class ShareAdvanceCollectionApiResource {
// ══════════════════════════════════════════════════════════════════════
// GET — One unapplied receipt by the voucher number quoted by the member
// ══════════════════════════════════════════════════════════════════════
// GET /unattributed — share-collection receipts that name no member yet
// ══════════════════════════════════════════════════════════════════════
/**
* Approved cashier receipts tagged as a share collection that are still waiting to be attached to
* a member.
*
* <p>
* A receipt taken straight into a GL account names no member — a GL account is nobody's account —
* so it can never be auto-adopted onto whichever member's screen happens to be open. That rule
* stands. What it left behind, though, was a counter tagging a receipt "Share Collection" on
* purpose and then finding nothing on the Apply screen but "no unapplied receipt for this
* member", with no way to discover that the voucher they just raised is sitting right there.
*
* <p>
* This lists exactly those receipts so they can be seen and picked. Picking one still runs the
* same by-voucher adoption a typed voucher number does: a person at the counter deliberately
* attaching a named voucher to a member they can see. The list makes the voucher findable; it
* does not decide whose money it is.
*
* <p>
* Receipts already claimed — applied to a share account, or consumed by a Demand Draft — are left
* out, so nothing here is something a second member could take twice. A receipt that already
* names a member (a clerk collection resolved from the savings account it debited) is still
* listed here, not only on that member's own screen -- every branch counter runs the same Apply
* screen for whichever member is in front of them, so a voucher must be findable there regardless
* of which member's screen happened to raise or auto-adopt it first. Attaching it from a different
* member's screen still goes through the ordinary by-voucher lookup below, which refuses a
* mismatch rather than silently moving the money -- this list only makes the voucher visible, it
* does not reassign it.
*/
@GET
@Path("/unattributed")
public Response listUnattributed() {
this.context.authenticatedUser();
// Same eager conversion as listUnapplied() -- run unconditionally (no client filter) so a
// voucher tagged Share Collection becomes visible everywhere the first time ANY Apply screen
// is opened, not only once the specific member it names happens to be looked up.
autoAdoptShareCollectionTagged();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT d.id::text AS id, d.voucher_no AS \"voucherNo\","
+ " d.account_no AS \"glAccountNo\", d.client_name AS \"glAccountName\", d.amount,"
+ " to_char(d.txn_date, 'YYYY-MM-DD') AS \"txnDate\","
+ " d.description, d.cashier_name AS \"cashierName\", NULL::bigint AS \"linkedClientId\""
+ " FROM m_cashier_deposit_request d"
+ " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id"
+ " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED'"
+ " AND d.client_id IS NULL AND d.share_account_id IS NULL"
+ " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"
+ " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id)"
+ " UNION ALL "
+ " SELECT 'sac-' || c.id AS id, c.voucher_no AS \"voucherNo\","
+ " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ " ORDER BY je.id LIMIT 1) AS \"glAccountNo\","
// The GL account's own name, not the member's -- "Deposited To" names an
// account on every other row of this list, and a receipt that already names a
// member still needs its own column to say whose it is (below), not to borrow
// this one and read as though a person were a GL account.
+ " (SELECT ga.name FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id"
+ " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false"
+ " ORDER BY je.id LIMIT 1) AS \"glAccountName\","
+ " c.amount, to_char(c.collection_date, 'YYYY-MM-DD') AS \"txnDate\","
// Visibly names whose receipt this is -- listing it here does not attribute it
// (attaching it still runs the same by-voucher lookup, which refuses a
// mismatched member), but a counter deciding whether to attach it needs to see
// that up front rather than discovering it only after clicking Attach.
+ " COALESCE(c.note, 'Share collection - Voucher ' || c.voucher_no)"
+ " || ' (linked to ' || cl.display_name || ')' AS description,"
+ " c.collected_by_name AS \"cashierName\", c.client_id AS \"linkedClientId\""
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id"
// A voucher's own free-standing collection (raised straight through POST
// /shareadvancecollection, no cashier deposit or clerk request behind it) has
// no entry-type tag at all -- there is nothing else it could be, so it always
// qualifies. One that WAS adopted from a cashier deposit or clerk disbursement
// only stays listed here while that source row is still tagged Share
// Collection today -- a voucher whose entry type reads General (or was
// corrected to General after adoption) belongs on the ordinary voucher
// register, not on this Share Collection-only list, even though the earlier
// adoption already happened and cannot itself be undone here.
+ " LEFT JOIN m_cashier_deposit_request cdr ON cdr.voucher_no = c.voucher_no"
+ " LEFT JOIN m_clerk_disbursement_request clr ON clr.voucher_no = c.voucher_no"
+ " LEFT JOIN m_code_value cdr_cv ON cdr_cv.id = cdr.entry_type_cv_id"
+ " LEFT JOIN m_code_value clr_cv ON clr_cv.id = clr.entry_type_cv_id"
+ " WHERE c.status = 'UNAPPLIED'"
+ " AND ((cdr.id IS NULL AND clr.id IS NULL)"
+ " OR COALESCE(cdr_cv.code_value, clr_cv.code_value) = 'Share Collection')"
+ " ORDER BY \"txnDate\" DESC");
final Map<String, Object> out = new LinkedHashMap<>();
out.put("rows", rows);
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// The voucher arrives as a query parameter because numbers are formatted CR/2108/0001:
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
@@ -247,7 +343,7 @@ public class ShareAdvanceCollectionApiResource {
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
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,"
+ " 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 "
+ " 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 +402,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 +545,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 +774,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 +799,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 +833,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 +870,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 +887,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 +920,25 @@ 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"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo);
}
@@ -892,12 +973,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 +1014,7 @@ 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"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo);
}
@@ -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.
*/