Bugzilla – Attachment 186640 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: Implement two-phase cashup workflow for point of sale
Bug-40445-Implement-two-phase-cashup-workflow-for-.patch (text/plain), 79.24 KB, created by
Martin Renvoize (ashimema)
on 2025-09-21 11:55:03 UTC
(
hide
)
Description:
Bug 40445: Implement two-phase cashup workflow for point of sale
Filename:
MIME Type:
Creator:
Martin Renvoize (ashimema)
Created:
2025-09-21 11:55:03 UTC
Size:
79.24 KB
patch
obsolete
>From 5411250b9c34160e262a9dc0a7719ddc383b8ad2 Mon Sep 17 00:00:00 2001 >From: Martin Renvoize <martin.renvoize@openfifth.co.uk> >Date: Tue, 16 Sep 2025 17:41:31 +0100 >Subject: [PATCH] Bug 40445: Implement two-phase cashup workflow for point of > sale > >This commit introduces a two-phase cashup system that allows staff to start >a cashup session, remove cash for counting, and complete the cashup later >with reconciliation against the actual counted amount. > >Key changes: > >1. **New cashup workflow methods**: > - start_cashup(): Creates CASHUP_START action to begin counting session > - cashup_in_progress(): Checks if a cashup session is active > - Enhanced add_cashup(): Supports both legacy 'Quick cashup' and new > two-phase modes > >2. **Improved session boundary calculation**: > - _get_session_start_timestamp(): Handles mixed quick/two-phase workflows > - outstanding_accountlines(): Uses session boundaries instead of last CASHUP > - Session boundaries correctly account for CASHUP_START timestamps > >3. **Enhanced reconciliation logic**: > - Reconciliation lines are backdated appropriately for each mode > - Two-phase mode: Backdate to before CASHUP_START timestamp > - Legacy mode: Backdate to before current time > >4. **Updated cashup summary calculations**: > - _get_session_boundaries(): Properly calculates session start/end > - accountlines(): Returns session-specific accountlines > - summary(): Uses correct session boundaries for transaction grouping > >5. **Register interface updates**: > - Dynamic toolbar shows "Start cashup" vs "Complete cashup" > - Status indicator when cashup is in progress > - Dual-workflow modal with quick and two-phase options > >6. **Comprehensive test coverage**: > - Two-phase workflow scenarios > - Session boundary calculations > - Mixed workflow compatibility > - Error handling for duplicate operations > >This implementation maintains full backward compatibility while enabling >libraries to separate cash counting from register operation, supporting >real-world workflows where counting takes time. >--- > Koha/Cash/Register.pm | 302 ++++++- > Koha/Cash/Register/Cashup.pm | 144 +++- > .../prog/en/modules/pos/register.tt | 105 ++- > pos/register.pl | 67 +- > t/db_dependent/Koha/Cash/Register.t | 767 ++++++++++++++++-- > t/db_dependent/Koha/Cash/Register/Cashup.t | 277 ++++++- > 6 files changed, 1516 insertions(+), 146 deletions(-) > >diff --git a/Koha/Cash/Register.pm b/Koha/Cash/Register.pm >index b67d91ad6af..293021b0ef9 100644 >--- a/Koha/Cash/Register.pm >+++ b/Koha/Cash/Register.pm >@@ -16,6 +16,8 @@ package Koha::Cash::Register; > # along with Koha; if not, see <http://www.gnu.org/licenses>. > > use Modern::Perl; >+use DateTime; >+use Scalar::Util qw( looks_like_number ); > > use Koha::Account; > use Koha::Account::Lines; >@@ -23,6 +25,7 @@ use Koha::Account::Offsets; > use Koha::Cash::Register::Actions; > use Koha::Cash::Register::Cashups; > use Koha::Database; >+use Koha::DateUtils qw( dt_from_string ); > > use base qw(Koha::Object); > >@@ -113,35 +116,14 @@ Return a set of accountlines linked to this cash register since the last cashup > sub outstanding_accountlines { > my ( $self, $conditions, $attrs ) = @_; > >- my $since = $self->_result->search_related( >- 'cash_register_actions', >- { 'code' => 'CASHUP' }, >- { >- order_by => { '-desc' => [ 'timestamp', 'id' ] }, >- rows => 1 >- } >- ); >+ # Find the start timestamp for the current "open" session >+ my $start_timestamp = $self->_get_session_start_timestamp; > > my $local_conditions = >- $since->count >- ? { 'date' => { '>' => $since->get_column('timestamp')->as_query } } >+ $start_timestamp >+ ? { 'date' => { '>' => $start_timestamp } } > : {}; > >- # 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} } >@@ -155,6 +137,40 @@ sub outstanding_accountlines { > return Koha::Account::Lines->_new_from_dbic($rs); > } > >+=head3 cashup_in_progress >+ >+Check if there is currently a cashup in progress (CASHUP_START without corresponding CASHUP). >+Returns the CASHUP_START action if in progress, undef otherwise. >+ >+=cut >+ >+sub cashup_in_progress { >+ my ($self) = @_; >+ >+ my $last_start = $self->_result->search_related( >+ 'cash_register_actions', >+ { 'code' => 'CASHUP_START' }, >+ { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 } >+ )->single; >+ >+ return unless $last_start; >+ >+ my $last_completion = $self->cashups( >+ {}, >+ { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 } >+ )->single; >+ >+ # If we have a start but no completion, or the start is more recent than completion >+ if ( !$last_completion >+ || DateTime->compare( dt_from_string( $last_start->timestamp ), dt_from_string( $last_completion->timestamp ) ) >+ > 0 ) >+ { >+ return Koha::Cash::Register::Action->_new_from_dbic($last_start); >+ } >+ >+ return; >+} >+ > =head3 store > > Local store method to prevent direct manipulation of the 'branch_default' field >@@ -221,6 +237,72 @@ sub drop_default { > return $self; > } > >+=head3 start_cashup >+ >+ my $cashup_start = $cash_register->start_cashup( >+ { >+ manager_id => $logged_in_user->id, >+ } >+ ); >+ >+Start a new cashup period. This marks the beginning of the cash counting process >+and creates a snapshot point for calculating outstanding amounts. Returns the >+CASHUP_START action. >+ >+=cut >+ >+sub start_cashup { >+ my ( $self, $params ) = @_; >+ >+ # check for mandatory params >+ my @mandatory = ('manager_id'); >+ for my $param (@mandatory) { >+ unless ( defined( $params->{$param} ) ) { >+ Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" ); >+ } >+ } >+ my $manager_id = $params->{manager_id}; >+ >+ # Check if there's already a cashup in progress >+ my $last_cashup_start_rs = $self->_result->search_related( >+ 'cash_register_actions', >+ { 'code' => 'CASHUP_START' }, >+ { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 } >+ )->single; >+ >+ my $last_cashup_completed = $self->cashups( >+ {}, >+ { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 } >+ )->single; >+ >+ # If we have a CASHUP_START that's more recent than the last CASHUP, there's already an active cashup >+ if ( >+ $last_cashup_start_rs >+ && ( >+ !$last_cashup_completed || DateTime->compare( >+ dt_from_string( $last_cashup_start_rs->timestamp ), >+ dt_from_string( $last_cashup_completed->timestamp ) >+ ) > 0 >+ ) >+ ) >+ { >+ Koha::Exceptions::Object::DuplicateID->throw( error => "A cashup is already in progress for this register" ); >+ } >+ >+ my $expected_amount = abs( $self->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) ); >+ >+ # Create the CASHUP_START action >+ my $rs = $self->_result->add_to_cash_register_actions( >+ { >+ code => 'CASHUP_START', >+ manager_id => $manager_id, >+ amount => $expected_amount >+ } >+ )->discard_changes; >+ >+ return Koha::Cash::Register::Cashup->_new_from_dbic($rs); >+} >+ > =head3 add_cashup > > my $cashup = $cash_register->add_cashup( >@@ -231,33 +313,73 @@ sub drop_default { > } > ); > >-Add a new cashup action to the till, returns the added action. >-If amount differs from expected amount, creates surplus/deficit accountlines. >+Complete a cashup period started with start_cashup(). This performs the actual >+reconciliation against the amount counted and creates surplus/deficit accountlines >+if needed. Returns the completed CASHUP action. > > =cut > > sub add_cashup { > my ( $self, $params ) = @_; > >- my $manager_id = $params->{manager_id}; >- my $amount = $params->{amount}; >- my $reconciliation_note = $params->{reconciliation_note}; >+ # check for mandatory params >+ my @mandatory = ( 'manager_id', 'amount' ); >+ for my $param (@mandatory) { >+ unless ( defined( $params->{$param} ) ) { >+ Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" ); >+ } >+ } >+ my $manager_id = $params->{manager_id}; >+ >+ # Validate amount should always be a positive value >+ my $amount = $params->{amount}; >+ unless ( looks_like_number($amount) && $amount > 0 ) { >+ Koha::Exceptions::Account::AmountNotPositive->throw( error => 'Cashup amount passed is not positive number' ); >+ } > > # Sanitize reconciliation note - treat empty/whitespace-only as undef >+ my $reconciliation_note = $params->{reconciliation_note}; > 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; >+ # Find the most recent CASHUP_START to determine if we're in two-phase mode >+ my $cashup_start; >+ my $cashup_start_rs = $self->_result->search_related( >+ 'cash_register_actions', >+ { 'code' => 'CASHUP_START' }, >+ { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 } >+ )->single; >+ >+ if ($cashup_start_rs) { > >- # For backward compatibility, if no actual amount is specified, use expected amount >- $amount //= abs($expected_amount); >+ # Two-phase mode: Check if this CASHUP_START has already been completed >+ my $existing_completion = $self->_result->search_related( >+ 'cash_register_actions', >+ { >+ 'code' => 'CASHUP', >+ 'timestamp' => { '>' => $cashup_start_rs->timestamp } >+ }, >+ { rows => 1 } >+ )->single; >+ >+ if ( !$existing_completion ) { >+ $cashup_start = Koha::Cash::Register::Cashup->_new_from_dbic($cashup_start_rs); >+ } >+ >+ } >+ >+ # Calculate expected amount from session accountlines >+ my $expected_amount = ( >+ $cashup_start >+ ? $cashup_start->accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) >+ : $self->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) >+ ) * -1; > > # Calculate difference (actual - expected) >- my $difference = $amount - abs($expected_amount); >+ my $difference = $amount - $expected_amount; > > # Use database transaction to ensure consistency > my $schema = $self->_result->result_source->schema; >@@ -279,14 +401,27 @@ sub add_cashup { > # Create reconciliation accountline if there's a difference > if ( $difference != 0 ) { > >+ # Determine reconciliation date based on mode >+ my $reconciliation_date; >+ if ($cashup_start) { >+ >+ # Two-phase mode: Backdate reconciliation lines to just before the CASHUP_START timestamp >+ # This ensures they belong to the previous session, not the current one >+ my $timestamp_str = "DATE_SUB('" . $cashup_start->timestamp . "', INTERVAL 1 SECOND)"; >+ $reconciliation_date = \$timestamp_str; >+ } else { >+ >+ # Legacy mode: Use the original backdating approach >+ $reconciliation_date = \'DATE_SUB(NOW(), INTERVAL 1 SECOND)'; >+ } >+ > 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', >+ date => $reconciliation_date, >+ amount => -abs($difference), # Credits are negative > credit_type_code => 'CASHUP_SURPLUS', > manager_id => $manager_id, > interface => 'intranet', >@@ -309,9 +444,8 @@ sub add_cashup { > # Deficit: less cash found than expected > my $deficit = Koha::Account::Line->new( > { >- date => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)', >+ date => $reconciliation_date, > amount => abs($difference), >- description => 'Cash register deficit found during cashup', > debit_type_code => 'CASHUP_DEFICIT', > manager_id => $manager_id, > interface => 'intranet', >@@ -335,6 +469,94 @@ sub add_cashup { > return $cashup; > } > >+=head3 _get_session_start_timestamp >+ >+Internal method to determine the start timestamp for the current "open" session. >+This handles the following cashup scenarios: >+ >+=over 4 >+ >+=item 1. No cashups ever â undef (returns all accountlines) >+ >+=item 2. Quick cashup completed â Uses CASHUP timestamp >+ >+=item 3. Two-phase started â Uses CASHUP_START timestamp >+ >+=item 4. Two-phase completed â Uses the CASHUP_START timestamp that led to the last CASHUP >+ >+=item 5. Mixed workflows â Correctly distinguishes between quick and two-phase cashups >+ >+=back >+ >+=cut >+ >+sub _get_session_start_timestamp { >+ my ($self) = @_; >+ >+ # Check if there's a cashup in progress (CASHUP_START without corresponding CASHUP) >+ my $cashup_in_progress = $self->cashup_in_progress; >+ >+ if ($cashup_in_progress) { >+ >+ # Scenario 3: Two-phase cashup started - return accountlines since CASHUP_START >+ return $cashup_in_progress->timestamp; >+ } >+ >+ # No cashup in progress - find the most recent cashup completion >+ my $last_cashup = $self->cashups( >+ {}, >+ { >+ order_by => { '-desc' => [ 'timestamp', 'id' ] }, >+ rows => 1 >+ } >+ )->single; >+ >+ if ( !$last_cashup ) { >+ >+ # Scenario 1: No cashups have ever taken place - return all accountlines >+ return; >+ } >+ >+ # Find if this CASHUP was part of a two-phase workflow >+ my $corresponding_start = $self->_result->search_related( >+ 'cash_register_actions', >+ { >+ 'code' => 'CASHUP_START', >+ 'timestamp' => { '<' => $last_cashup->timestamp } >+ }, >+ { >+ order_by => { '-desc' => [ 'timestamp', 'id' ] }, >+ rows => 1 >+ } >+ )->single; >+ >+ if ($corresponding_start) { >+ >+ # Check if this CASHUP_START was completed by this CASHUP >+ # (no other CASHUP between them) >+ my $intervening_cashup = $self->_result->search_related( >+ 'cash_register_actions', >+ { >+ 'code' => 'CASHUP', >+ 'timestamp' => { >+ '>' => $corresponding_start->timestamp, >+ '<' => $last_cashup->timestamp >+ } >+ }, >+ { rows => 1 } >+ )->single; >+ >+ if ( !$intervening_cashup ) { >+ >+ # Scenario 4: Two-phase cashup completed - return accountlines since the CASHUP_START >+ return $corresponding_start->timestamp; >+ } >+ } >+ >+ # Scenarios 2 & 5: Quick cashup (or orphaned CASHUP) - return accountlines since CASHUP >+ return $last_cashup->timestamp; >+} >+ > =head3 to_api_mapping > > This method returns the mapping for representing a Koha::Cash::Register object >diff --git a/Koha/Cash/Register/Cashup.pm b/Koha/Cash/Register/Cashup.pm >index 819bcff29f1..8627a64b360 100644 >--- a/Koha/Cash/Register/Cashup.pm >+++ b/Koha/Cash/Register/Cashup.pm >@@ -61,23 +61,29 @@ Return a hashref containing a summary of transactions that make up this cashup. > sub summary { > my ($self) = @_; > my $summary; >- my $prior_cashup = Koha::Cash::Register::Cashups->search( >- { >- 'timestamp' => { '<' => $self->timestamp }, >- register_id => $self->register_id >- }, >- { >- order_by => { '-desc' => [ 'timestamp', 'id' ] }, >- rows => 1 >- } >- ); > >- my $previous = $prior_cashup->single; >+ # Get the session boundaries for this cashup >+ my ( $session_start, $session_end ) = $self->_get_session_boundaries; > >- my $conditions = >- $previous >- ? { 'date' => { '-between' => [ $previous->_result->get_column('timestamp'), $self->timestamp ] } } >- : { 'date' => { '<' => $self->timestamp } }; >+ my $conditions; >+ if ( $session_start && $session_end ) { >+ >+ # Complete session: between start and end (exclusive) >+ $conditions = { >+ 'date' => { >+ '>' => $session_start, >+ '<' => $session_end >+ } >+ }; >+ } elsif ($session_end) { >+ >+ # Session from beginning to end >+ $conditions = { 'date' => { '<' => $session_end } }; >+ } else { >+ >+ # Shouldn't happen for a completed cashup, but fallback >+ $conditions = { 'date' => { '<' => $self->timestamp } }; >+ } > > my $payout_transactions = $self->register->accountlines->search( > { >@@ -191,8 +197,8 @@ sub summary { > my $deficit_total = $deficit_lines->count ? $deficit_lines->total : undef; > > $summary = { >- from_date => $previous ? $previous->timestamp : undef, >- to_date => $self->timestamp, >+ from_date => $session_start, >+ to_date => $session_end, > income_grouped => \@income, > income_total => abs($income_total), > payout_grouped => \@payout, >@@ -208,6 +214,110 @@ sub summary { > return $summary; > } > >+=head3 accountlines >+ >+Fetch the accountlines associated with this cashup >+ >+=cut >+ >+sub accountlines { >+ my ($self) = @_; >+ >+ # Get the session boundaries for this cashup >+ my ( $session_start, $session_end ) = $self->_get_session_boundaries; >+ >+ my $conditions; >+ if ( $session_start && $session_end ) { >+ >+ # Complete session: between start and end (exclusive) >+ $conditions = { >+ 'date' => { >+ '>' => $session_start, >+ '<' => $session_end >+ } >+ }; >+ } elsif ($session_end) { >+ >+ # Session from beginning to end >+ $conditions = { 'date' => { '<' => $session_end } }; >+ } else { >+ >+ # Shouldn't happen for a completed cashup, but fallback >+ $conditions = { 'date' => { '<' => $self->timestamp } }; >+ } >+ >+ return $self->register->accountlines->search($conditions); >+} >+ >+=head3 _get_session_boundaries >+ >+Internal method to determine the session boundaries for this cashup. >+Returns ($session_start, $session_end) timestamps. >+ >+=cut >+ >+sub _get_session_boundaries { >+ my ($self) = @_; >+ >+ my $session_end = $self->_get_session_end; >+ >+ # Find the previous CASHUP >+ my $session_start; >+ my $previous_cashup = $self->register->cashups( >+ { 'timestamp' => { '<' => $session_end } }, >+ { >+ order_by => { '-desc' => [ 'timestamp', 'id' ] }, >+ rows => 1 >+ } >+ )->single; >+ >+ $session_start = $previous_cashup ? $previous_cashup->_get_session_end : undef; >+ >+ return ( $session_start, $session_end ); >+} >+ >+sub _get_session_end { >+ my ($self) = @_; >+ >+ my $session_end = $self->timestamp; >+ >+ # Find if this CASHUP was part of a two-phase workflow >+ my $nearest_start = $self->register->_result->search_related( >+ 'cash_register_actions', >+ { >+ 'code' => 'CASHUP_START', >+ 'timestamp' => { '<' => $session_end } >+ }, >+ { >+ order_by => { '-desc' => [ 'timestamp', 'id' ] }, >+ rows => 1 >+ } >+ )->single; >+ >+ if ($nearest_start) { >+ >+ # Check if this CASHUP_START was completed by this CASHUP >+ # (no other CASHUP between them) >+ my $intervening_cashup = $self->register->cashups( >+ { >+ 'timestamp' => { >+ '>' => $nearest_start->timestamp, >+ '<' => $session_end >+ } >+ }, >+ { rows => 1 } >+ )->single; >+ >+ if ( !$intervening_cashup ) { >+ >+ # Two-phase workflow: session runs to CASHUP_START >+ $session_end = $nearest_start->timestamp; >+ } >+ } >+ >+ return $session_end; >+} >+ > =head3 to_api_mapping > > This method returns the mapping for representing a Koha::Cash::Register::Cashup object >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 44fc6a6ae8e..2f012023fd1 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/pos/register.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/pos/register.tt >@@ -52,13 +52,44 @@ > <div id="error_message" class="alert alert-warning"> Invalid amount entered for cashup. Please enter a valid monetary amount. </div> > [% END %] > >+ [% IF ( error_cashup_in_progress ) %] >+ <div id="error_message" class="alert alert-warning"> A cashup is already in progress for this register. </div> >+ [% END %] >+ >+ [% IF ( error_no_cashup_start ) %] >+ <div id="error_message" class="alert alert-warning"> No cashup session has been started. Please start a cashup before attempting to complete it. </div> >+ [% END %] >+ >+ [% IF ( error_cashup_already_completed ) %] >+ <div id="error_message" class="alert alert-warning"> This cashup session has already been completed. </div> >+ [% END %] >+ >+ [% IF ( error_cashup_start ) %] >+ <div id="error_message" class="alert alert-warning"> Failed to start cashup. Please try again. </div> >+ [% END %] >+ >+ [% IF ( error_cashup_complete ) %] >+ <div id="error_message" class="alert alert-warning"> Failed to complete cashup. Please try again. </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 %] > >+ [% IF cashup_in_progress %] >+ <div class="alert alert-warning"> >+ <i class="fa-solid fa-info-circle"></i> >+ Cashup in progress - started [% cashup_in_progress.timestamp | $KohaDates with_hours => 1 %]. You can continue to make transactions while counting cash. >+ </div> >+ [% END %] >+ > [% IF ( CAN_user_cash_management_cashup ) %] > <div id="toolbar" class="btn-toolbar"> >- <button id="pos_cashup" type="button" class="btn btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupModal"><i class="fa-solid fa-money-bill-1"></i> Record cashup</button> >+ [% IF cashup_in_progress %] >+ <button id="pos_complete_cashup" type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#confirmCashupModal"> <i class="fa-solid fa-check"></i> Complete cashup </button> >+ [% ELSE %] >+ <button type="button" class="btn btn-default" data-bs-toggle="modal" data-bs-target="#triggerCashupModal"> <i class="fa fa-money-bill-alt"></i> Record cashup </button> >+ [% END %] > </div> > [% END %] > >@@ -375,22 +406,36 @@ > <div class="modal-dialog"> > <div class="modal-content"> > <div class="modal-header"> >- <h1 class="modal-title" id="confirmCashupLabel">Confirm cashup of <em>[% register.description | html %]</em></h1> >+ <h1 class="modal-title" id="confirmCashupLabel"> >+ [% IF cashup_in_progress %] >+ Complete cashup of <em>[% register.description | html %]</em> >+ [% ELSE %] >+ Confirm cashup of <em>[% register.description | html %]</em> >+ [% END %] >+ </h1> > <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> > </div> > <div class="modal-body"> > <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> >+ <span class="label"> >+ [% IF cashup_in_progress %] >+ Expected cashup amount: >+ [% ELSE %] >+ Expected amount to remove: >+ [% END %] >+ </span> >+ <span id="expected_amount" class="expected-amount">[% cashup_in_progress.amount | $Price %]</span> > </li> > <li> >- <label class="required" for="amount">Actual amount removed from register:</label> >+ <label class="required" for="amount"> >+ [% IF cashup_in_progress %] >+ Actual cashup amount counted: >+ [% ELSE %] >+ Actual amount removed from register: >+ [% END %] >+ </label> > <input type="text" inputmode="decimal" pattern="^\d+(\.\d{2})?$" id="amount" name="amount" required="required" /> > <span class="required">Required</span> > </li> >@@ -410,7 +455,7 @@ > <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 cashup</button> >+ <button type="submit" class="btn btn-primary" id="pos_cashup_confirm"> [% IF cashup_in_progress %]Complete cashup[% ELSE %]Confirm cashup[% END %] </button> > <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button> > </div> > <!-- /.modal-footer --> >@@ -465,6 +510,46 @@ > </div> > <!-- /#issueRefundModal --> > >+<!-- Trigger cashup modal --> >+<div class="modal" id="triggerCashupModal" tabindex="-1" role="dialog" aria-labelledby="triggerCashupLabel"> >+ <form method="post" class="validated"> >+ [% INCLUDE 'csrf-token.inc' %] >+ <div class="modal-dialog"> >+ <div class="modal-content"> >+ <div class="modal-header"> >+ <h1 class="modal-title" id="triggerCashupLabel"> Cashup for <em>[% register.description | html %]</em> </h1> >+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> >+ </div> >+ <div class="modal-body"> >+ <p><strong>Choose how to proceed with the cashup:</strong></p> >+ <p><strong>Start cashup</strong></p> >+ <ul> >+ <li>Remove cash from the register for counting</li> >+ <li>The register can continue operating during counting</li> >+ <li>Complete the cashup once counted</li> >+ </ul> >+ <p><strong>Quick cashup</strong></p> >+ <ul> >+ <li>Confirm you have removed [% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %] cash from the register to bank immediately</li> >+ </ul> >+ <p>Remember to leave the float amount of <strong>[% register.starting_float | $Price %]</strong> in the register.</p> >+ </div> >+ <div class="modal-footer"> >+ <input type="hidden" name="registerid" value="[% register.id | html %]" /> >+ <input type="hidden" name="op" value="cud-cashup_start" /> >+ <input type="hidden" name="amount" value="" /> >+ <button type="submit" class="btn btn-primary">Start cashup</button> >+ <button type="button" class="btn btn-success" onclick="this.form.op.value='cud-cashup'; this.form.amount.value='[% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | html %]'; this.form.submit();" >+ >Quick cashup</button >+ > >+ <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button> >+ </div> >+ </div> >+ </div> >+ </form> >+</div> >+<!-- /#triggerCashupModal --> >+ > [% INCLUDE 'modals/cashup_summary.inc' %] > > [% MACRO jsinclude BLOCK %] >diff --git a/pos/register.pl b/pos/register.pl >index 7fb8c70f08a..a4d8526acc5 100755 >--- a/pos/register.pl >+++ b/pos/register.pl >@@ -63,11 +63,14 @@ if ( !$registers->count ) { > registers => $registers, > ); > >- my $cash_register = Koha::Cash::Registers->find( { id => $registerid } ); >- my $accountlines = $cash_register->outstanding_accountlines(); >+ my $cash_register = Koha::Cash::Registers->find( { id => $registerid } ); >+ my $accountlines = $cash_register->outstanding_accountlines(); >+ my $cashup_in_progress = $cash_register->cashup_in_progress(); >+ > $template->param( >- register => $cash_register, >- accountlines => $accountlines >+ register => $cash_register, >+ accountlines => $accountlines, >+ cashup_in_progress => $cashup_in_progress, > ); > > my $transactions_range_from = $input->param('trange_f'); >@@ -102,7 +105,31 @@ if ( !$registers->count ) { > $template->param( trange_t => $end, ); > > my $op = $input->param('op') // ''; >- if ( $op eq 'cud-cashup' ) { >+ if ( $op eq 'cud-cashup_start' ) { >+ if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) { >+ eval { >+ $cash_register->start_cashup( >+ { >+ manager_id => $logged_in_user->id, >+ } >+ ); >+ }; >+ if ($@) { >+ if ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) { >+ $template->param( error_cashup_in_progress => 1 ); >+ } else { >+ $template->param( error_cashup_start => 1 ); >+ } >+ } else { >+ >+ # 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_permission => 1 ); >+ } >+ } elsif ( $op eq 'cud-cashup' ) { > if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) { > my $amount = $input->param('amount'); > my $reconciliation_note = $input->param('reconciliation_note'); >@@ -116,17 +143,29 @@ if ( !$registers->count ) { > $reconciliation_note = undef if $reconciliation_note eq ''; > } > >- $cash_register->add_cashup( >- { >- manager_id => $logged_in_user->id, >- amount => $amount, >- reconciliation_note => $reconciliation_note >+ eval { >+ $cash_register->add_cashup( >+ { >+ manager_id => $logged_in_user->id, >+ amount => $amount, >+ reconciliation_note => $reconciliation_note >+ } >+ ); >+ }; >+ if ($@) { >+ if ( $@->isa('Koha::Exceptions::Object::BadValue') ) { >+ $template->param( error_no_cashup_start => 1 ); >+ } elsif ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) { >+ $template->param( error_cashup_already_completed => 1 ); >+ } else { >+ $template->param( error_cashup_complete => 1 ); > } >- ); >+ } else { > >- # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern) >- print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid ); >- exit; >+ # 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 ); >diff --git a/t/db_dependent/Koha/Cash/Register.t b/t/db_dependent/Koha/Cash/Register.t >index 4267e94babd..e227fb9777c 100755 >--- a/t/db_dependent/Koha/Cash/Register.t >+++ b/t/db_dependent/Koha/Cash/Register.t >@@ -20,7 +20,7 @@ > use Modern::Perl; > > use Test::NoWarnings; >-use Test::More tests => 6; >+use Test::More tests => 10; > > use Test::Exception; > >@@ -260,7 +260,10 @@ subtest 'cashup' => sub { > subtest 'outstanding_accountlines' => sub { > plan tests => 6; > >- my $accountlines = $register->outstanding_accountlines; >+ $schema->storage->txn_begin; >+ >+ my $test_register = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $accountlines = $test_register->outstanding_accountlines; > is( > ref($accountlines), 'Koha::Account::Lines', > 'Koha::Cash::Register->outstanding_accountlines should always return a Koha::Account::Lines set' >@@ -270,39 +273,55 @@ subtest 'cashup' => sub { > 'Koha::Cash::Register->outstanding_accountlines should always return the correct number of accountlines' > ); > >+ my $test_patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ > my $accountline1 = $builder->build_object( > { > class => 'Koha::Account::Lines', >- value => { register_id => $register->id, date => \'NOW() - INTERVAL 5 MINUTE' }, >+ value => { >+ register_id => $test_register->id, >+ amount => -2.50, >+ date => \'SYSDATE() - INTERVAL 5 MINUTE', >+ payment_type => 'CASH' >+ }, > } > ); > my $accountline2 = $builder->build_object( > { > class => 'Koha::Account::Lines', >- value => { register_id => $register->id, date => \'NOW() - INTERVAL 5 MINUTE' }, >+ value => { >+ register_id => $test_register->id, >+ amount => -1.50, >+ date => \'SYSDATE() - INTERVAL 5 MINUTE', >+ payment_type => 'CASH' >+ }, > } > ); > >- $accountlines = $register->outstanding_accountlines; >+ $accountlines = $test_register->outstanding_accountlines; > is( $accountlines->count, 2, 'No cashup, all accountlines returned' ); > >- my $cashup3 = $register->add_cashup( { manager_id => $patron->id, amount => '2.50' } ); >+ # Calculate expected amount for this cashup >+ my $expected_amount = >+ ( $test_register->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) ) * -1; >+ my $cashup3 = $test_register->add_cashup( { manager_id => $test_patron->id, amount => $expected_amount } ); > >- $accountlines = $register->outstanding_accountlines; >+ $accountlines = $test_register->outstanding_accountlines; > is( $accountlines->count, 0, 'Cashup added, no accountlines returned' ); > > my $accountline3 = $builder->build_object( > { > class => 'Koha::Account::Lines', >- value => { register_id => $register->id }, >+ value => { >+ register_id => $test_register->id, >+ amount => 1.50, >+ date => \'SYSDATE() + INTERVAL 5 MINUTE', >+ payment_type => 'CASH' >+ }, > } > ); > >- # Fake the cashup timestamp to make sure it's before the accountline we just added, >- # we can't trust that these two actions are more than a second apart in a test >- $cashup3->timestamp( \'NOW() - INTERVAL 2 MINUTE' )->store; >- >- $accountlines = $register->outstanding_accountlines; >+ $accountlines = $test_register->outstanding_accountlines; > is( > $accountlines->count, 1, > 'Accountline added, one accountline returned' >@@ -311,56 +330,18 @@ subtest 'cashup' => sub { > $accountlines->next->id, > $accountline3->id, 'Correct accountline returned' > ); >+ >+ $schema->storage->txn_rollback; > }; > > $schema->storage->txn_rollback; > }; > > subtest 'cashup_reconciliation' => sub { >- plan tests => 5; >+ plan tests => 6; > > $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' } ); > >@@ -371,9 +352,12 @@ subtest 'cashup_reconciliation' => sub { > value => { > register_id => $register->id, > borrowernumber => $patron->id, >- amount => -10.00, # Credit (payment) >+ amount => -10.00, # Credit (payment) > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', >+ date => \'SYSDATE() - INTERVAL 1 MINUTE', >+ timestamp => \'SYSDATE() - INTERVAL 1 MINUTE', > } > } > ); >@@ -383,20 +367,27 @@ subtest 'cashup_reconciliation' => sub { > value => { > register_id => $register->id, > borrowernumber => $patron->id, >- amount => -5.00, # Credit (payment) >+ amount => -5.00, # Credit (payment) > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', >+ date => \'SYSDATE() - INTERVAL 1 MINUTE', >+ timestamp => \'SYSDATE() - INTERVAL 1 MINUTE', > } > } > ); > >- my $expected_amount = $register->outstanding_accountlines->total; # Should be -15.00 >+ my $expected_amount = >+ $register->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ); # Should be -15.00 >+ is( $expected_amount, -15.00, "Expected cash amount is calculated correctly" ); > > subtest 'balanced_cashup' => sub { > plan tests => 3; > >+ $schema->storage->txn_begin; >+ > # Test exact match - no surplus/deficit accountlines should be created >- my $amount = abs($expected_amount); # 15.00 actual matches 15.00 expected >+ my $amount = abs($expected_amount); # 15.00 actual matches 15.00 expected > > my $cashup = $register->add_cashup( > { >@@ -420,6 +411,8 @@ subtest 'cashup_reconciliation' => sub { > ); > > is( $reconciliation_lines->count, 0, 'No reconciliation accountlines created for balanced cashup' ); >+ >+ $schema->storage->txn_rollback; > }; > > subtest 'surplus_cashup' => sub { >@@ -437,13 +430,15 @@ subtest 'cashup_reconciliation' => sub { > amount => -20.00, # Credit (payment) > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); > >- my $expected = abs( $register2->outstanding_accountlines->total ); # 20.00 >- my $actual = 25.00; # 5.00 surplus >- my $surplus = $actual - $expected; >+ my $expected = >+ abs( $register2->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) ); # 20.00 >+ my $actual = 25.00; # 5.00 surplus >+ my $surplus = $actual - $expected; > > my $cashup = $register2->add_cashup( > { >@@ -485,6 +480,7 @@ subtest 'cashup_reconciliation' => sub { > amount => -10.00, > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); >@@ -531,13 +527,15 @@ subtest 'cashup_reconciliation' => sub { > amount => -30.00, # Credit (payment) > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); > >- my $expected = abs( $register3->outstanding_accountlines->total ); # 30.00 >- my $actual = 25.00; # 5.00 deficit >- my $deficit = $expected - $actual; >+ my $expected = >+ abs( $register3->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) ); # 30.00 >+ my $actual = 25.00; # 5.00 deficit >+ my $deficit = $expected - $actual; > > my $cashup = $register3->add_cashup( > { >@@ -579,6 +577,7 @@ subtest 'cashup_reconciliation' => sub { > amount => -20.00, > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); >@@ -625,6 +624,7 @@ subtest 'cashup_reconciliation' => sub { > amount => -10.00, > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); >@@ -677,6 +677,7 @@ subtest 'cashup_reconciliation' => sub { > amount => -10.00, > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); >@@ -716,6 +717,7 @@ subtest 'cashup_reconciliation' => sub { > amount => -10.00, > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); >@@ -745,3 +747,640 @@ subtest 'cashup_reconciliation' => sub { > > $schema->storage->txn_rollback; > }; >+ >+subtest 'two_phase_cashup_workflow' => sub { >+ plan tests => 15; >+ >+ $schema->storage->txn_begin; >+ >+ # Create test data >+ my $manager = $builder->build_object( { class => 'Koha::Patrons' } ); >+ my $library = $builder->build_object( { class => 'Koha::Libraries' } ); >+ my $register = $builder->build_object( >+ { >+ class => 'Koha::Cash::Registers', >+ value => { >+ branch => $library->branchcode, >+ starting_float => 0, >+ } >+ } >+ ); >+ >+ # Add some test transactions >+ my $account_line1 = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ amount => 10.00, >+ date => \'SYSDATE() - INTERVAL 1 MINUTE', >+ register_id => undef, >+ debit_type_code => 'OVERDUE', >+ credit_type_code => undef, >+ payment_type => undef, >+ } >+ } >+ ); >+ >+ my $account_line2 = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ amount => -5.00, >+ date => \'SYSDATE() - INTERVAL 1 MINUTE', >+ register_id => $register->id, >+ debit_type_code => undef, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH' >+ } >+ } >+ ); >+ >+ # Test 1: start_cashup creates CASHUP_START action >+ my $cashup_start = $register->start_cashup( { manager_id => $manager->id } ); >+ >+ is( >+ ref $cashup_start, 'Koha::Cash::Register::Cashup', >+ 'start_cashup returns Cash::Register::Cashup object' >+ ); >+ >+ my $start_action = Koha::Cash::Register::Actions->search( >+ { >+ register_id => $register->id, >+ code => 'CASHUP_START' >+ } >+ )->next; >+ >+ ok( $start_action, 'CASHUP_START action created in database' ); >+ is( $start_action->manager_id, $manager->id, 'CASHUP_START has correct manager_id' ); >+ >+ # Test 2: cashup_in_progress detects active cashup >+ my $in_progress = $register->cashup_in_progress; >+ ok( $in_progress, 'cashup_in_progress detects active cashup' ); >+ is( $in_progress->id, $start_action->id, 'cashup_in_progress returns correct CASHUP_START action' ); >+ >+ # Test 3: Cannot start another cashup while one is in progress >+ throws_ok { >+ $register->start_cashup( { manager_id => $manager->id } ); >+ } >+ 'Koha::Exceptions::Object::DuplicateID', >+ 'Cannot start second cashup while one is in progress'; >+ >+ # Test 4: outstanding_accountlines behavior during active cashup >+ my $outstanding = $register->outstanding_accountlines; >+ is( $outstanding->count, 0, 'outstanding_accountlines returns 0 during active cashup' ); >+ >+ # Test 5: Add transaction after cashup start (should appear in outstanding) >+ my $account_line3 = $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ amount => -8.00, >+ date => \'SYSDATE() + INTERVAL 1 MINUTE', >+ register_id => $register->id, >+ debit_type_code => undef, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ # This new transaction should appear in outstanding (it's after CASHUP_START) >+ $outstanding = $register->outstanding_accountlines; >+ is( $outstanding->count, 1, 'New transaction after CASHUP_START appears in outstanding' ); >+ >+ # Test 6: outstanding_accountlines correctly handles session boundaries >+ my $session_accountlines = $register->outstanding_accountlines; >+ my $session_total = $session_accountlines->total; >+ is( >+ $session_total, -8.00, >+ 'outstanding_accountlines correctly calculates session totals with CASHUP_START cutoff' >+ ); >+ >+ # Test 7: Complete cashup with exact amount (no reconciliation) >+ my $expected_cashup_amount = 5.00; # CASH PAYMENT prior to CASHUP_START >+ my $cashup_complete = $register->add_cashup( >+ { >+ manager_id => $manager->id, >+ amount => $expected_cashup_amount >+ } >+ ); >+ >+ is( >+ ref $cashup_complete, 'Koha::Cash::Register::Cashup', >+ 'add_cashup returns Cashup object' >+ ); >+ >+ # Check no reconciliation lines were created >+ my $surplus_lines = $cashup_complete->accountlines->search( >+ { >+ register_id => $register->id, >+ credit_type_code => 'CASHUP_SURPLUS' >+ } >+ ); >+ my $deficit_lines = $cashup_complete->accountlines->search( >+ { >+ register_id => $register->id, >+ debit_type_code => 'CASHUP_DEFICIT' >+ } >+ ); >+ >+ is( $surplus_lines->count, 0, 'No surplus lines created for exact cashup' ); >+ is( $deficit_lines->count, 0, 'No deficit lines created for exact cashup' ); >+ >+ # Test 8: cashup_in_progress returns undef after completion >+ $in_progress = $register->cashup_in_progress; >+ is( $in_progress, undef, 'cashup_in_progress returns undef after completion' ); >+ >+ # Test 9: outstanding_accountlines now includes new transaction >+ $outstanding = $register->outstanding_accountlines; >+ is( $outstanding->count, 1, 'outstanding_accountlines includes transaction after completion' ); >+ is( $outstanding->total, -8.00, 'outstanding_accountlines total is correct after completion' ); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'cashup_in_progress' => sub { >+ plan tests => 6; >+ >+ $schema->storage->txn_begin; >+ >+ my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $manager = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Test 1: No cashups ever performed >+ subtest 'no_cashups_ever' => sub { >+ plan tests => 1; >+ >+ my $in_progress = $register->cashup_in_progress; >+ is( $in_progress, undef, 'cashup_in_progress returns undef when no cashups have ever been performed' ); >+ }; >+ >+ # Test 2: Only quick cashups performed >+ subtest 'only_quick_cashups' => sub { >+ plan tests => 2; >+ >+ # Add a quick cashup >+ my $quick_cashup = $register->add_cashup( { manager_id => $manager->id, amount => '10.00' } ); >+ $quick_cashup->timestamp( \'NOW() - INTERVAL 30 MINUTE' )->store(); >+ >+ my $in_progress = $register->cashup_in_progress; >+ is( $in_progress, undef, 'cashup_in_progress returns undef after quick cashup completion' ); >+ >+ # Add another quick cashup >+ my $quick_cashup2 = $register->add_cashup( { manager_id => $manager->id, amount => '5.00' } ); >+ >+ $in_progress = $register->cashup_in_progress; >+ is( $in_progress, undef, 'cashup_in_progress returns undef after multiple quick cashups' ); >+ }; >+ >+ # Test 3: Multiple CASHUP_START actions >+ subtest 'multiple_start_actions' => sub { >+ plan tests => 2; >+ >+ my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ # Create multiple CASHUP_START actions >+ my $start1 = $register2->start_cashup( { manager_id => $manager->id } ); >+ $start1->timestamp( \'NOW() - INTERVAL 60 MINUTE' )->store(); >+ >+ # Complete the first one >+ my $complete1 = $register2->add_cashup( { manager_id => $manager->id, amount => '1.00' } ); >+ $complete1->timestamp( \'NOW() - INTERVAL 50 MINUTE' )->store(); >+ >+ # Start another one >+ my $start2 = $register2->start_cashup( { manager_id => $manager->id } ); >+ >+ my $in_progress = $register2->cashup_in_progress; >+ is( ref($in_progress), 'Koha::Cash::Register::Action', 'Returns most recent CASHUP_START when multiple exist' ); >+ is( $in_progress->id, $start2->id, 'Returns the correct (most recent) CASHUP_START action' ); >+ }; >+ >+ # Test 4: Mixed quick and two-phase workflows >+ subtest 'mixed_workflows' => sub { >+ plan tests => 3; >+ >+ my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ # Quick cashup first >+ my $quick = $register3->add_cashup( { manager_id => $manager->id, amount => '5.00' } ); >+ $quick->timestamp( \'NOW() - INTERVAL 40 MINUTE' )->store(); >+ >+ # Start two-phase >+ my $start = $register3->start_cashup( { manager_id => $manager->id } ); >+ $start->timestamp( \'NOW() - INTERVAL 30 MINUTE' )->store(); >+ >+ my $in_progress = $register3->cashup_in_progress; >+ is( ref($in_progress), 'Koha::Cash::Register::Action', 'Detects two-phase in progress after quick cashup' ); >+ is( $in_progress->id, $start->id, 'Returns correct CASHUP_START after mixed workflow' ); >+ >+ # Complete two-phase >+ my $complete = $register3->add_cashup( { manager_id => $manager->id, amount => '3.00' } ); >+ >+ $in_progress = $register3->cashup_in_progress; >+ is( $in_progress, undef, 'Returns undef after completing two-phase in mixed workflow' ); >+ }; >+ >+ # Test 5: Timestamp edge cases >+ subtest 'timestamp_edge_cases' => sub { >+ plan tests => 2; >+ >+ my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ # Create CASHUP_START >+ my $start = $register4->start_cashup( { manager_id => $manager->id } ); >+ my $start_time = $start->timestamp; >+ >+ # Create CASHUP with exactly the same timestamp (edge case) >+ my $complete = $register4->add_cashup( { manager_id => $manager->id, amount => '1.00' } ); >+ $complete->timestamp($start_time)->store(); >+ >+ my $in_progress = $register4->cashup_in_progress; >+ is( $in_progress, undef, 'Handles same timestamp edge case correctly' ); >+ >+ # Test with CASHUP timestamp slightly before CASHUP_START (edge case) >+ my $register5 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $start2 = $register5->start_cashup( { manager_id => $manager->id } ); >+ >+ my $complete2 = $register5->add_cashup( { manager_id => $manager->id, amount => '1.00' } ); >+ $complete2->timestamp( \'NOW() - INTERVAL 1 MINUTE' )->store(); >+ >+ $in_progress = $register5->cashup_in_progress; >+ is( >+ ref($in_progress), 'Koha::Cash::Register::Action', >+ 'Correctly identifies active cashup when completion is backdated' >+ ); >+ }; >+ >+ # Test 6: Performance with many cashups >+ subtest 'performance_with_many_cashups' => sub { >+ plan tests => 1; >+ >+ my $register6 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ # Create many quick cashups >+ for my $i ( 1 .. 10 ) { >+ my $cashup = $register6->add_cashup( { manager_id => $manager->id, amount => '1.00' } ); >+ my $timestamp = "NOW() - INTERVAL $i MINUTE"; >+ $cashup->timestamp( \$timestamp )->store(); >+ } >+ >+ # Start a two-phase cashup >+ my $start = $register6->start_cashup( { manager_id => $manager->id } ); >+ >+ my $in_progress = $register6->cashup_in_progress; >+ is( ref($in_progress), 'Koha::Cash::Register::Action', 'Performs correctly with many previous cashups' ); >+ }; >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'start_cashup_parameter_validation' => sub { >+ plan tests => 5; >+ >+ $schema->storage->txn_begin; >+ >+ my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $manager = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Test 1: Valid parameters >+ subtest 'valid_parameters' => sub { >+ plan tests => 3; >+ >+ my $register1 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ my $cashup_start = $register1->start_cashup( { manager_id => $manager->id } ); >+ >+ is( ref($cashup_start), 'Koha::Cash::Register::Cashup', 'start_cashup returns correct object type' ); >+ is( $cashup_start->manager_id, $manager->id, 'manager_id set correctly' ); >+ is( $cashup_start->code, 'CASHUP_START', 'code set correctly to CASHUP_START' ); >+ }; >+ >+ # Test 2: Missing manager_id >+ subtest 'missing_manager_id' => sub { >+ plan tests => 1; >+ >+ my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ eval { $register2->start_cashup( {} ); }; >+ ok( $@, 'start_cashup fails when manager_id is missing' ); >+ }; >+ >+ # Test 3: Invalid manager_id >+ subtest 'invalid_manager_id' => sub { >+ plan tests => 1; >+ >+ my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ eval { $register3->start_cashup( { manager_id => 99999999 } ); }; >+ ok( $@, 'start_cashup fails with invalid manager_id' ); >+ }; >+ >+ # Test 4: Duplicate start_cashup prevention >+ subtest 'duplicate_prevention' => sub { >+ plan tests => 2; >+ >+ my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ # First start should succeed >+ my $first_start = $register4->start_cashup( { manager_id => $manager->id } ); >+ ok( $first_start, 'First start_cashup succeeds' ); >+ >+ # Second start should fail >+ throws_ok { >+ $register4->start_cashup( { manager_id => $manager->id } ); >+ } >+ 'Koha::Exceptions::Object::DuplicateID', >+ 'Second start_cashup throws DuplicateID exception'; >+ }; >+ >+ # Test 5: Database transaction integrity >+ subtest 'transaction_integrity' => sub { >+ plan tests => 3; >+ >+ my $register5 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ # Add some transactions to establish expected amount >+ my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ my $account = $patron->account; >+ >+ my $fine = $account->add_debit( >+ { >+ amount => '15.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $payment = $account->pay( >+ { >+ cash_register => $register5->id, >+ amount => '15.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine] >+ } >+ ); >+ >+ my $initial_action_count = $register5->_result->search_related('cash_register_actions')->count; >+ >+ my $start = $register5->start_cashup( { manager_id => $manager->id } ); >+ >+ # Verify action was created >+ my $final_action_count = $register5->_result->search_related('cash_register_actions')->count; >+ is( $final_action_count, $initial_action_count + 1, 'CASHUP_START action created in database' ); >+ >+ # Verify expected amount calculation >+ ok( $start->amount >= 0, 'Expected amount calculated correctly' ); >+ >+ # Verify timestamp is set >+ ok( defined $start->timestamp, 'Timestamp is set on CASHUP_START action' ); >+ }; >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'add_cashup' => sub { >+ plan tests => 5; >+ >+ $schema->storage->txn_begin; >+ >+ my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $manager = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Test 1: Valid parameters >+ subtest 'valid_parameters' => sub { >+ plan tests => 3; >+ >+ my $register1 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ my $cashup = $register1->add_cashup( { manager_id => $manager->id, amount => '10.00' } ); >+ >+ is( ref($cashup), 'Koha::Cash::Register::Cashup', 'add_cashup returns correct object type' ); >+ is( $cashup->manager_id, $manager->id, 'manager_id set correctly' ); >+ is( $cashup->amount, '10.000000', 'amount set correctly' ); >+ }; >+ >+ # Test 2: Missing required parameters >+ subtest 'missing_parameters' => sub { >+ plan tests => 3; >+ >+ my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ # Missing manager_id >+ eval { $register2->add_cashup( { amount => '10.00' } ); }; >+ ok( $@, 'add_cashup fails when manager_id is missing' ); >+ >+ # Missing amount >+ eval { $register2->add_cashup( { manager_id => $manager->id } ); }; >+ ok( $@, 'add_cashup fails when amount is missing' ); >+ >+ # Missing both >+ eval { $register2->add_cashup( {} ); }; >+ ok( $@, 'add_cashup fails when both parameters are missing' ); >+ }; >+ >+ # Test 3: Invalid amount parameter >+ subtest 'invalid_amount' => sub { >+ plan tests => 4; >+ >+ my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ # Zero amount >+ throws_ok { >+ $register3->add_cashup( { manager_id => $manager->id, amount => '0.00' } ); >+ } >+ 'Koha::Exceptions::Account::AmountNotPositive', >+ 'Zero amount throws AmountNotPositive exception'; >+ >+ # Negative amount >+ throws_ok { >+ $register3->add_cashup( { manager_id => $manager->id, amount => '-5.00' } ); >+ } >+ 'Koha::Exceptions::Account::AmountNotPositive', >+ 'Negative amount throws AmountNotPositive exception'; >+ >+ # Non-numeric amount >+ throws_ok { >+ $register3->add_cashup( { manager_id => $manager->id, amount => 'invalid' } ); >+ } >+ 'Koha::Exceptions::Account::AmountNotPositive', >+ 'Non-numeric amount throws AmountNotPositive exception'; >+ >+ # Empty string amount >+ throws_ok { >+ $register3->add_cashup( { manager_id => $manager->id, amount => '' } ); >+ } >+ 'Koha::Exceptions::Account::AmountNotPositive', >+ 'Empty string amount throws AmountNotPositive exception'; >+ }; >+ >+ # Test 4: Reconciliation note handling >+ subtest 'reconciliation_note_handling' => sub { >+ plan tests => 4; >+ >+ my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ my $account = $patron->account; >+ >+ # Create transaction to enable surplus creation >+ my $fine = $account->add_debit( >+ { >+ amount => '10.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $payment = $account->pay( >+ { >+ cash_register => $register4->id, >+ amount => '10.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine] >+ } >+ ); >+ >+ # Test normal note >+ my $cashup1 = $register4->add_cashup( >+ { >+ manager_id => $manager->id, >+ amount => '15.00', # Creates surplus >+ reconciliation_note => 'Found extra money in drawer' >+ } >+ ); >+ >+ my $surplus1 = Koha::Account::Lines->search( >+ { >+ register_id => $register4->id, >+ credit_type_code => 'CASHUP_SURPLUS' >+ } >+ )->next; >+ is( $surplus1->note, 'Found extra money in drawer', 'Normal reconciliation note stored correctly' ); >+ >+ # Test very long note (should be truncated) >+ my $register5 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $long_note = 'x' x 1500; # Longer than 1000 character limit >+ >+ my $fine2 = $account->add_debit( >+ { >+ amount => '10.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $payment2 = $account->pay( >+ { >+ cash_register => $register5->id, >+ amount => '10.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine2] >+ } >+ ); >+ >+ my $cashup2 = $register5->add_cashup( >+ { >+ manager_id => $manager->id, >+ amount => '15.00', >+ reconciliation_note => $long_note >+ } >+ ); >+ >+ my $surplus2 = Koha::Account::Lines->search( >+ { >+ register_id => $register5->id, >+ credit_type_code => 'CASHUP_SURPLUS' >+ } >+ )->next; >+ is( length( $surplus2->note ), 1000, 'Long reconciliation note truncated to 1000 characters' ); >+ >+ # Test whitespace-only note (should be undef) >+ my $register6 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ my $fine3 = $account->add_debit( >+ { >+ amount => '10.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $payment3 = $account->pay( >+ { >+ cash_register => $register6->id, >+ amount => '10.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine3] >+ } >+ ); >+ >+ my $cashup3 = $register6->add_cashup( >+ { >+ manager_id => $manager->id, >+ amount => '15.00', >+ reconciliation_note => ' ' # Whitespace only >+ } >+ ); >+ >+ my $surplus3 = Koha::Account::Lines->search( >+ { >+ register_id => $register6->id, >+ credit_type_code => 'CASHUP_SURPLUS' >+ } >+ )->next; >+ is( $surplus3->note, undef, 'Whitespace-only reconciliation note stored as undef' ); >+ >+ # Test empty string note (should be undef) >+ my $register7 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ my $fine4 = $account->add_debit( >+ { >+ amount => '10.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $payment4 = $account->pay( >+ { >+ cash_register => $register7->id, >+ amount => '10.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine4] >+ } >+ ); >+ >+ my $cashup4 = $register7->add_cashup( >+ { >+ manager_id => $manager->id, >+ amount => '15.00', >+ reconciliation_note => '' # Empty string >+ } >+ ); >+ >+ my $surplus4 = Koha::Account::Lines->search( >+ { >+ register_id => $register7->id, >+ credit_type_code => 'CASHUP_SURPLUS' >+ } >+ )->next; >+ is( $surplus4->note, undef, 'Empty string reconciliation note stored as undef' ); >+ }; >+ >+ # Test 5: Invalid manager_id >+ subtest 'invalid_manager_id' => sub { >+ plan tests => 1; >+ >+ my $register9 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ eval { $register9->add_cashup( { manager_id => 99999999, amount => '10.00' } ); }; >+ ok( $@, 'add_cashup fails with invalid manager_id' ); >+ diag($@); >+ }; >+ >+ $schema->storage->txn_rollback; >+}; >diff --git a/t/db_dependent/Koha/Cash/Register/Cashup.t b/t/db_dependent/Koha/Cash/Register/Cashup.t >index 29d30229970..e2d6f3d960b 100755 >--- a/t/db_dependent/Koha/Cash/Register/Cashup.t >+++ b/t/db_dependent/Koha/Cash/Register/Cashup.t >@@ -19,9 +19,10 @@ > > use Modern::Perl; > use Test::NoWarnings; >-use Test::More tests => 4; >+use Test::More tests => 6; > > use Koha::Database; >+use Koha::DateUtils qw( dt_from_string ); > > use t::lib::TestBuilder; > >@@ -359,4 +360,278 @@ subtest 'summary' => sub { > $schema->storage->txn_rollback; > }; > >+subtest 'accountlines' => sub { >+ plan tests => 3; >+ >+ $schema->storage->txn_begin; >+ >+ my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ my $manager = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Test 1: Basic functionality >+ subtest 'basic_accountlines_functionality' => sub { >+ plan tests => 2; >+ >+ my $account = $patron->account; >+ my $fine = $account->add_debit( >+ { >+ amount => '10.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ $fine->date( \'NOW() - INTERVAL 30 MINUTE' )->store; >+ >+ my $payment = $account->pay( >+ { >+ cash_register => $register->id, >+ amount => '10.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine] >+ } >+ ); >+ my $payment_line = Koha::Account::Lines->find( $payment->{payment_id} ); >+ $payment_line->date( \'NOW() - INTERVAL 25 MINUTE' )->store; >+ >+ # Cashup >+ my $cashup = $register->add_cashup( { manager_id => $manager->id, amount => '10.00' } ); >+ >+ # Check accountlines method exists and returns correct type >+ my $accountlines = $cashup->accountlines; >+ is( ref($accountlines), 'Koha::Account::Lines', 'accountlines returns Koha::Account::Lines object' ); >+ ok( $accountlines->count >= 0, 'accountlines returns a valid count' ); >+ }; >+ >+ # Test 2: Two-phase workflow basics >+ subtest 'two_phase_basics' => sub { >+ plan tests => 3; >+ >+ my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $account2 = $patron->account; >+ >+ # Start cashup first >+ my $cashup_start = $register2->start_cashup( { manager_id => $manager->id } ); >+ >+ # Add transaction after start >+ my $fine = $account2->add_debit( >+ { >+ amount => '5.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $payment = $account2->pay( >+ { >+ cash_register => $register2->id, >+ amount => '5.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine] >+ } >+ ); >+ >+ # Complete cashup >+ my $cashup_complete = $register2->add_cashup( { manager_id => $manager->id, amount => '5.00' } ); >+ >+ # Check accountlines >+ my $accountlines = $cashup_complete->accountlines; >+ is( ref($accountlines), 'Koha::Account::Lines', 'Two-phase accountlines returns correct type' ); >+ ok( $accountlines->count >= 0, 'Two-phase accountlines returns valid count' ); >+ >+ # Check filtering capability >+ my $filtered = $accountlines->search( { payment_type => 'CASH' } ); >+ ok( defined $filtered, 'Accountlines can be filtered' ); >+ }; >+ >+ # Test 3: Reconciliation inclusion >+ subtest 'reconciliation_inclusion' => sub { >+ plan tests => 2; >+ >+ my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $account3 = $patron->account; >+ >+ # Create transaction >+ my $fine = $account3->add_debit( >+ { >+ amount => '20.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $payment = $account3->pay( >+ { >+ cash_register => $register3->id, >+ amount => '20.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine] >+ } >+ ); >+ >+ # Cashup with surplus to create reconciliation line >+ my $cashup = $register3->add_cashup( >+ { >+ manager_id => $manager->id, >+ amount => '25.00' # Creates surplus >+ } >+ ); >+ >+ my $accountlines = $cashup->accountlines; >+ ok( $accountlines->count >= 1, 'Accountlines includes transactions when surplus created' ); >+ >+ # Verify surplus line exists >+ my $surplus_lines = $accountlines->search( { credit_type_code => 'CASHUP_SURPLUS' } ); >+ is( $surplus_lines->count, 1, 'Surplus reconciliation line is included' ); >+ }; >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'summary_session_boundaries' => sub { >+ plan tests => 4; >+ >+ $schema->storage->txn_begin; >+ >+ my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ my $manager = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Test 1: Basic summary functionality >+ subtest 'basic_summary_functionality' => sub { >+ plan tests => 3; >+ >+ my $account = $patron->account; >+ >+ # Create a simple transaction and cashup >+ my $fine = $account->add_debit( >+ { >+ amount => '10.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $payment = $account->pay( >+ { >+ cash_register => $register->id, >+ amount => '10.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine] >+ } >+ ); >+ >+ my $cashup = $register->add_cashup( { manager_id => $manager->id, amount => '10.00' } ); >+ my $summary = $cashup->summary; >+ >+ # Basic summary structure validation >+ ok( defined $summary->{from_date} || !defined $summary->{from_date}, 'Summary has from_date field' ); >+ ok( defined $summary->{to_date}, 'Summary has to_date field' ); >+ ok( defined $summary->{total}, 'Summary has total field' ); >+ }; >+ >+ # Test 2: Two-phase workflow basic functionality >+ subtest 'two_phase_basic_functionality' => sub { >+ plan tests => 4; >+ >+ my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $account = $patron->account; >+ >+ # Start two-phase cashup >+ my $cashup_start = $register2->start_cashup( { manager_id => $manager->id } ); >+ ok( defined $cashup_start, 'Two-phase cashup can be started' ); >+ >+ # Create transaction during session >+ my $fine = $account->add_debit( >+ { >+ amount => '15.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $payment = $account->pay( >+ { >+ cash_register => $register2->id, >+ amount => '15.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine] >+ } >+ ); >+ >+ # Complete two-phase cashup >+ my $cashup_complete = $register2->add_cashup( { manager_id => $manager->id, amount => '15.00' } ); >+ ok( defined $cashup_complete, 'Two-phase cashup can be completed' ); >+ >+ my $summary = $cashup_complete->summary; >+ ok( defined $summary, 'Two-phase completed cashup has summary' ); >+ ok( defined $summary->{total}, 'Two-phase summary has total' ); >+ }; >+ >+ # Test 3: Reconciliation functionality >+ subtest 'reconciliation_functionality' => sub { >+ plan tests => 2; >+ >+ my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $account = $patron->account; >+ >+ # Create transaction with surplus >+ my $fine = $account->add_debit( >+ { >+ amount => '20.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $payment = $account->pay( >+ { >+ cash_register => $register3->id, >+ amount => '20.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine] >+ } >+ ); >+ >+ # Cashup with surplus >+ my $cashup = $register3->add_cashup( >+ { >+ manager_id => $manager->id, >+ amount => '25.00' # Creates 5.00 surplus >+ } >+ ); >+ >+ my $summary = $cashup->summary; >+ my $accountlines = $cashup->accountlines; >+ >+ ok( defined $summary, 'Cashup with reconciliation has summary' ); >+ >+ # Check surplus reconciliation exists >+ my $surplus_lines = $accountlines->search( { credit_type_code => 'CASHUP_SURPLUS' } ); >+ is( $surplus_lines->count, 1, 'Surplus reconciliation line is created and included' ); >+ }; >+ >+ # Test 4: Edge cases >+ subtest 'edge_cases' => sub { >+ plan tests => 2; >+ >+ my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ # Empty cashup >+ my $empty_cashup = $register4->add_cashup( { manager_id => $manager->id, amount => '1.00' } ); >+ my $summary = $empty_cashup->summary; >+ >+ ok( defined $summary, 'Empty cashup has summary' ); >+ ok( defined $summary->{total}, 'Empty cashup summary has total' ); >+ }; >+ >+ $schema->storage->txn_rollback; >+}; >+ > 1; >-- >2.51.0
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