Bugzilla – Attachment 185291 Details for
Bug 40445
Point of Sale reconciliation input during daily summaries
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 40445: Add cashup reconciliation functionality to point of sale
Bug-40445-Add-cashup-reconciliation-functionality-.patch (text/plain), 25.07 KB, created by
Martin Renvoize (ashimema)
on 2025-08-08 17:29:12 UTC
(
hide
)
Description:
Bug 40445: Add cashup reconciliation functionality to point of sale
Filename:
MIME Type:
Creator:
Martin Renvoize (ashimema)
Created:
2025-08-08 17:29:12 UTC
Size:
25.07 KB
patch
obsolete
>From a96ecdc466ada67ee596d04606faccfd0ae9c8fc Mon Sep 17 00:00:00 2001 >From: Martin Renvoize <martin.renvoize@openfifth.co.uk> >Date: Fri, 8 Aug 2025 13:19:45 +0100 >Subject: [PATCH] Bug 40445: Add cashup reconciliation functionality to point > of sale > >This patch implements comprehensive cashup reconciliation capabilities for the Koha >point-of-sale system, allowing staff to record actual cash amounts and automatically >track surplus or deficit discrepancies. > >Backend Changes: >- Enhanced Koha::Cash::Register->add_cashup() method to accept actual_amount and optional notes >- Automatic creation of CASHUP_SURPLUS or CASHUP_DEFICIT accountlines when discrepancies detected >- Database transaction handling ensures atomicity of cashup actions and reconciliation records >- Input validation and sanitization for amounts and notes > >Frontend Changes: >- Interactive cashup modal requiring staff to enter actual amount removed from register >- Real-time reconciliation calculation showing surplus/deficit as user types >- Conditional note field (1000 char limit) appears only when discrepancies are detected >- Enhanced cashup summary modal displays reconciliation details prominently >- Empty amount field forces conscious entry (no pre-population) > >Features: >- Balanced cashups: Only cashup action created (no reconciliation accountlines) >- Surplus cashups: Creates CASHUP_SURPLUS credit with audit details >- Deficit cashups: Creates CASHUP_DEFICIT debit with audit details >- Staff notes: Optional explanations for discrepancies stored with system calculations >- Full audit trail: All reconciliation data preserved with timestamps and manager links > >The implementation ensures mathematical balance is maintained while providing complete >audit capabilities for cash register discrepancies. >--- > Koha/Cash/Register.pm | 106 ++++++++++++++++-- > Koha/Cash/Register/Cashup.pm | 27 ++++- > .../en/includes/modals/cashup_summary.inc | 28 ++++- > .../prog/en/modules/pos/register.tt | 88 ++++++++++++++- > .../intranet-tmpl/prog/js/cashup_modal.js | 88 ++++++++++++++- > pos/register.pl | 33 ++++-- > 6 files changed, 340 insertions(+), 30 deletions(-) > >diff --git a/Koha/Cash/Register.pm b/Koha/Cash/Register.pm >index 19a1ecf9372..0ca8f714ad0 100644 >--- a/Koha/Cash/Register.pm >+++ b/Koha/Cash/Register.pm >@@ -17,6 +17,7 @@ package Koha::Cash::Register; > > use Modern::Perl; > >+use Koha::Account; > use Koha::Account::Lines; > use Koha::Account::Offsets; > use Koha::Cash::Register::Actions; >@@ -208,27 +209,114 @@ sub drop_default { > > my $cashup = $cash_register->add_cashup( > { >- manager_id => $logged_in_user->id, >- amount => $cash_register->outstanding_accountlines->total >+ manager_id => $logged_in_user->id, >+ amount => $amount_removed_from_register, >+ [ reconciliation_note => $reconciliation_note ] > } > ); > > Add a new cashup action to the till, returns the added action. >+If amount differs from expected amount, creates surplus/deficit accountlines. > > =cut > > sub add_cashup { > my ( $self, $params ) = @_; > >- my $rs = $self->_result->add_to_cash_register_actions( >- { >- code => 'CASHUP', >- manager_id => $params->{manager_id}, >- amount => $params->{amount} >+ my $manager_id = $params->{manager_id}; >+ my $amount = $params->{amount}; >+ my $reconciliation_note = $params->{reconciliation_note}; >+ >+ # Sanitize reconciliation note - treat empty/whitespace-only as undef >+ if ( defined $reconciliation_note ) { >+ $reconciliation_note = substr( $reconciliation_note, 0, 1000 ); # Limit length >+ $reconciliation_note =~ s/^\s+|\s+$//g; # Trim whitespace >+ $reconciliation_note = undef if $reconciliation_note eq ''; # Empty after trim = undef >+ } >+ >+ # Calculate expected amount from outstanding accountlines >+ my $expected_amount = $self->outstanding_accountlines->total; >+ >+ # For backward compatibility, if no actual amount is specified, use expected amount >+ $amount //= abs($expected_amount); >+ >+ # Calculate difference (actual - expected) >+ my $difference = $amount - abs($expected_amount); >+ >+ # Use database transaction to ensure consistency >+ my $schema = $self->_result->result_source->schema; >+ my $cashup; >+ >+ $schema->txn_do( >+ sub { >+ # Create the cashup action with actual amount >+ my $rs = $self->_result->add_to_cash_register_actions( >+ { >+ code => 'CASHUP', >+ manager_id => $manager_id, >+ amount => $amount >+ } >+ )->discard_changes; >+ >+ $cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs); >+ >+ # Create reconciliation accountline if there's a difference >+ if ( $difference != 0 ) { >+ >+ if ( $difference > 0 ) { >+ >+ # Surplus: more cash found than expected (credits are negative amounts) >+ my $surplus = Koha::Account::Line->new( >+ { >+ date => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)', >+ amount => -abs($difference), # Credits are negative >+ description => 'Cash register surplus found during cashup', >+ credit_type_code => 'CASHUP_SURPLUS', >+ manager_id => $manager_id, >+ interface => 'intranet', >+ register_id => $self->id, >+ note => $reconciliation_note >+ } >+ )->store(); >+ >+ # Record the account offset >+ my $account_offset = Koha::Account::Offset->new( >+ { >+ credit_id => $surplus->id, >+ type => 'CREATE', >+ amount => -abs($difference) # Offsets match the line amount >+ } >+ )->store(); >+ >+ } else { >+ >+ # Deficit: less cash found than expected >+ my $deficit = Koha::Account::Line->new( >+ { >+ date => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)', >+ amount => abs($difference), >+ description => 'Cash register deficit found during cashup', >+ debit_type_code => 'CASHUP_DEFICIT', >+ manager_id => $manager_id, >+ interface => 'intranet', >+ register_id => $self->id, >+ note => $reconciliation_note >+ } >+ )->store(); >+ my $account_offset = Koha::Account::Offset->new( >+ { >+ debit_id => $deficit->id, >+ type => 'CREATE', >+ amount => abs($difference) # Debits have positive offsets >+ } >+ )->store(); >+ >+ } >+ } > } >- )->discard_changes; >+ ); > >- return Koha::Cash::Register::Cashup->_new_from_dbic($rs); >+ return $cashup; > } > > =head3 to_api_mapping >diff --git a/Koha/Cash/Register/Cashup.pm b/Koha/Cash/Register/Cashup.pm >index fa534286166..819bcff29f1 100644 >--- a/Koha/Cash/Register/Cashup.pm >+++ b/Koha/Cash/Register/Cashup.pm >@@ -80,10 +80,18 @@ sub summary { > : { 'date' => { '<' => $self->timestamp } }; > > my $payout_transactions = $self->register->accountlines->search( >- { %{$conditions}, credit_type_code => undef }, >+ { >+ %{$conditions}, >+ credit_type_code => undef, >+ debit_type_code => { '!=' => 'CASHUP_DEFICIT' } >+ }, > ); > my $income_transactions = $self->register->accountlines->search( >- { %{$conditions}, debit_type_code => undef }, >+ { >+ %{$conditions}, >+ debit_type_code => undef, >+ credit_type_code => { '!=' => 'CASHUP_SURPLUS' } >+ }, > ); > > my $income_summary = Koha::Account::Offsets->search( >@@ -173,6 +181,15 @@ sub summary { > push @total_grouped, { payment_type => $type->lib, total => $typed_total }; > } > >+ # Check for reconciliation lines separately (for footer display only) >+ my $surplus_lines = >+ $self->register->accountlines->search( { %{$conditions}, credit_type_code => 'CASHUP_SURPLUS' } ); >+ my $deficit_lines = >+ $self->register->accountlines->search( { %{$conditions}, debit_type_code => 'CASHUP_DEFICIT' } ); >+ >+ my $surplus_total = $surplus_lines->count ? $surplus_lines->total : undef; >+ my $deficit_total = $deficit_lines->count ? $deficit_lines->total : undef; >+ > $summary = { > from_date => $previous ? $previous->timestamp : undef, > to_date => $self->timestamp, >@@ -181,7 +198,11 @@ sub summary { > payout_grouped => \@payout, > payout_total => abs($payout_total), > total => $total * -1, >- total_grouped => \@total_grouped >+ total_grouped => \@total_grouped, >+ >+ # Reconciliation data for footer display >+ surplus_total => $surplus_total ? $surplus_total * 1 : undef, >+ deficit_total => $deficit_total ? $deficit_total * 1 : undef > }; > > return $summary; >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/cashup_summary.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/cashup_summary.inc >index 960dc051566..cd503074494 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/cashup_summary.inc >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/cashup_summary.inc >@@ -11,7 +11,8 @@ > <li>Cash register: <span id="register_description"></span></li> > <li>Period: <span id="from_date"></span> to <span id="to_date"></span></li> > </ul> >- <table> >+ >+ <table class="table table-striped"> > <thead> > <tr> > <th>Type</th> >@@ -21,6 +22,31 @@ > <tbody> </tbody> > <tfoot> </tfoot> > </table> >+ >+ <style> >+ #cashupSummaryModal .reconciliation-separator hr { >+ margin: 0.5rem 0; >+ border-color: #dee2e6; >+ } >+ >+ #cashupSummaryModal .reconciliation-info { >+ background-color: #f8f9fa; >+ } >+ >+ #cashupSummaryModal .reconciliation-result.text-warning { >+ background-color: #fff3cd; >+ color: #856404; >+ } >+ >+ #cashupSummaryModal .reconciliation-result.text-danger { >+ background-color: #f8d7da; >+ color: #721c24; >+ } >+ >+ #cashupSummaryModal .total-row { >+ border-top: 2px solid #dee2e6; >+ } >+ </style> > </div> > <!-- /.modal-body --> > <div class="modal-footer"> >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/pos/register.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/pos/register.tt >index e01152f7515..44fc6a6ae8e 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/pos/register.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/pos/register.tt >@@ -48,6 +48,10 @@ > <div id="error_message" class="alert alert-warning"> You do not have permission to perform cashup actions. </div> > [% END %] > >+ [% IF ( error_cashup_amount ) %] >+ <div id="error_message" class="alert alert-warning"> Invalid amount entered for cashup. Please enter a valid monetary amount. </div> >+ [% END %] >+ > [% IF ( error_refund_permission ) %] > <div id="error_message" class="alert alert-warning"> You do not have permission to perform refund actions. </div> > [% END %] >@@ -366,7 +370,7 @@ > > <!-- Confirm cashup modal --> > <div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel"> >- <form id="cashup_form" method="post" enctype="multipart/form-data"> >+ <form id="cashup_form" method="post" enctype="multipart/form-data" class="validated"> > [% INCLUDE 'csrf-token.inc' %] > <div class="modal-dialog"> > <div class="modal-content"> >@@ -375,13 +379,38 @@ > <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> > </div> > <div class="modal-body"> >- Please confirm that you have removed [% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %] from the cash register and left a float of [% register.starting_float | $Price %]. >+ <fieldset class="rows"> >+ <ol> >+ <li> >+ <span class="label">Expected amount to remove:</span> >+ <span id="expected_amount" class="expected-amount">[% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %]</span> >+ </li> >+ <li> >+ <span class="label">Float to remain:</span> >+ <span>[% register.starting_float | $Price %]</span> >+ </li> >+ <li> >+ <label class="required" for="amount">Actual amount removed from register:</label> >+ <input type="text" inputmode="decimal" pattern="^\d+(\.\d{2})?$" id="amount" name="amount" required="required" /> >+ <span class="required">Required</span> >+ </li> >+ <li id="reconciliation_display" style="display: none;"> >+ <span class="label">Reconciliation:</span> >+ <span id="reconciliation_text"></span> >+ </li> >+ <li id="reconciliation_note_field" style="display: none;"> >+ <label for="reconciliation_note">Note (optional):</label> >+ <textarea id="reconciliation_note" name="reconciliation_note" rows="3" cols="40" maxlength="1000" placeholder="Enter a note explaining the surplus or deficit..."></textarea> >+ <div class="hint">Maximum 1000 characters</div> >+ </li> >+ </ol> >+ </fieldset> > </div> > <!-- /.modal-body --> > <div class="modal-footer"> > <input type="hidden" name="registerid" value="[% register.id | html %]" /> > <input type="hidden" name="op" value="cud-cashup" /> >- <button type="submit" class="btn btn-primary" id="pos_cashup_confirm">Confirm</button> >+ <button type="submit" class="btn btn-primary" id="pos_cashup_confirm">Confirm cashup</button> > <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button> > </div> > <!-- /.modal-footer --> >@@ -573,6 +602,59 @@ > } > ] > }, null, 1); >+ >+ // Real-time reconciliation calculation for cashup modal >+ $("#amount").on("input", function() { >+ var actualAmount = parseFloat($(this).val()) || 0; >+ var expectedText = $("#expected_amount").text().replace(/[£$,]/g, ''); >+ var expectedAmount = parseFloat(expectedText) || 0; >+ var difference = actualAmount - expectedAmount; >+ >+ if ($(this).val() && !isNaN(actualAmount)) { >+ var reconciliationText = ""; >+ var reconciliationClass = ""; >+ var hasDiscrepancy = false; >+ >+ if (difference > 0) { >+ reconciliationText = "Surplus: " + difference.format_price(); >+ reconciliationClass = "success"; >+ hasDiscrepancy = true; >+ } else if (difference < 0) { >+ reconciliationText = "Deficit: " + Math.abs(difference).format_price(); >+ reconciliationClass = "warning"; >+ hasDiscrepancy = true; >+ } else { >+ reconciliationText = "Balanced - no surplus or deficit"; >+ reconciliationClass = "success"; >+ hasDiscrepancy = false; >+ } >+ >+ $("#reconciliation_text").text(reconciliationText) >+ .removeClass("success warning") >+ .addClass(reconciliationClass); >+ $("#reconciliation_display").show(); >+ >+ // Show/hide note field based on whether there's a discrepancy >+ if (hasDiscrepancy) { >+ $("#reconciliation_note_field").show(); >+ } else { >+ $("#reconciliation_note_field").hide(); >+ $("#reconciliation_note").val(''); // Clear note when balanced >+ } >+ } else { >+ $("#reconciliation_display").hide(); >+ $("#reconciliation_note_field").hide(); >+ } >+ }); >+ >+ // Reset modal when opened >+ $("#confirmCashupModal").on("shown.bs.modal", function() { >+ // Start with empty actual amount field (user must enter amount) >+ $("#amount").val('').focus(); >+ $("#reconciliation_display").hide(); >+ $("#reconciliation_note_field").hide(); >+ $("#reconciliation_note").val(''); >+ }); > </script> > [% END %] > >diff --git a/koha-tmpl/intranet-tmpl/prog/js/cashup_modal.js b/koha-tmpl/intranet-tmpl/prog/js/cashup_modal.js >index f5921558be2..667a8205890 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/cashup_modal.js >+++ b/koha-tmpl/intranet-tmpl/prog/js/cashup_modal.js >@@ -16,6 +16,13 @@ $(document).ready(function () { > summary_modal.find("#from_date").text(from_date); > let to_date = $datetime(data.summary.to_date); > summary_modal.find("#to_date").text(to_date); >+ >+ // Check for reconciliation (surplus or deficit) from dedicated fields >+ var surplus = data.summary.surplus_total; >+ var deficit = data.summary.deficit_total; >+ var expectedAmount = data.summary.total; >+ var actualAmount = data.amount; >+ > var tbody = summary_modal.find("tbody"); > tbody.empty(); > for (out of data.summary.payout_grouped) { >@@ -61,22 +68,91 @@ $(document).ready(function () { > > var tfoot = summary_modal.find("tfoot"); > tfoot.empty(); >+ >+ // 1. Total (sum of all transactions) > tfoot.append( >- "<tr><td>Total</td><td>" + >+ "<tr class='total-row'><td><strong>Total</strong></td><td><strong>" + > data.summary.total.format_price() + >- "</td></tr>" >+ "</strong></td></tr>" >+ ); >+ >+ // Add separator line >+ tfoot.append( >+ "<tr class='reconciliation-separator'><td colspan='2'><hr></td></tr>" > ); >+ >+ // 2. Cash collected (amount recorded as removed from register) >+ var cashCollected = null; > for (type of data.summary.total_grouped) { >- if (type.total !== 0) { >+ if ( >+ type.payment_type === "Cash" || >+ type.payment_type === "CASH" >+ ) { >+ cashCollected = type.total; >+ break; >+ } >+ } >+ if (cashCollected !== null) { >+ tfoot.append( >+ "<tr><td><strong>Cash collected</strong></td><td><strong>" + >+ cashCollected.format_price() + >+ "</strong></td></tr>" >+ ); >+ } >+ >+ // 3. Other payment types collected (excluding CASH) >+ for (type of data.summary.total_grouped) { >+ if ( >+ type.total !== 0 && >+ type.payment_type !== "Cash" && >+ type.payment_type !== "CASH" >+ ) { > tfoot.append( >- "<tr><td>" + >+ "<tr><td><strong>" + > escape_str(type.payment_type) + >- "</td><td>" + >+ " collected" + >+ "</strong></td><td><strong>" + > type.total.format_price() + >- "</td></tr>" >+ "</strong></td></tr>" > ); > } > } >+ >+ // 4. Cashup surplus OR deficit (highlighted) >+ if (surplus || deficit) { >+ // Add separator before reconciliation >+ tfoot.append( >+ "<tr class='reconciliation-separator'><td colspan='2'><hr></td></tr>" >+ ); >+ >+ var reconciliationClass, >+ reconciliationLabel, >+ reconciliationAmount; >+ >+ if (surplus) { >+ reconciliationClass = >+ "reconciliation-result text-warning"; >+ reconciliationLabel = "Cashup surplus"; >+ reconciliationAmount = >+ "+" + Math.abs(surplus).format_price(); >+ } else if (deficit) { >+ reconciliationClass = >+ "reconciliation-result text-danger"; >+ reconciliationLabel = "Cashup deficit"; >+ reconciliationAmount = >+ "-" + Math.abs(deficit).format_price(); >+ } >+ >+ tfoot.append( >+ "<tr class='" + >+ reconciliationClass + >+ "'><td><strong>" + >+ reconciliationLabel + >+ "</strong></td><td><strong>" + >+ reconciliationAmount + >+ "</strong></td></tr>" >+ ); >+ } > }, > }); > }); >diff --git a/pos/register.pl b/pos/register.pl >index a215f36267c..7fb8c70f08a 100755 >--- a/pos/register.pl >+++ b/pos/register.pl >@@ -104,16 +104,33 @@ if ( !$registers->count ) { > my $op = $input->param('op') // ''; > if ( $op eq 'cud-cashup' ) { > if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) { >- $cash_register->add_cashup( >- { >- manager_id => $logged_in_user->id, >- amount => $cash_register->outstanding_accountlines->total >+ my $amount = $input->param('amount'); >+ my $reconciliation_note = $input->param('reconciliation_note'); >+ >+ if ( defined $amount && $amount =~ /^\d+(?:\.\d{1,2})?$/ ) { >+ >+ # Sanitize and limit note length >+ if ( defined $reconciliation_note ) { >+ $reconciliation_note = substr( $reconciliation_note, 0, 1000 ); >+ $reconciliation_note =~ s/^\s+|\s+$//g; # Trim whitespace >+ $reconciliation_note = undef if $reconciliation_note eq ''; > } >- ); > >- # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern) >- print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid ); >- exit; >+ $cash_register->add_cashup( >+ { >+ manager_id => $logged_in_user->id, >+ amount => $amount, >+ reconciliation_note => $reconciliation_note >+ } >+ ); >+ >+ # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern) >+ print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid ); >+ exit; >+ >+ } else { >+ $template->param( error_cashup_amount => 1 ); >+ } > } else { > $template->param( error_cashup_permission => 1 ); > } >-- >2.50.1
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 40445
:
185289
|
185290
| 185291 |
185292