Bugzilla – Attachment 189422 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
Bug-40445-Implement-two-phase-cashup-workflow.patch (text/plain), 121.12 KB, created by
Martin Renvoize (ashimema)
on 2025-11-10 15:57:13 UTC
(
hide
)
Description:
Bug 40445: Implement two-phase cashup workflow
Filename:
MIME Type:
Creator:
Martin Renvoize (ashimema)
Created:
2025-11-10 15:57:13 UTC
Size:
121.12 KB
patch
obsolete
>From ccd3b8c0e810aaae5e172100649f9a772cec443e 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 > >Introduces two-phase cashup system allowing staff to start a cashup >session, remove cash for counting, and complete later with reconciliation. > >Key features: >- start_cashup(): Creates CASHUP_START action for counting session >- cashup_in_progress(): Checks if session is active >- Enhanced add_cashup(): Supports both legacy and two-phase modes >- Improved session boundary calculation handles mixed workflows >- Reconciliation lines backdated appropriately per mode > >Single register interface: >- Dynamic toolbar shows "Start cashup" vs "Complete cashup" >- Status indicator when cashup in progress >- Dual-workflow modal with quick and two-phase options > >Registers page enhancements: >- Checkbox selection for multiple registers >- "Select all" functionality with indeterminate state >- "Cashup selected" button with workflow modal >- Both workflows support single or multiple registers >- Comprehensive error handling for bulk operations > >Test plan: >1. Apply patch and run prove t/db_dependent/Koha/Cash/Register.t >2. Single register two-phase: > - Start cashup on register > - Verify status indicator appears > - Add more transactions > - Complete cashup with actual amount > - Verify session boundaries correct >3. Single register quick cashup: > - Use "Quick cashup" option > - Verify immediate completion >4. Registers page multiple selection: > - Select multiple registers with checkboxes > - Click "Cashup selected" > - Choose "Start cashup" - verify all start successfully > - Complete each individually >5. Registers page quick cashup: > - Select multiple registers > - Choose "Quick cashup" with reconciliation amounts > - Verify all complete immediately >6. Mixed workflows: > - Use both quick and two-phase on same register over time > - Verify session boundaries calculate correctly >--- > Koha/Cash/Register.pm | 302 +++++- > Koha/Cash/Register/Cashup.pm | 144 ++- > .../prog/en/modules/pos/register.tt | 105 +- > .../prog/en/modules/pos/registers.tt | 438 +++++++- > pos/register.pl | 68 +- > pos/registers.pl | 149 ++- > t/db_dependent/Koha/Cash/Register.t | 960 ++++++++++++++++-- > t/db_dependent/Koha/Cash/Register/Cashup.t | 315 +++++- > 8 files changed, 2299 insertions(+), 182 deletions(-) > >diff --git a/Koha/Cash/Register.pm b/Koha/Cash/Register.pm >index 9f2c17c376e..b54f9712143 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 <https://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 ea94abaf700..7339494b7c2 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/koha-tmpl/intranet-tmpl/prog/en/modules/pos/registers.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/pos/registers.tt >index c79ad6c20fd..e1ce660966b 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/pos/registers.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/pos/registers.tt >@@ -45,9 +45,83 @@ > <div id="error_message" class="alert alert-warning"> You do not have permission to perform cashup actions. </div> > [% END %] > >- [% IF CAN_user_cash_management_cashup %] >- <div id="toolbar" class="btn-toolbar"> >- <button type="button" class="cashup_all btn btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupAllModal"><i class="fa-solid fa-money-bill-1"></i> Cashup all</button> >+ [% IF ( error_cashup_start && cashup_errors ) %] >+ <div class="alert alert-warning"> >+ <strong>Some registers failed to start cashup:</strong> >+ <ul> >+ [% FOREACH error IN cashup_errors %] >+ <li>[% error | html %]</li> >+ [% END %] >+ </ul> >+ </div> >+ [% END %] >+ >+ [% IF ( error_cashup_complete && cashup_errors ) %] >+ <div class="alert alert-warning"> >+ <strong>Some registers failed to complete cashup:</strong> >+ <ul> >+ [% FOREACH error IN cashup_errors %] >+ <li>[% error | html %]</li> >+ [% END %] >+ </ul> >+ </div> >+ [% END %] >+ >+ [% IF ( cashup_start_success ) %] >+ <div class="alert alert-success"> >+ <i class="fa fa-check"></i> >+ [% IF cashup_start_success == 1 %] >+ Successfully started cashup for 1 register. >+ [% ELSE %] >+ Successfully started cashup for [% cashup_start_success | html %] registers. >+ [% END %] >+ [% IF ( cashup_start_errors ) %] >+ [% IF cashup_start_errors == 1 %] >+ However, 1 register had errors. >+ [% ELSE %] >+ However, [% cashup_start_errors | html %] registers had errors. >+ [% END %] >+ [% END %] >+ </div> >+ [% END %] >+ >+ [% IF ( cashup_complete_success ) %] >+ <div class="alert alert-success"> >+ <i class="fa fa-check"></i> >+ [% IF cashup_complete_success == 1 %] >+ Successfully completed cashup for 1 register. >+ [% ELSE %] >+ Successfully completed cashup for [% cashup_complete_success | html %] registers. >+ [% END %] >+ [% IF ( cashup_complete_errors ) %] >+ [% IF cashup_complete_errors == 1 %] >+ However, 1 register had errors. >+ [% ELSE %] >+ However, [% cashup_complete_errors | html %] registers had errors. >+ [% END %] >+ [% END %] >+ </div> >+ [% END %] >+ >+ [% IF ( cashup_start_errors && !cashup_start_success ) %] >+ <div class="alert alert-warning"> >+ <i class="fa fa-exclamation-triangle"></i> >+ [% IF cashup_start_errors == 1 %] >+ Failed to start cashup for 1 register. >+ [% ELSE %] >+ Failed to start cashup for [% cashup_start_errors | html %] registers. >+ [% END %] >+ </div> >+ [% END %] >+ >+ [% IF ( cashup_complete_errors && !cashup_complete_success ) %] >+ <div class="alert alert-warning"> >+ <i class="fa fa-exclamation-triangle"></i> >+ [% IF cashup_complete_errors == 1 %] >+ Failed to complete cashup for 1 register. >+ [% ELSE %] >+ Failed to complete cashup for [% cashup_complete_errors | html %] registers. >+ [% END %] > </div> > [% END %] > >@@ -64,6 +138,9 @@ > <table id="registers" class="table_registers"> > <thead> > <tr> >+ [% IF CAN_user_cash_management_cashup %] >+ <th class="no-export"><input type="checkbox" id="select_all_registers" title="Select all available registers" /></th> >+ [% END %] > <th>Register name</th> > <th>Register description</th> > <th>Last cashup</th> >@@ -80,6 +157,15 @@ > [% SET bankable = 0, ctotal = 0, dtotal = 0, cctotal = 0, cdtotal = 0 %] > [% FOREACH register IN registers %] > <tr> >+ [% IF CAN_user_cash_management_cashup %] >+ <td> >+ [% IF register.cashup_in_progress %] >+ <input type="checkbox" class="register_checkbox" value="[% register.id | html %]" disabled title="Cashup in progress" /> >+ [% ELSE %] >+ <input type="checkbox" class="register_checkbox" value="[% register.id | html %]" /> >+ [% END %] >+ </td> >+ [% END %] > <td><a href="/cgi-bin/koha/pos/register.pl?registerid=[% register.id | uri %]">[% register.name | html %]</a></td> > <td>[% register.description | html %]</td> > <td> >@@ -112,17 +198,31 @@ > </td> > [% IF CAN_user_cash_management_cashup %] > <td> >- <button >- type="button" >- class="cashup_individual btn btn-xs btn-default" >- data-bs-toggle="modal" >- data-bs-target="#confirmCashupModal" >- data-register="[% register.description | html %]" >- data-bankable="[% rbankable | $Price %]" >- data-float="[% register.starting_float | $Price %]" >- data-registerid="[% register.id | html %]" >- ><i class="fa-solid fa-money-bill-1"></i> Record cashup</button >- > >+ [% IF register.cashup_in_progress %] >+ <button >+ type="button" >+ class="btn btn-xs btn-primary pos_complete_cashup" >+ data-bs-toggle="modal" >+ data-bs-target="#confirmCashupModal" >+ data-register="[% register.description | html %]" >+ data-bankable="[% rbankable | $Price %]" >+ data-float="[% register.starting_float | $Price %]" >+ data-registerid="[% register.id | html %]" >+ ><i class="fa-solid fa-check"></i> Complete cashup</button >+ > >+ [% ELSE %] >+ <button >+ type="button" >+ class="cashup_individual btn btn-xs btn-default" >+ data-bs-toggle="modal" >+ data-bs-target="#triggerCashupModalRegister" >+ data-register="[% register.description | html %]" >+ data-bankable="[% rbankable | $Price %]" >+ data-float="[% register.starting_float | $Price %]" >+ data-registerid="[% register.id | html %]" >+ ><i class="fa-solid fa-money-bill-1"></i> Record cashup</button >+ > >+ [% END %] > </td> > [% END %] > </tr> >@@ -130,13 +230,19 @@ > </tbody> > <tfoot> > <tr> >- <td colspan="4" align="right">Totals:</td> >+ [% IF CAN_user_cash_management_cashup %] >+ <td colspan="5" align="right">Totals:</td> >+ [% ELSE %] >+ <td colspan="4" align="right">Totals:</td> >+ [% END %] > <td>[% bankable | $Price %]</td> > <td>[% ctotal | $Price %] ([% cctotal | $Price %])</td> > <td>[% dtotal | $Price %] ([% cdtotal | $Price %])</td> > [% IF CAN_user_cash_management_cashup %] > <td> >- <button type="button" class="cashup_all btn btn-xs btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupAllModal"><i class="fa-solid fa-money-bill-1"></i> Cashup all</button> >+ <button type="button" id="cashup_selected_btn" class="btn btn-xs btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupSelectedModal" disabled >+ ><i class="fa-solid fa-money-bill-1"></i> Cashup selected</button >+ > > </td> > [% END %] > </tr> >@@ -147,24 +253,151 @@ > [% END %] > [% END %] > >+<!-- Trigger cashup modal for individual registers --> >+<div class="modal" id="triggerCashupModalRegister" tabindex="-1" role="dialog" aria-labelledby="triggerCashupLabelRegister"> >+ <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="triggerCashupLabelRegister"> >+ Cashup for <em><span id="register_desc"></span></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 <span id="expected_amount_display"></span> cash from the register to bank immediately</li> >+ </ul> >+ <p >+ >Remember to leave the float amount of <strong><span id="float_amount_display"></span></strong> in the register.</p >+ > >+ </div> >+ <div class="modal-footer"> >+ <input type="hidden" name="registerid" id="register_id_field" value="" /> >+ <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" id="quick_cashup_btn">Quick cashup</button> >+ <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button> >+ </div> >+ </div> >+ </div> >+ </form> >+</div> >+<!-- /#triggerCashupModalRegister --> >+ >+<!-- Confirm cashup selected modal --> >+<div class="modal" id="confirmCashupSelectedModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupSelectedLabel"> >+ <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="confirmCashupSelectedLabel">Cashup selected registers</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 for <span id="selected_count">0</span> selected register(s):</strong></p >+ > >+ >+ <div class="row"> >+ <div class="col-md-6"> >+ <div class="card"> >+ <div class="card-body"> >+ <h5 class="card-title"><i class="fa-solid fa-play"></i> Start cashup for selected</h5> >+ <p class="card-text">Begin two-phase cashup for all selected registers. Cash can be removed for counting while registers continue operating.</p> >+ <ul class="small"> >+ <li>Remove cash from each register for counting</li> >+ <li>Registers continue operating during counting</li> >+ <li>Complete each register individually later</li> >+ </ul> >+ </div> >+ </div> >+ </div> >+ <div class="col-md-6"> >+ <div class="card"> >+ <div class="card-body"> >+ <h5 class="card-title"><i class="fa-solid fa-lightning"></i> Quick cashup for selected</h5> >+ <p class="card-text">Complete cashup immediately for all selected registers using expected amounts (no reconciliation needed).</p> >+ <ul class="small"> >+ <li>Uses expected amounts for each register</li> >+ <li>No individual reconciliation</li> >+ <li>Completes all selected registers immediately</li> >+ </ul> >+ </div> >+ </div> >+ </div> >+ </div> >+ >+ <div class="mt-3"> >+ <h6>Selected registers:</h6> >+ <ul id="selected_registers_list"></ul> >+ </div> >+ </div> >+ <div class="modal-footer"> >+ <input type="hidden" name="registerid" id="selected_registers_field" value="" /> >+ <input type="hidden" name="op" id="selected_operation" value="" /> >+ <button type="button" class="btn btn-primary" id="start_selected_btn">Start cashup for selected</button> >+ <button type="button" class="btn btn-success" id="quick_selected_btn">Quick cashup for selected</button> >+ <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button> >+ </div> >+ </div> >+ </div> >+ </form> >+</div> >+<!-- /#confirmCashupSelectedModal --> >+ > <!-- Confirm cashup modal --> > <div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel"> >- <form id="cashup_individual_form" method="post" enctype="multipart/form-data"> >+ <form id="cashup_individual_form" method="post" enctype="multipart/form-data" class="validated"> > [% INCLUDE 'csrf-token.inc' %] > <div class="modal-dialog"> > <div class="modal-content"> > <div class="modal-header"> >- <h1 class="modal-title" id="confirmCashupLabel" >- >Confirm cashup of <em><span id="registerc"></span></em >- ></h1> >+ <h1 class="modal-title" id="confirmCashupLabel"> >+ Confirm cashup of <em><span id="registerc"></span></em> >+ </h1> > <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 <span id="cashc"></span> from the cash register and left a float of <span id="floatc"></span>. </div> >+ <div class="modal-body"> >+ <fieldset class="rows"> >+ <ol> >+ <li> >+ <span class="label"> Expected cashup amount: </span> >+ <span id="cashc" class="expected-amount"></span> >+ </li> >+ <li> >+ <label class="required" for="amount"> Actual cashup amount counted: </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" id="cashup_registerid" value="" /> > <input type="hidden" name="op" value="cud-cashup" /> >- <button type="submit" class="btn btn-primary" id="cashup_confirm">Confirm</button> >+ <button type="submit" class="btn btn-primary" id="cashup_confirm"> Complete cashup</button> > <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button> > </div> > <!-- /.modal-footer --> >@@ -225,10 +458,56 @@ > $("#outgoing").text('[% dtotal | $Price %] ([% cdtotal | $Price %])'); > > var registers_table = $("#registers").kohaTable({ >+ columnDefs: [{ targets: [ -1, 0 ], orderable: false }], > searching: false, > paginationType: "full", > }); > >+ // 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(e){ > var button = $(e.relatedTarget); > var register = button.data('register'); >@@ -239,6 +518,121 @@ > $('#floatc').text(rfloat); > var rid = button.data('registerid'); > $('#cashup_registerid').val(rid); >+ $("#amount").val('').focus(); >+ $("#reconciliation_display").hide(); >+ $("#reconciliation_note_field").hide(); >+ $("#reconciliation_note").val(''); >+ }); >+ >+ // Handle the new trigger cashup modal for individual registers >+ $("#triggerCashupModalRegister").on("shown.bs.modal", function(e){ >+ var button = $(e.relatedTarget); >+ var register = button.data('register'); >+ $("#register_desc").text(register); >+ var bankable = button.data('bankable'); >+ $("#expected_amount_display").text(bankable); >+ var rfloat = button.data('float'); >+ $('#float_amount_display').text(rfloat); >+ var rid = button.data('registerid'); >+ $('#register_id_field').val(rid); >+ >+ // Store bankable amount for quick cashup >+ $('#triggerCashupModalRegister').data('bankable-amount', bankable.replace(/[^0-9.-]/g, '')); >+ }); >+ >+ // Handle Quick cashup button click >+ $("#quick_cashup_btn").on("click", function(e){ >+ e.preventDefault(); >+ var form = $(this).closest('form'); >+ var bankableAmount = $('#triggerCashupModalRegister').data('bankable-amount'); >+ >+ // Change operation to cud-cashup (quick cashup) >+ form.find('input[name="op"]').val('cud-cashup'); >+ >+ // Set the amount to the expected bankable amount >+ form.find('input[name="amount"]').val(bankableAmount); >+ >+ // Submit the form >+ form.submit(); >+ }); >+ >+ // Select all registers functionality >+ $("#select_all_registers").on("change", function() { >+ var isChecked = $(this).is(":checked"); >+ $(".register_checkbox:not(:disabled)").prop("checked", isChecked); >+ updateCashupSelectedButton(); >+ }); >+ >+ // Individual checkbox change handler >+ $(".register_checkbox").on("change", function() { >+ updateCashupSelectedButton(); >+ >+ // Update select all checkbox state >+ var totalCheckboxes = $(".register_checkbox:not(:disabled)").length; >+ var checkedCheckboxes = $(".register_checkbox:not(:disabled):checked").length; >+ >+ if (checkedCheckboxes === 0) { >+ $("#select_all_registers").prop("indeterminate", false).prop("checked", false); >+ } else if (checkedCheckboxes === totalCheckboxes) { >+ $("#select_all_registers").prop("indeterminate", false).prop("checked", true); >+ } else { >+ $("#select_all_registers").prop("indeterminate", true); >+ } >+ }); >+ >+ // Update cashup selected button state >+ function updateCashupSelectedButton() { >+ var selectedCount = $(".register_checkbox:checked").length; >+ var button = $("#cashup_selected_btn"); >+ >+ if (selectedCount > 0) { >+ button.prop("disabled", false).removeClass("btn-default").addClass("btn-primary"); >+ } else { >+ button.prop("disabled", true).removeClass("btn-primary").addClass("btn-default"); >+ } >+ } >+ >+ // Handle cashup selected modal >+ $("#confirmCashupSelectedModal").on("shown.bs.modal", function(e) { >+ var selectedCheckboxes = $(".register_checkbox:checked"); >+ var selectedCount = selectedCheckboxes.length; >+ var selectedIds = []; >+ var selectedNames = []; >+ >+ selectedCheckboxes.each(function() { >+ var registerRow = $(this).closest("tr"); >+ var registerId = $(this).val(); >+ var registerName = registerRow.find("td:nth-child(2) a").text(); // Second column (after checkbox) >+ >+ selectedIds.push(registerId); >+ selectedNames.push(registerName); >+ }); >+ >+ $("#selected_count").text(selectedCount); >+ $("#selected_registers_field").val(selectedIds.join(",")); >+ >+ // Populate register list >+ var listHtml = ""; >+ selectedNames.forEach(function(name) { >+ listHtml += "<li>" + name + "</li>"; >+ }); >+ $("#selected_registers_list").html(listHtml); >+ }); >+ >+ // Handle start cashup for selected >+ $("#start_selected_btn").on("click", function(e) { >+ e.preventDefault(); >+ var form = $(this).closest("form"); >+ form.find("#selected_operation").val("cud-cashup_start"); >+ form.submit(); >+ }); >+ >+ // Handle quick cashup for selected >+ $("#quick_selected_btn").on("click", function(e) { >+ e.preventDefault(); >+ var form = $(this).closest("form"); >+ form.find("#selected_operation").val("cud-cashup"); >+ form.submit(); > }); > }); > </script> >diff --git a/pos/register.pl b/pos/register.pl >index c8439f3f941..df1faf73def 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,18 +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 ); > } >- ); >- >- # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern) >- print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid ); >- exit; >+ } 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_amount => 1 ); > } >diff --git a/pos/registers.pl b/pos/registers.pl >index 4eb58459dd4..071a4a17824 100755 >--- a/pos/registers.pl >+++ b/pos/registers.pl >@@ -53,32 +53,155 @@ if ( !$registers->count ) { > $template->param( registers => $registers ); > } > >+# Handle success/error messages from redirects >+my $cashup_start_success = $input->param('cashup_start_success'); >+my $cashup_start_errors = $input->param('cashup_start_errors'); >+my $cashup_complete_success = $input->param('cashup_complete_success'); >+my $cashup_complete_errors = $input->param('cashup_complete_errors'); >+ >+if ($cashup_start_success) { >+ $template->param( cashup_start_success => $cashup_start_success ); >+} >+if ($cashup_start_errors) { >+ $template->param( cashup_start_errors => $cashup_start_errors ); >+} >+if ($cashup_complete_success) { >+ $template->param( cashup_complete_success => $cashup_complete_success ); >+} >+if ($cashup_complete_errors) { >+ $template->param( cashup_complete_errors => $cashup_complete_errors ); >+} >+ > 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' } ) ) { >- my $registerid = $input->param('registerid'); >- if ($registerid) { >- my $register = Koha::Cash::Registers->find( { id => $registerid } ); >- $register->add_cashup( >- { >- manager_id => $logged_in_user->id, >- amount => $register->outstanding_accountlines->total >+ my $registerid_param = $input->param('registerid'); >+ my @register_ids = split( ',', $registerid_param ); >+ my @errors = (); >+ my $success_count = 0; >+ >+ foreach my $register_id (@register_ids) { >+ $register_id =~ s/^\s+|\s+$//g; # Trim whitespace >+ next unless $register_id; >+ >+ my $register = Koha::Cash::Registers->find( { id => $register_id } ); >+ next unless $register; >+ >+ eval { >+ $register->start_cashup( >+ { >+ manager_id => $logged_in_user->id, >+ } >+ ); >+ $success_count++; >+ }; >+ if ($@) { >+ if ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) { >+ push @errors, "Register " . $register->name . ": Cashup already in progress"; >+ } else { >+ push @errors, "Register " . $register->name . ": Failed to start cashup"; > } >+ } >+ } >+ >+ if ( @errors && $success_count == 0 ) { >+ >+ # All failed - stay on page to show errors >+ $template->param( >+ error_cashup_start => 1, >+ cashup_errors => \@errors > ); > } else { >- for my $register ( $registers->as_list ) { >+ >+ # Some or all succeeded - redirect with coded parameters >+ my $redirect_url = "/cgi-bin/koha/pos/registers.pl"; >+ my @params; >+ >+ if ( $success_count > 0 ) { >+ push @params, "cashup_start_success=" . $success_count; >+ } >+ if (@errors) { >+ push @params, "cashup_start_errors=" . scalar(@errors); >+ } >+ >+ if (@params) { >+ $redirect_url .= "?" . join( "&", @params ); >+ } >+ >+ print $input->redirect($redirect_url); >+ exit; >+ } >+ } else { >+ $template->param( error_cashup_permission => 1 ); >+ } >+} elsif ( $op eq 'cud-cashup' ) { >+ if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) { >+ my $registerid_param = $input->param('registerid'); >+ my @register_ids = split( ',', $registerid_param ); >+ my @errors = (); >+ my $success_count = 0; >+ >+ foreach my $register_id (@register_ids) { >+ $register_id =~ s/^\s+|\s+$//g; # Trim whitespace >+ next unless $register_id; >+ >+ my $register = Koha::Cash::Registers->find( { id => $register_id } ); >+ next unless $register; >+ >+ eval { >+ # Quick cashup: calculate expected amount from outstanding accountlines >+ my $expected_amount = >+ $register->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) * -1; >+ >+ # Quick cashup assumes actual amount equals expected (no reconciliation needed) > $register->add_cashup( > { > manager_id => $logged_in_user->id, >- amount => $register->outstanding_accountlines->total >+ amount => $expected_amount, >+ >+ # No reconciliation_note = quick cashup assumes correct amounts > } > ); >+ $success_count++; >+ }; >+ if ($@) { >+ if ( $@->isa('Koha::Exceptions::Object::BadValue') ) { >+ push @errors, "Register " . $register->name . ": No cashup session to complete"; >+ } elsif ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) { >+ push @errors, "Register " . $register->name . ": Cashup already completed"; >+ } else { >+ push @errors, "Register " . $register->name . ": Failed to complete cashup"; >+ } > } > } > >- # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern) >- print $input->redirect("/cgi-bin/koha/pos/registers.pl"); >- exit; >+ if ( @errors && $success_count == 0 ) { >+ >+ # All failed - stay on page to show errors >+ $template->param( >+ error_cashup_complete => 1, >+ cashup_errors => \@errors >+ ); >+ } else { >+ >+ # Some or all succeeded - redirect with coded parameters >+ my $redirect_url = "/cgi-bin/koha/pos/registers.pl"; >+ my @params; >+ >+ if ( $success_count > 0 ) { >+ push @params, "cashup_complete_success=" . $success_count; >+ } >+ if (@errors) { >+ push @params, "cashup_complete_errors=" . scalar(@errors); >+ } >+ >+ if (@params) { >+ $redirect_url .= "?" . join( "&", @params ); >+ } >+ >+ print $input->redirect($redirect_url); >+ exit; >+ } > } 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 096de883cbb..b103b03375b 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,833 @@ 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' } ); >+ my $patron = $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 cash for first quick cashup >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register->id, >+ borrowernumber => $patron->id, >+ amount => -10.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ # 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 cash for second quick cashup >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register->id, >+ borrowernumber => $patron->id, >+ amount => -5.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ # 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' } ); >+ my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Add cash transactions before starting cashup >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register2->id, >+ borrowernumber => $patron->id, >+ amount => -5.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ # 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(); >+ >+ # Add more cash for second cashup >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register2->id, >+ borrowernumber => $patron->id, >+ amount => -3.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ # 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' } ); >+ my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Add cash for first quick cashup >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register3->id, >+ borrowernumber => $patron->id, >+ amount => -5.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ # Quick cashup first >+ my $quick = $register3->add_cashup( { manager_id => $manager->id, amount => '5.00' } ); >+ $quick->timestamp( \'NOW() - INTERVAL 40 MINUTE' )->store(); >+ >+ # Add cash for two-phase cashup >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register3->id, >+ borrowernumber => $patron->id, >+ amount => -3.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ # 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' } ); >+ my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Add cash for cashup >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register4->id, >+ borrowernumber => $patron->id, >+ amount => -2.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ # 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' } ); >+ >+ # Add cash for register5 >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register5->id, >+ borrowernumber => $patron->id, >+ amount => -2.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ 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' } ); >+ my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Add cash for the many quick cashups >+ for my $i ( 1 .. 10 ) { >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register6->id, >+ borrowernumber => $patron->id, >+ amount => -1.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ } >+ >+ # 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(); >+ } >+ >+ # Add cash for two-phase cashup >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register6->id, >+ borrowernumber => $patron->id, >+ amount => -2.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ # 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 $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Add cash transaction before starting cashup >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register1->id, >+ borrowernumber => $patron->id, >+ amount => -5.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ 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' } ); >+ my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Add cash transaction before starting cashup >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register3->id, >+ borrowernumber => $patron->id, >+ amount => -5.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ 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' } ); >+ my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ # Add cash transaction before starting cashup >+ $builder->build_object( >+ { >+ class => 'Koha::Account::Lines', >+ value => { >+ register_id => $register4->id, >+ borrowernumber => $patron->id, >+ amount => -5.00, >+ credit_type_code => 'PAYMENT', >+ payment_type => 'CASH', >+ } >+ } >+ ); >+ >+ # 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 + 0, 10, '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 e2836a05920..77777d8bee3 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,316 @@ 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; >+ >+ # Add initial cash transaction before starting cashup >+ my $initial_fine = $account2->add_debit( >+ { >+ amount => '3.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $initial_payment = $account2->pay( >+ { >+ cash_register => $register2->id, >+ amount => '3.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$initial_fine] >+ } >+ ); >+ >+ # 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; >+ >+ # Add initial cash transaction before starting cashup >+ my $initial_fine = $account->add_debit( >+ { >+ amount => '5.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $initial_payment = $account->pay( >+ { >+ cash_register => $register2->id, >+ amount => '5.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$initial_fine] >+ } >+ ); >+ >+ # 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.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