Bugzilla – Attachment 189421 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
Bug-40445-Add-cashup-reconciliation-functionality.patch (text/plain), 42.03 KB, created by
Martin Renvoize (ashimema)
on 2025-11-10 15:57:10 UTC
(
hide
)
Description:
Bug 40445: Add cashup reconciliation functionality
Filename:
MIME Type:
Creator:
Martin Renvoize (ashimema)
Created:
2025-11-10 15:57:10 UTC
Size:
42.03 KB
patch
obsolete
>From 149075b19e6643c2274d0bf032158fcee5868ff5 Mon Sep 17 00:00:00 2001 >From: Martin Renvoize <martin.renvoize@openfifth.co.uk> >Date: Fri, 8 Aug 2025 13:18:59 +0100 >Subject: [PATCH] Bug 40445: Add cashup reconciliation functionality > >Implements cashup reconciliation allowing staff to record actual cash >amounts and track surplus/deficit discrepancies. > >Backend changes: >- Enhanced add_cashup() to accept actual_amount and optional notes >- Automatic CASHUP_SURPLUS/DEFICIT accountline creation for discrepancies >- Transaction handling ensures atomicity of cashup and reconciliation >- outstanding_accountlines() excludes reconciliation entries > >Frontend changes: >- Interactive modal requiring actual amount entry >- Real-time surplus/deficit calculation >- Conditional note field for discrepancies (1000 char limit) >- Enhanced summary modal displays reconciliation details >- Full audit trail with timestamps and manager links > >Test plan: >1. Apply all patches and restart services >2. Run prove t/db_dependent/Koha/Cash/Register.t >3. Create test transactions on a register >4. Perform cashup with balanced amount - verify no reconciliation accountlines >5. Perform cashup with surplus - verify CASHUP_SURPLUS credit created >6. Perform cashup with deficit - verify CASHUP_DEFICIT debit created >7. Add notes to reconciliation - verify stored correctly >8. Start new cashup - verify previous reconciliation excluded from outstanding >9. View cashup summary - verify reconciliation displayed prominently >--- > Koha/Cash/Register.pm | 122 ++++- > 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 +- > t/db_dependent/Koha/Cash/Register.t | 435 +++++++++++++++++- > 7 files changed, 790 insertions(+), 31 deletions(-) > >diff --git a/Koha/Cash/Register.pm b/Koha/Cash/Register.pm >index 030d80d6080..9f2c17c376e 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; >@@ -125,6 +126,22 @@ sub outstanding_accountlines { > $since->count > ? { 'date' => { '>' => $since->get_column('timestamp')->as_query } } > : {}; >+ >+ # Exclude reconciliation accountlines from outstanding accountlines >+ $local_conditions->{'-and'} = [ >+ { >+ '-or' => [ >+ { 'credit_type_code' => { '!=' => 'CASHUP_SURPLUS' } }, >+ { 'credit_type_code' => undef } >+ ] >+ }, >+ { >+ '-or' => [ >+ { 'debit_type_code' => { '!=' => 'CASHUP_DEFICIT' } }, >+ { 'debit_type_code' => undef } >+ ] >+ } >+ ]; > my $merged_conditions = > $conditions > ? { %{$conditions}, %{$local_conditions} } >@@ -208,27 +225,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 f21c2b3a2fa..ea94abaf700 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 66e0533c74a..c8439f3f941 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 ); > } >diff --git a/t/db_dependent/Koha/Cash/Register.t b/t/db_dependent/Koha/Cash/Register.t >index 23b1cb59561..096de883cbb 100755 >--- a/t/db_dependent/Koha/Cash/Register.t >+++ b/t/db_dependent/Koha/Cash/Register.t >@@ -20,11 +20,14 @@ > use Modern::Perl; > > use Test::NoWarnings; >-use Test::More tests => 5; >+use Test::More tests => 6; > > use Test::Exception; > > use Koha::Database; >+use Koha::Account; >+use Koha::Account::CreditTypes; >+use Koha::Account::DebitTypes; > > use t::lib::TestBuilder; > >@@ -312,3 +315,433 @@ subtest 'cashup' => sub { > > $schema->storage->txn_rollback; > }; >+ >+subtest 'cashup_reconciliation' => sub { >+ plan tests => 5; >+ >+ $schema->storage->txn_begin; >+ >+ # Ensure required account types for reconciliation exist (they should already exist from mandatory data) >+ use Koha::Account::CreditTypes; >+ use Koha::Account::DebitTypes; >+ >+ my $surplus_credit_type = Koha::Account::CreditTypes->find( { code => 'CASHUP_SURPLUS' } ); >+ if ( !$surplus_credit_type ) { >+ $surplus_credit_type = $builder->build_object( >+ { >+ class => 'Koha::Account::CreditTypes', >+ value => { >+ code => 'CASHUP_SURPLUS', >+ description => 'Cash register surplus found during cashup', >+ can_be_added_manually => 0, >+ credit_number_enabled => 0, >+ is_system => 1, >+ archived => 0, >+ } >+ } >+ ); >+ } >+ >+ my $deficit_debit_type = Koha::Account::DebitTypes->find( { code => 'CASHUP_DEFICIT' } ); >+ if ( !$deficit_debit_type ) { >+ $deficit_debit_type = $builder->build_object( >+ { >+ class => 'Koha::Account::DebitTypes', >+ value => { >+ code => 'CASHUP_DEFICIT', >+ description => 'Cash register deficit found during cashup', >+ can_be_invoiced => 0, >+ can_be_sold => 0, >+ default_amount => undef, >+ is_system => 1, >+ archived => 0, >+ restricts_checkouts => 0, >+ } >+ } >+ ); >+ } >+ >+ my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Create some outstanding accountlines to establish expected amount >+ my $accountline1 = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register->id, >+ borrowernumber => $patron->id, >+ amount => -10.00, # Credit (payment) >+ credit_type_code => 'PAYMENT', >+ debit_type_code => undef, >+ } >+ } >+ ); >+ my $accountline2 = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register->id, >+ borrowernumber => $patron->id, >+ amount => -5.00, # Credit (payment) >+ credit_type_code => 'PAYMENT', >+ debit_type_code => undef, >+ } >+ } >+ ); >+ >+ my $expected_amount = $register->outstanding_accountlines->total; # Should be -15.00 >+ >+ subtest 'balanced_cashup' => sub { >+ plan tests => 3; >+ >+ # Test exact match - no surplus/deficit accountlines should be created >+ my $amount = abs($expected_amount); # 15.00 actual matches 15.00 expected >+ >+ my $cashup = $register->add_cashup( >+ { >+ manager_id => $patron->id, >+ amount => $amount >+ } >+ ); >+ >+ ok( $cashup, 'Cashup created successfully' ); >+ is( sprintf( '%.0f', $cashup->amount ), sprintf( '%.0f', $amount ), 'Cashup amount matches actual amount' ); >+ >+ # Check no surplus/deficit accountlines were created >+ my $reconciliation_lines = Koha::Account::Lines->search( >+ { >+ register_id => $register->id, >+ '-or' => [ >+ { credit_type_code => 'CASHUP_SURPLUS' }, >+ { debit_type_code => 'CASHUP_DEFICIT' } >+ ] >+ } >+ ); >+ >+ is( $reconciliation_lines->count, 0, 'No reconciliation accountlines created for balanced cashup' ); >+ }; >+ >+ subtest 'surplus_cashup' => sub { >+ plan tests => 7; >+ >+ $schema->storage->txn_begin; >+ >+ my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $accountline3 = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register2->id, >+ borrowernumber => $patron->id, >+ amount => -20.00, # Credit (payment) >+ credit_type_code => 'PAYMENT', >+ debit_type_code => undef, >+ } >+ } >+ ); >+ >+ my $expected = abs( $register2->outstanding_accountlines->total ); # 20.00 >+ my $actual = 25.00; # 5.00 surplus >+ my $surplus = $actual - $expected; >+ >+ my $cashup = $register2->add_cashup( >+ { >+ manager_id => $patron->id, >+ amount => $actual >+ } >+ ); >+ >+ ok( $cashup, 'Surplus cashup created successfully' ); >+ is( sprintf( '%.0f', $cashup->amount ), sprintf( '%.0f', $actual ), 'Cashup amount matches actual amount' ); >+ >+ # Check surplus accountline was created >+ my $surplus_lines = Koha::Account::Lines->search( >+ { >+ register_id => $register2->id, >+ credit_type_code => 'CASHUP_SURPLUS' >+ } >+ ); >+ >+ is( $surplus_lines->count, 1, 'One surplus accountline created' ); >+ >+ my $surplus_line = $surplus_lines->next; >+ is( >+ sprintf( '%.0f', $surplus_line->amount ), sprintf( '%.0f', -$surplus ), >+ 'Surplus amount is correct (negative for credit)' >+ ); >+ >+ # Note should be undef for surplus without user note >+ is( $surplus_line->note, undef, 'No note for surplus without user reconciliation note' ); >+ >+ # Test surplus with user note >+ my $register_with_note = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $accountline_with_note = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register_with_note->id, >+ borrowernumber => $patron->id, >+ amount => -10.00, >+ credit_type_code => 'PAYMENT', >+ debit_type_code => undef, >+ } >+ } >+ ); >+ >+ my $cashup_with_note = $register_with_note->add_cashup( >+ { >+ manager_id => $patron->id, >+ amount => 15.00, # 5.00 surplus >+ reconciliation_note => 'Found extra \x{00A3}5 under the till drawer' # £5 in UTF-8 >+ } >+ ); >+ >+ my $surplus_with_note = Koha::Account::Lines->search( >+ { >+ register_id => $register_with_note->id, >+ credit_type_code => 'CASHUP_SURPLUS' >+ } >+ )->next; >+ >+ like( >+ $surplus_with_note->note, qr/Found extra .+5 under the till drawer/, >+ 'User note included in surplus accountline' >+ ); >+ is( >+ $surplus_with_note->note, 'Found extra \x{00A3}5 under the till drawer', >+ 'Only user note stored (no base reconciliation info)' >+ ); >+ >+ $schema->storage->txn_rollback; >+ }; >+ >+ subtest 'deficit_cashup' => sub { >+ plan tests => 7; >+ >+ $schema->storage->txn_begin; >+ >+ my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $accountline4 = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register3->id, >+ borrowernumber => $patron->id, >+ amount => -30.00, # Credit (payment) >+ credit_type_code => 'PAYMENT', >+ debit_type_code => undef, >+ } >+ } >+ ); >+ >+ my $expected = abs( $register3->outstanding_accountlines->total ); # 30.00 >+ my $actual = 25.00; # 5.00 deficit >+ my $deficit = $expected - $actual; >+ >+ my $cashup = $register3->add_cashup( >+ { >+ manager_id => $patron->id, >+ amount => $actual >+ } >+ ); >+ >+ ok( $cashup, 'Deficit cashup created successfully' ); >+ is( sprintf( '%.0f', $cashup->amount ), sprintf( '%.0f', $actual ), 'Cashup amount matches actual amount' ); >+ >+ # Check deficit accountline was created >+ my $deficit_lines = Koha::Account::Lines->search( >+ { >+ register_id => $register3->id, >+ debit_type_code => 'CASHUP_DEFICIT' >+ } >+ ); >+ >+ is( $deficit_lines->count, 1, 'One deficit accountline created' ); >+ >+ my $deficit_line = $deficit_lines->next; >+ is( >+ sprintf( '%.0f', $deficit_line->amount ), sprintf( '%.0f', $deficit ), >+ 'Deficit amount is correct (positive for debit)' >+ ); >+ >+ # Note should be undef for deficit without user note >+ is( $deficit_line->note, undef, 'No note for deficit without user reconciliation note' ); >+ >+ # Test deficit with user note >+ my $register_deficit_note = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $accountline_deficit_note = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register_deficit_note->id, >+ borrowernumber => $patron->id, >+ amount => -20.00, >+ credit_type_code => 'PAYMENT', >+ debit_type_code => undef, >+ } >+ } >+ ); >+ >+ my $cashup_deficit_note = $register_deficit_note->add_cashup( >+ { >+ manager_id => $patron->id, >+ amount => 15.00, # 5.00 deficit >+ reconciliation_note => 'Till was short, possibly due to incorrect change given' >+ } >+ ); >+ >+ my $deficit_with_note = Koha::Account::Lines->search( >+ { >+ register_id => $register_deficit_note->id, >+ debit_type_code => 'CASHUP_DEFICIT' >+ } >+ )->next; >+ >+ like( >+ $deficit_with_note->note, qr/Till was short, possibly due to incorrect change given/, >+ 'User note included in deficit accountline' >+ ); >+ is( >+ $deficit_with_note->note, 'Till was short, possibly due to incorrect change given', >+ 'Only user note stored (no base reconciliation info)' >+ ); >+ >+ $schema->storage->txn_rollback; >+ }; >+ >+ subtest 'transaction_integrity' => sub { >+ plan tests => 4; >+ >+ $schema->storage->txn_begin; >+ >+ my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $accountline5 = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register4->id, >+ borrowernumber => $patron->id, >+ amount => -10.00, >+ credit_type_code => 'PAYMENT', >+ debit_type_code => undef, >+ } >+ } >+ ); >+ >+ my $initial_accountline_count = Koha::Account::Lines->search( { register_id => $register4->id } )->count; >+ >+ my $initial_action_count = $register4->cashups->count; >+ >+ # Test successful transaction >+ my $cashup = $register4->add_cashup( >+ { >+ manager_id => $patron->id, >+ amount => 15.00 # Creates surplus >+ } >+ ); >+ >+ # Check both cashup action and surplus accountline were created >+ is( $register4->cashups->count, $initial_action_count + 1, 'Cashup action created' ); >+ >+ my $final_accountline_count = Koha::Account::Lines->search( { register_id => $register4->id } )->count; >+ >+ is( $final_accountline_count, $initial_accountline_count + 1, 'Surplus accountline created' ); >+ >+ # Verify the new accountline is the surplus >+ my $surplus_line = Koha::Account::Lines->search( >+ { >+ register_id => $register4->id, >+ credit_type_code => 'CASHUP_SURPLUS' >+ } >+ )->next; >+ >+ ok( $surplus_line, 'Surplus accountline exists' ); >+ is( $surplus_line->register_id, $register4->id, 'Surplus linked to correct register' ); >+ >+ $schema->storage->txn_rollback; >+ }; >+ >+ subtest 'note_handling' => sub { >+ plan tests => 2; >+ >+ $schema->storage->txn_begin; >+ >+ my $register_note_test = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $accountline_note_test = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register_note_test->id, >+ borrowernumber => $patron->id, >+ amount => -10.00, >+ credit_type_code => 'PAYMENT', >+ debit_type_code => undef, >+ } >+ } >+ ); >+ >+ # Test balanced cashup with note (should not create surplus/deficit) >+ my $balanced_cashup = $register_note_test->add_cashup( >+ { >+ manager_id => $patron->id, >+ amount => 10.00, # Balanced >+ reconciliation_note => 'This note should be ignored for balanced cashup' >+ } >+ ); >+ >+ my $balanced_reconciliation_lines = Koha::Account::Lines->search( >+ { >+ register_id => $register_note_test->id, >+ '-or' => [ >+ { credit_type_code => 'CASHUP_SURPLUS' }, >+ { debit_type_code => 'CASHUP_DEFICIT' } >+ ] >+ } >+ ); >+ >+ is( >+ $balanced_reconciliation_lines->count, 0, >+ 'No reconciliation accountlines created for balanced cashup with note' >+ ); >+ >+ # Test empty/whitespace note handling >+ my $register_empty_note = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $accountline_empty_note = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register_empty_note->id, >+ borrowernumber => $patron->id, >+ amount => -10.00, >+ credit_type_code => 'PAYMENT', >+ debit_type_code => undef, >+ } >+ } >+ ); >+ >+ my $empty_note_cashup = $register_empty_note->add_cashup( >+ { >+ manager_id => $patron->id, >+ amount => 12.00, # 2.00 surplus >+ reconciliation_note => ' ' # Whitespace only >+ } >+ ); >+ >+ my $empty_note_surplus = Koha::Account::Lines->search( >+ { >+ register_id => $register_empty_note->id, >+ credit_type_code => 'CASHUP_SURPLUS' >+ } >+ )->next; >+ >+ is( >+ $empty_note_surplus->note, undef, >+ 'No note stored when user note is empty/whitespace' >+ ); >+ >+ $schema->storage->txn_rollback; >+ }; >+ >+ $schema->storage->txn_rollback; >+}; >-- >2.51.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
|
185809
|
186636
|
186637
|
186638
|
186639
|
186640
|
186641
|
186642
|
186643
|
189333
|
189334
|
189335
|
189336
|
189337
|
189338
|
189339
|
189340
|
189341
|
189353
|
189354
|
189355
|
189356
|
189357
|
189358
|
189359
|
189360
|
189361
|
189362
|
189363
|
189364
|
189410
|
189411
|
189412
|
189413
|
189414
|
189415
|
189420
|
189421
|
189422
|
189423
|
189424
|
189425
|
189426
|
189427
|
189428
|
189429
|
189430
|
189476
|
189477
|
189478
|
189479
|
189480
|
189481
|
189482