From bf52c2cda15aedef5a0f9cbc05a8d03d73a9e8be Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 12:48:22 +0530 Subject: [PATCH] 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 --- .../ShareAdvanceCollectionApiResource.java | 226 ++++++++++++------ .../voucher/service/VoucherService.java | 15 +- 2 files changed, 166 insertions(+), 75 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index 23004022d..bff7f2df1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -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. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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> 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 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> 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> 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> 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 out = new LinkedHashMap<>(); out.put("glAccounts", rows); return Response.ok(gson.toJson(out)).build(); @@ -458,20 +545,10 @@ public class ShareAdvanceCollectionApiResource { final Map 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 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> 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 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> 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 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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index 0fbbf4813..be8aa7f6b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -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. */