Bugzilla – Attachment 193182 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 optional two-phase cashup workflow
b6f0f39.patch (text/plain), 155.12 KB, created by
Martin Renvoize (ashimema)
on 2026-02-16 11:08:10 UTC
(
hide
)
Description:
Bug 40445: Implement optional two-phase cashup workflow
Filename:
MIME Type:
Creator:
Martin Renvoize (ashimema)
Created:
2026-02-16 11:08:10 UTC
Size:
155.12 KB
patch
obsolete
>From b6f0f395a2cdfaf2d58538dc3548bc2b6197b9b5 Mon Sep 17 00:00:00 2001 >From: Martin Renvoize <martin.renvoize@openfifth.co.uk> >Date: Mon, 16 Feb 2026 07:49:16 +0000 >Subject: [PATCH] Bug 40445: Implement optional two-phase cashup workflow > >This patch implements an optional two-phase cashup workflow for point >of sale operations, along with improved error handling and validation. > >Two-Phase Cashup Workflow: >- Staff can initiate cashup (CASHUP_START) and complete later >- Prevents new transactions on registers with cashups in progress >- Tracks cashup sessions with start/end timestamps >- Supports both quick cashup (immediate) and staged cashup workflows >- Updates batch cashup workflow operations across multiple registers > >Error Handling & Validation: >- Centralized exception handling for cashup operations >- Clear validation messages for missing parameters >- Prevents cashup when no cash transactions exist >- Improved handling of zero/negative amounts >- Informative error messages for user guidance > >Negative Amount Support: >- Supports negative cashup amounts for cashup deficits >- Automatic detection when actual cash is less than expected amount >- Creates appropriate CASHUP_DEFICIT records >- UI handles both positive and negative reconciliation amounts >- Updated calculations and display logic throughout > >Modal Refactoring: >- Eliminates code duplication between register.tt and registers.tt >- Centralized cashup modal functionality in cashup_modals.js >- Shared confirm_cashup.inc and trigger_cashup.inc templates >- Consistent user experience across all cashup workflows >- Improved maintainability and code organization > >Backend changes (Koha::Cash::Register): >- start_cashup(): Creates CASHUP_START action to begin session >- cashup_in_progress(): Detects active cashup sessions >- add_cashup(): Enhanced with two-phase completion support >- outstanding_accountlines(): Respects cashup session boundaries > >Frontend changes: >- Interactive modals for starting and completing cashups >- Real-time calculation displays >- Support for reconciliation with actual amounts >- Batch operations UI for multiple registers >- Responsive error messaging and validation feedback > >Test plan: >1. Apply patches and restart services >2. Run prove t/db_dependent/Koha/Cash/Register.t >3. Run prove t/db_dependent/Koha/Cash/Register/Cashup.t >4. Test single register workflows: > - Start cashup, add transactions, verify blocked > - Complete cashup with reconciliation amounts > - Test with positive, negative, and zero amounts >5. Test multi-register workflows: > - Select multiple registers on registers page > - Perform batch cashup operations > - Verify each register processes correctly >6. Test error conditions: > - Attempt cashup with no transactions > - Attempt cashup with invalid amounts > - Verify clear error messages displayed > >Sponsored-by: OpenFifth <https://openfifth.co.uk/> >Signed-off-by: Jackie Usher <jackie.usher@westsussex.gov.uk> >--- > Koha/Cash/Register.pm | 380 +++++- > Koha/Cash/Register/Cashup.pm | 144 ++- > .../en/includes/modals/confirm_cashup.inc | 106 ++ > .../en/includes/modals/trigger_cashup.inc | 50 + > .../prog/en/modules/pos/register.tt | 178 ++- > .../prog/en/modules/pos/registers.tt | 307 ++++- > .../prog/js/modals/cashup_modals.js | 277 +++++ > pos/register.pl | 110 +- > pos/registers.pl | 191 ++- > t/db_dependent/Koha/Cash/Register.t | 1098 ++++++++++++++++- > t/db_dependent/Koha/Cash/Register/Cashup.t | 315 ++++- > 11 files changed, 2825 insertions(+), 331 deletions(-) > create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/modals/confirm_cashup.inc > create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/modals/trigger_cashup.inc > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/modals/cashup_modals.js > >diff --git a/Koha/Cash/Register.pm b/Koha/Cash/Register.pm >index dd22ba0a7b0..611347e8568 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,87 @@ 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 = $self->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) * -1; >+ >+ # Prevent starting a cashup when there are no transactions at all >+ my $total_transactions = $self->outstanding_accountlines->total() * -1; >+ unless ( $total_transactions != 0 ) { >+ Koha::Exceptions::Object::BadValue->throw( >+ error => "Cannot start cashup with no transactions", >+ type => 'amount', >+ value => $total_transactions >+ ); >+ } >+ >+ # Create the CASHUP_START action using centralized exception handling >+ my $schema = $self->_result->result_source->schema; >+ my $rs = $schema->safe_do( >+ sub { >+ return $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 +328,83 @@ 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 is a valid number >+ my $amount = $params->{amount}; >+ unless ( looks_like_number($amount) ) { >+ Koha::Exceptions::Account::AmountNotPositive->throw( error => 'Cashup amount must be a valid 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) { >+ >+ # 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); >+ } >+ >+ } > >- # For backward compatibility, if no actual amount is specified, use expected amount >- $amount //= abs($expected_amount); >+ # 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; >+ >+ # Validate reconciliation note requirement if there's a discrepancy >+ if ( $difference != 0 ) { >+ my $note_required = C4::Context->preference('CashupReconciliationNoteRequired') // 0; >+ >+ if ( $note_required && !defined $reconciliation_note ) { >+ Koha::Exceptions::MissingParameter->throw( >+ error => "Reconciliation note is required when cashup amount differs from expected amount" ); >+ } >+ } > > # Use database transaction to ensure consistency > my $schema = $self->_result->result_source->schema; >@@ -265,36 +412,52 @@ sub add_cashup { > > $schema->txn_do( > sub { >- # Create the cashup action with actual amount >- my $rs = $self->_result->add_to_cash_register_actions( >- { >- code => 'CASHUP', >- manager_id => $manager_id, >- amount => $amount >+ # Create the cashup action - safe_do handles exception translation >+ my $rs = $schema->safe_do( >+ sub { >+ return $self->_result->add_to_cash_register_actions( >+ { >+ code => 'CASHUP', >+ manager_id => $manager_id, >+ amount => $amount >+ } >+ )->discard_changes; > } >- )->discard_changes; >- >+ ); > $cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs); > > # 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 >- amountoutstanding => 0, >- description => 'Cash register surplus found during cashup', >- credit_type_code => 'CASHUP_SURPLUS', >- payment_type => 'CASH', >- manager_id => $manager_id, >- interface => 'intranet', >- branchcode => $self->branch, >- register_id => $self->id, >- note => $reconciliation_note >+ date => $reconciliation_date, >+ amount => -abs($difference), # Credits are negative >+ amountoutstanding => 0, >+ credit_type_code => 'CASHUP_SURPLUS', >+ manager_id => $manager_id, >+ interface => 'intranet', >+ branchcode => $self->branch, >+ register_id => $self->id, >+ payment_type => 'CASH', >+ note => $reconciliation_note > } > )->store(); > >@@ -312,17 +475,16 @@ sub add_cashup { > # Deficit: less cash found than expected > my $deficit = Koha::Account::Line->new( > { >- date => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)', >- amount => abs($difference), >- amountoutstanding => 0, >- description => 'Cash register deficit found during cashup', >- debit_type_code => 'CASHUP_DEFICIT', >- payment_type => 'CASH', >- manager_id => $manager_id, >- interface => 'intranet', >- branchcode => $self->branch, >- register_id => $self->id, >- note => $reconciliation_note >+ date => $reconciliation_date, >+ amount => abs($difference), >+ amountoutstanding => 0, >+ debit_type_code => 'CASHUP_DEFICIT', >+ manager_id => $manager_id, >+ interface => 'intranet', >+ branchcode => $self->branch, >+ register_id => $self->id, >+ payment_type => 'CASH', >+ note => $reconciliation_note > } > )->store(); > my $account_offset = Koha::Account::Offset->new( >@@ -341,6 +503,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 35367230a0e..01ffb9fcb48 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( > { >@@ -198,8 +204,8 @@ sub summary { > my $deficit_note = $deficit_record ? $deficit_record->note : 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, >@@ -217,6 +223,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/includes/modals/confirm_cashup.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/confirm_cashup.inc >new file mode 100644 >index 00000000000..e0f4e53c377 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/confirm_cashup.inc >@@ -0,0 +1,106 @@ >+[% USE raw %] >+<!-- Confirm cashup modal --> >+<!-- Parameters: >+ - modal_id: ID for the modal (required) >+ - reconciliation_note_avs: Authorized values for notes (optional) >+ - reconciliation_note_required: Whether note is required (optional) >+ - cashup_in_progress: Whether completing in-progress cashup (optional) >+ - form_action: Form action URL (optional, defaults to current page) >+ - redirect_to: Where to redirect after completion (optional: 'register' or 'registers') >+--> >+<div class="modal" id="[% modal_id | html %]" tabindex="-1" role="dialog" aria-labelledby="[% modal_id | html %]Label"> >+ <form method="post" enctype="multipart/form-data" class="validated confirm-cashup-form" [% IF form_action %]action="[% form_action | html %]"[% END %]> >+ [% INCLUDE 'csrf-token.inc' %] >+ <div class="modal-dialog"> >+ <div class="modal-content"> >+ <div class="modal-header"> >+ <h1 class="modal-title" id="[% modal_id | html %]Label"> >+ [% IF cashup_in_progress %] >+ Complete cashup of <em><span class="register-name">[% register.description | html %]</span></em> >+ [% ELSE %] >+ Confirm cashup of <em><span class="register-name"></span></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-label"> >+ [% IF cashup_in_progress %] >+ [% IF cashup_in_progress.amount < 0 %] >+ Expected amount to add: >+ [% ELSE %] >+ Expected cashup amount: >+ [% END %] >+ [% ELSE %] >+ Expected cashup amount: >+ [% END %] >+ </span> >+ <span class="expected-amount">[% IF cashup_in_progress %][% cashup_in_progress.amount | $Price %][% END %]</span> >+ </li> >+ <li> >+ <label class="required actual-amount-label" for="cashup_amount"> >+ [% IF cashup_in_progress %] >+ [% IF cashup_in_progress.amount < 0 %] >+ Actual amount added to register: >+ [% ELSE %] >+ Actual cashup amount counted: >+ [% END %] >+ [% ELSE %] >+ Actual cashup amount counted: >+ [% END %] >+ </label> >+ <input type="text" inputmode="decimal" pattern="^-?\d+(\.\d{2})?$" id="cashup_amount" name="amount" class="cashup-amount-input" required="required" /> >+ <span class="required">Required</span> >+ </li> >+ <li class="reconciliation-display" style="display: none;"> >+ <span class="label">Reconciliation:</span> >+ <span class="reconciliation-text"></span> >+ </li> >+ <li class="reconciliation-note-field" style="display: none;"> >+ <label class="reconciliation-note-label" for="cashup_reconciliation_note"> Note[% IF reconciliation_note_required %](required)[% ELSE %](optional)[% END %]: </label> >+ [% IF reconciliation_note_avs %] >+ <select id="cashup_reconciliation_note" class="reconciliation-note-input" name="reconciliation_note"> >+ <option value="">-- Select a reason --</option> >+ [% FOREACH av IN reconciliation_note_avs %] >+ <option value="[% av.authorised_value | html %]">[% av.lib | html %]</option> >+ [% END %] >+ </select> >+ [% IF reconciliation_note_required %] >+ <span class="required">Required</span> >+ [% END %] >+ [% ELSE %] >+ <textarea >+ id="cashup_reconciliation_note" >+ class="reconciliation-note-input" >+ name="reconciliation_note" >+ rows="3" >+ cols="40" >+ maxlength="1000" >+ placeholder="Enter a note explaining the surplus or deficit..." >+ ></textarea> >+ [% IF reconciliation_note_required %] >+ <span class="required">Required</span> >+ [% END %] >+ <div class="hint">Maximum 1000 characters</div> >+ [% END %] >+ </li> >+ </ol> >+ </fieldset> >+ </div> >+ <div class="modal-footer"> >+ <input type="hidden" name="registerid" class="register-id-field" value="[% IF cashup_in_progress %][% register.id | html %][% END %]" /> >+ <input type="hidden" name="op" value="cud-cashup" /> >+ [% IF redirect_to %] >+ <input type="hidden" name="redirect_to" value="[% redirect_to | html %]" /> >+ [% END %] >+ <button type="submit" class="btn btn-primary"> [% 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> >+ </div> >+ </div> >+ </form> >+</div> >+<!-- /#[% modal_id | html %] --> >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/trigger_cashup.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/trigger_cashup.inc >new file mode 100644 >index 00000000000..302e985f5c6 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/trigger_cashup.inc >@@ -0,0 +1,50 @@ >+[% USE raw %] >+<!-- Trigger cashup modal --> >+<!-- Parameters: >+ - modal_id: ID for the modal (required) >+ - register_description: Register description to pre-populate (optional, for register.tt) >+ - register_id: Register ID to pre-populate (optional, for register.tt) >+ - form_action: Form action URL (optional, defaults to current page) >+ - redirect_to: Where to redirect after completion (optional: 'register' or 'registers') >+--> >+<div class="modal" id="[% modal_id | html %]" tabindex="-1" role="dialog" aria-labelledby="[% modal_id | html %]Label"> >+ <form method="post" class="validated trigger-cashup-form" [% IF form_action %]action="[% form_action | html %]"[% END %]> >+ [% INCLUDE 'csrf-token.inc' %] >+ <div class="modal-dialog"> >+ <div class="modal-content"> >+ <div class="modal-header"> >+ <h1 class="modal-title" id="[% modal_id | html %]Label"> >+ Cashup for <em><span class="register-description">[% IF register_description %][% register_description | html %][% END %]</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 class="start-cashup-instructions"> >+ <!-- JavaScript will populate this based on positive/negative amount --> >+ </ul> >+ <p><strong>Quick cashup</strong></p> >+ <ul class="quick-cashup-instructions"> >+ <!-- JavaScript will populate this based on positive/negative amount --> >+ </ul> >+ <p class="float-reminder-text"> >+ <!-- JavaScript will populate this based on positive/negative amount --> >+ </p> >+ </div> >+ <div class="modal-footer"> >+ <input type="hidden" name="registerid" class="register-id-field" value="[% IF register_id %][% register_id | html %][% END %]" /> >+ <input type="hidden" name="op" value="cud-cashup_start" /> >+ <input type="hidden" name="amount" value="" /> >+ [% IF redirect_to %] >+ <input type="hidden" name="redirect_to" value="[% redirect_to | html %]" /> >+ [% END %] >+ <button type="submit" class="btn btn-primary">Start cashup</button> >+ <button type="button" class="btn btn-success quick-cashup-btn">Quick cashup</button> >+ <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button> >+ </div> >+ </div> >+ </div> >+ </form> >+</div> >+<!-- /#[% modal_id | html %] --> >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 4464c1a9db7..13fe42f07cb 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,73 @@ > <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_cashup_no_transactions ) %] >+ <div id="error_message" class="alert alert-info"> Cannot start cashup - there are no transactions in this register since the last cashup. </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_missing_param ) %] >+ <div id="error_message" class="alert alert-warning"> Missing required parameter for cashup: [% error_message | html %] </div> >+ [% END %] >+ >+ [% IF ( error_cashup_amount_invalid ) %] >+ <div id="error_message" class="alert alert-warning"> The cashup amount must be a valid number. </div> >+ [% END %] >+ >+ [% IF ( error_reconciliation_note_required ) %] >+ <div id="error_message" class="alert alert-warning"> Reconciliation note is required when cashup amount differs from expected amount. </div> >+ [% END %] >+ >+ [% IF ( error_cashup_complete ) %] >+ <div id="error_message" class="alert alert-warning"> >+ Failed to complete cashup. Please try again. >+ [% IF error_details %] >+ <br /><strong>Error details:</strong> [% error_details | html %] >+ [% END %] >+ </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> >+ [% SET progress_timestamp = cashup_in_progress.timestamp | $KohaDates(with_hours => 1) %] >+ [% tx("Cashup in progress - started {timestamp}. You can continue to make transactions while counting cash.", { timestamp = progress_timestamp }) | html %] >+ (<a data-bs-toggle="modal" data-cashup="[% cashup_in_progress.id | html %]" data-register="[% register.description | html %]" data-in-progress="true" href="#cashupSummaryModal" class="button" >+ >[% t("Preview cashup summary") | html %]</a >+ >) >+ </div> >+ [% END %] >+ >+ [% SET total_bankable = accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 %] >+ [% SET total_transactions = accountlines.total() * -1 %] > [% 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" [% IF total_transactions == 0 %]disabled title="No transactions"[% END %]> >+ <i class="fa fa-money-bill-alt"></i> Record cashup >+ </button> >+ [% END %] > </div> > [% END %] > >@@ -81,7 +141,7 @@ > <li>Float: [% register.starting_float | $Price %]</li> > <li>Total income (cash): [% accountlines.credits_total * -1 | $Price %] ([% accountlines.credits_total(payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %])</li> > <li>Total outgoing (cash): [% accountlines.debits_total * -1 | $Price %] ([% accountlines.debits_total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %])</li> >- <li>Total bankable: [% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %]</li> >+ <li>Total bankable: [% total_bankable | $Price %]</li> > </ul> > > [% IF register.last_cashup %] >@@ -368,60 +428,6 @@ > [% END %] > [% END %] > >-<!-- Confirm cashup modal --> >-<div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel"> >- <form id="cashup_form" method="post" enctype="multipart/form-data" 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>[% register.description | html %]</em></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> >- </li> >- <li> >- <label class="required" for="amount">Actual amount removed from register:</label> >- <input type="text" inputmode="decimal" pattern="^\d+(\.\d{2})?$" id="amount" name="amount" required="required" /> >- <span class="required">Required</span> >- </li> >- <li id="reconciliation_display" style="display: none;"> >- <span class="label">Reconciliation:</span> >- <span id="reconciliation_text"></span> >- </li> >- <li id="reconciliation_note_field" style="display: none;"> >- <label for="reconciliation_note">Note (optional):</label> >- <textarea id="reconciliation_note" name="reconciliation_note" rows="3" cols="40" maxlength="1000" placeholder="Enter a note explaining the surplus or deficit..."></textarea> >- <div class="hint">Maximum 1000 characters</div> >- </li> >- </ol> >- </fieldset> >- </div> >- <!-- /.modal-body --> >- <div class="modal-footer"> >- <input type="hidden" name="registerid" value="[% register.id | html %]" /> >- <input type="hidden" name="op" value="cud-cashup" /> >- <button type="submit" class="btn btn-primary" id="pos_cashup_confirm">Confirm cashup</button> >- <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button> >- </div> >- <!-- /.modal-footer --> >- </div> >- <!-- /.modal-content --> >- </div> >- <!-- /.modal-dialog --> >- </form> >-</div> >-<!-- /#confirmCashupModal --> >- > <!-- Issue refund modal --> > <div class="modal" id="issueRefundModal" tabindex="-1" role="dialog" aria-labelledby="issueRefundLabel"> > <form id="refund_form" method="post" enctype="multipart/form-data" class="validated"> >@@ -466,12 +472,15 @@ > <!-- /#issueRefundModal --> > > [% INCLUDE 'modals/cashup_summary.inc' %] >+[% INCLUDE 'modals/trigger_cashup.inc' modal_id='triggerCashupModal' register_description=register.description register_id=register.id %] >+[% INCLUDE 'modals/confirm_cashup.inc' modal_id='confirmCashupModal' reconciliation_note_avs=reconciliation_note_avs reconciliation_note_required=reconciliation_note_required cashup_in_progress=cashup_in_progress %] > > [% MACRO jsinclude BLOCK %] > [% INCLUDE 'datatables.inc' %] > [% INCLUDE 'format_price.inc' %] > [% INCLUDE 'js-date-format.inc' %] > [% Asset.js("js/cashup_modal.js") | $raw %] >+ [% Asset.js("js/modals/cashup_modals.js") | $raw %] > [% Asset.js("js/modal_printer.js") | $raw %] > [% INCLUDE 'calendar.inc' %] > <script> >@@ -617,57 +626,16 @@ > } > }); > >- // 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(); >- } >+ // Initialize cashup modals >+ initTriggerCashupModal('#triggerCashupModal', { >+ bankableAmount: [% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | html %], >+ registerFloat: [% register.starting_float | html %] > }); > >- // Reset modal when opened >- $("#confirmCashupModal").on("shown.bs.modal", function() { >- // Start with empty actual amount field (user must enter amount) >- $("#amount").val('').focus(); >- $("#reconciliation_display").hide(); >- $("#reconciliation_note_field").hide(); >- $("#reconciliation_note").val(''); >+ initConfirmCashupModal('#confirmCashupModal', { >+ hasAuthorisedValues: [% reconciliation_note_avs ? 'true' : 'false' | html %], >+ noteRequired: [% reconciliation_note_required ? 'true' : 'false' | html %], >+ isInProgress: [% cashup_in_progress ? 'true' : 'false' | html %] > }); > </script> > [% END %] >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 4c34a41dcde..19f820f9061 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> >@@ -79,7 +156,20 @@ > <tbody> > [% SET bankable = 0, ctotal = 0, dtotal = 0, cctotal = 0, cdtotal = 0 %] > [% FOREACH register IN registers %] >+ [% SET rbankable = ( register.outstanding_accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 ) %] >+ [% SET rtotal = ( register.outstanding_accountlines.total() * -1 ) %] > <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" /> >+ [% ELSIF rtotal == 0 %] >+ <input type="checkbox" class="register_checkbox" value="[% register.id | html %]" disabled title="No transactions" /> >+ [% 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> >@@ -92,7 +182,6 @@ > </td> > <td>[% register.starting_float | $Price %]</td> > <td> >- [% SET rbankable = ( register.outstanding_accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 ) %] > [% SET bankable = bankable + rbankable %] > [% rbankable | $Price %] > </td> >@@ -112,17 +201,33 @@ > </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-expected="[% register.cashup_in_progress.amount | $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 %]" >+ [% IF rtotal == 0 %]disabled title="No transactions"[% END %] >+ ><i class="fa-solid fa-money-bill-1"></i> Record cashup</button >+ > >+ [% END %] > </td> > [% END %] > </tr> >@@ -130,13 +235,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,34 +258,67 @@ > [% END %] > [% END %] > >-<!-- 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"> >+<!-- 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="confirmCashupLabel" >- >Confirm cashup of <em><span id="registerc"></span></em >- ></h1> >+ <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"> Please confirm that you have removed <span id="cashc"></span> from the cash register and left a float of <span id="floatc"></span>. </div> >- <!-- /.modal-body --> >+ <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="cashup_registerid" value="" /> >- <input type="hidden" name="op" value="cud-cashup" /> >- <button type="submit" class="btn btn-primary" id="cashup_confirm">Confirm</button> >+ <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> >- <!-- /.modal-footer --> > </div> >- <!-- /.modal-content --> > </div> >- <!-- /.modal-dialog --> > </form> > </div> >-<!-- /#confirmCashupModal --> >+<!-- /#confirmCashupSelectedModal --> > > <!-- Confirm cashupall modal --> > <div class="modal" id="confirmCashupAllModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupAllLabel"> >@@ -209,12 +353,15 @@ > <!-- /#confirmCashupAllModal --> > > [% INCLUDE 'modals/cashup_summary.inc' %] >+[% INCLUDE 'modals/trigger_cashup.inc' modal_id='triggerCashupModalRegister' form_action='/cgi-bin/koha/pos/register.pl' redirect_to='registers' %] >+[% INCLUDE 'modals/confirm_cashup.inc' modal_id='confirmCashupModal' reconciliation_note_avs=reconciliation_note_avs reconciliation_note_required=reconciliation_note_required form_action='/cgi-bin/koha/pos/register.pl' redirect_to='registers' %] > > [% MACRO jsinclude BLOCK %] > [% INCLUDE 'datatables.inc' %] > [% INCLUDE 'format_price.inc' %] > [% INCLUDE 'js-date-format.inc' %] > [% Asset.js("js/cashup_modal.js") | $raw %] >+ [% Asset.js("js/modals/cashup_modals.js") | $raw %] > [% Asset.js("js/modal_printer.js") | $raw %] > <script> > $(document).ready(function () { >@@ -225,20 +372,96 @@ > $("#outgoing").text('[% dtotal | $Price %] ([% cdtotal | $Price %])'); > > var registers_table = $("#registers").kohaTable({ >+ columnDefs: [{ targets: [ -1, 0 ], orderable: false }], > searching: false, > paginationType: "full", > }); > >- $("#confirmCashupModal").on("shown.bs.modal", function(e){ >- var button = $(e.relatedTarget); >- var register = button.data('register'); >- $("#registerc").text(register); >- var bankable = button.data('bankable'); >- $("#cashc").text(bankable); >- var rfloat = button.data('float'); >- $('#floatc').text(rfloat); >- var rid = button.data('registerid'); >- $('#cashup_registerid').val(rid); >+ // Initialize cashup modals >+ initTriggerCashupModal('#triggerCashupModalRegister'); >+ >+ initConfirmCashupModal('#confirmCashupModal', { >+ hasAuthorisedValues: [% reconciliation_note_avs ? 'true' : 'false' | html %], >+ noteRequired: [% reconciliation_note_required ? 'true' : 'false' | html %] >+ }); >+ >+ // 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(); > }); > > // Check for cashup hash in URL >diff --git a/koha-tmpl/intranet-tmpl/prog/js/modals/cashup_modals.js b/koha-tmpl/intranet-tmpl/prog/js/modals/cashup_modals.js >new file mode 100644 >index 00000000000..34c82d3a85d >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/modals/cashup_modals.js >@@ -0,0 +1,277 @@ >+/** >+ * Cashup Modal JavaScript Module >+ * Shared initialization functions for cashup modals across POS register pages >+ */ >+ >+/** >+ * Initialize trigger cashup modal behavior >+ * @param {string} modalSelector - jQuery selector for the modal (e.g., '#triggerCashupModal') >+ * @param {object} options - Configuration options >+ * @param {number} options.registerFloat - Starting float amount (for register.tt) >+ * @param {number} options.bankableAmount - Bankable amount (for register.tt) >+ */ >+function initTriggerCashupModal(modalSelector, options) { >+ options = options || {}; >+ >+ $(modalSelector).on("shown.bs.modal", function (e) { >+ var button = $(e.relatedTarget); >+ var modal = $(this); >+ >+ // Get data from button (for registers.tt) or options (for register.tt) >+ var register = button.data("register"); >+ var bankable = button.data("bankable"); >+ var rfloat = button.data("float"); >+ var rid = button.data("registerid"); >+ >+ // For register.tt, use options if provided >+ if (options.bankableAmount !== undefined) { >+ bankable = options.bankableAmount; >+ } >+ if (options.registerFloat !== undefined) { >+ rfloat = options.registerFloat; >+ } >+ >+ // Populate register description if available >+ if (register) { >+ modal.find(".register-description").text(register); >+ } >+ >+ // Set register ID if available >+ if (rid) { >+ modal.find(".register-id-field").val(rid); >+ } >+ >+ // Guard against undefined/null bankable value >+ if (bankable === undefined || bankable === null) { >+ console.error("Bankable amount is undefined"); >+ return; >+ } >+ >+ // Parse bankable amount (remove currency formatting, keep minus sign) >+ var bankableAmount = String(bankable).replace(/[^0-9.-]/g, ""); >+ var numericAmount = parseFloat(bankableAmount); >+ var isNegative = numericAmount < 0; >+ >+ // Format amounts for display >+ var absAmountFormatted = Math.abs(numericAmount).format_price(); >+ var floatFormatted = rfloat; >+ if (typeof rfloat === "number") { >+ floatFormatted = rfloat.format_price(); >+ } >+ >+ // Update Start cashup instructions >+ var startInstructions; >+ if (isNegative) { >+ startInstructions = >+ "<li>" + >+ __("Count cash in the register") + >+ "</li>" + >+ "<li>" + >+ __("The register can continue operating during counting") + >+ "</li>" + >+ "<li>" + >+ __("Complete the cashup by adding cash to restore the float") + >+ "</li>"; >+ } else { >+ startInstructions = >+ "<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>"; >+ } >+ modal.find(".start-cashup-instructions").html(startInstructions); >+ >+ // Update Quick cashup instructions >+ var quickInstructions; >+ if (isNegative) { >+ quickInstructions = >+ "<li>" + >+ __("Top up the register with %s to restore the float").format( >+ absAmountFormatted >+ ) + >+ "</li>"; >+ } else { >+ quickInstructions = >+ "<li>" + >+ __( >+ "Confirm you have removed %s cash from the register to bank immediately" >+ ).format(absAmountFormatted) + >+ "</li>"; >+ } >+ modal.find(".quick-cashup-instructions").html(quickInstructions); >+ >+ // Update float reminder >+ var floatReminder; >+ if (isNegative) { >+ floatReminder = __( >+ "This will bring the register back to the expected float of <strong>%s</strong>" >+ ).format(floatFormatted); >+ } else { >+ floatReminder = __( >+ "Remember to leave the float amount of <strong>%s</strong> in the register." >+ ).format(floatFormatted); >+ } >+ modal.find(".float-reminder-text").html(floatReminder); >+ >+ // Store bankable amount for quick cashup (with sign) >+ modal.data("bankable-amount", bankableAmount); >+ }); >+ >+ // Handle Quick cashup button click >+ $(modalSelector + " .quick-cashup-btn").on("click", function (e) { >+ e.preventDefault(); >+ var form = $(this).closest("form"); >+ var modal = $(this).closest(".modal"); >+ var bankableAmount = modal.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(); >+ }); >+} >+ >+/** >+ * Initialize confirm cashup modal behavior with reconciliation calculation >+ * @param {string} modalSelector - jQuery selector for the modal (e.g., '#confirmCashupModal') >+ * @param {object} options - Configuration options >+ * @param {boolean} options.noteRequired - Whether reconciliation note is required when there's a discrepancy >+ * @param {boolean} options.hasAuthorisedValues - Whether authorized values are configured for notes >+ * @param {boolean} options.isInProgress - Whether this is completing an in-progress cashup (for register.tt) >+ */ >+function initConfirmCashupModal(modalSelector, options) { >+ options = options || {}; >+ var noteRequired = options.noteRequired || false; >+ var hasAuthorisedValues = options.hasAuthorisedValues || false; >+ var isInProgress = options.isInProgress || false; >+ >+ // Real-time reconciliation calculation >+ $(modalSelector + " .cashup-amount-input").on("input", function () { >+ var modal = $(this).closest(".modal"); >+ var actualAmount = parseFloat($(this).val()) || 0; >+ var expectedText = modal >+ .find(".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; >+ } >+ >+ modal >+ .find(".reconciliation-text") >+ .text(reconciliationText) >+ .removeClass("success warning") >+ .addClass(reconciliationClass); >+ modal.find(".reconciliation-display").show(); >+ >+ // Show/hide note field based on whether there's a discrepancy >+ if (hasDiscrepancy) { >+ modal.find(".reconciliation-note-field").show(); >+ >+ // Update required attribute and label based on system preference >+ if (noteRequired) { >+ modal >+ .find(".reconciliation-note-input") >+ .attr("required", "required"); >+ modal >+ .find(".reconciliation-note-label") >+ .addClass("required"); >+ } else { >+ modal >+ .find(".reconciliation-note-input") >+ .removeAttr("required"); >+ modal >+ .find(".reconciliation-note-label") >+ .removeClass("required"); >+ } >+ } else { >+ modal.find(".reconciliation-note-field").hide(); >+ modal.find(".reconciliation-note-input").val(""); // Clear note when balanced >+ modal.find(".reconciliation-note-input").removeAttr("required"); >+ modal >+ .find(".reconciliation-note-label") >+ .removeClass("required"); >+ } >+ } else { >+ modal.find(".reconciliation-display").hide(); >+ modal.find(".reconciliation-note-field").hide(); >+ } >+ }); >+ >+ // Reset/populate modal when opened >+ $(modalSelector).on("shown.bs.modal", function (e) { >+ var button = $(e.relatedTarget); >+ var modal = $(this); >+ >+ // For registers.tt: populate from button data >+ if (button.length && button.data("register")) { >+ var register = button.data("register"); >+ modal.find(".register-name").text(register); >+ >+ var expected = button.data("expected"); >+ modal.find(".expected-amount").text(expected); >+ >+ var rid = button.data("registerid"); >+ modal.find(".register-id-field").val(rid); >+ >+ // Parse expected amount to check if negative >+ // Convert to string first in case jQuery's .data() parsed it as a number >+ var expectedAmount = String(expected || "").replace( >+ /[^0-9.-]/g, >+ "" >+ ); >+ var isNegative = parseFloat(expectedAmount) < 0; >+ >+ // Update labels based on sign >+ if (isNegative) { >+ modal >+ .find(".expected-amount-label") >+ .text(__("Expected amount to add:")); >+ modal >+ .find(".actual-amount-label") >+ .text(__("Actual amount added to register:")); >+ } else { >+ modal >+ .find(".expected-amount-label") >+ .text(__("Expected cashup amount:")); >+ modal >+ .find(".actual-amount-label") >+ .text(__("Actual cashup amount counted:")); >+ } >+ } >+ >+ // Reset fields >+ modal.find(".cashup-amount-input").val("").focus(); >+ modal.find(".reconciliation-display").hide(); >+ modal.find(".reconciliation-note-field").hide(); >+ modal.find(".reconciliation-note-input").val(""); >+ }); >+} >diff --git a/pos/register.pl b/pos/register.pl >index b721204b407..e2c226c95c0 100755 >--- a/pos/register.pl >+++ b/pos/register.pl >@@ -63,11 +63,27 @@ 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(); >+ >+ # Get authorized values for reconciliation notes if configured >+ my $note_av_category = C4::Context->preference('CashupReconciliationNoteAuthorisedValue'); >+ my $reconciliation_note_avs; >+ if ($note_av_category) { >+ require Koha::AuthorisedValues; >+ $reconciliation_note_avs = Koha::AuthorisedValues->search( >+ { category => $note_av_category }, >+ { order_by => { '-asc' => 'lib' } } >+ ); >+ } >+ > $template->param( >- register => $cash_register, >- accountlines => $accountlines >+ register => $cash_register, >+ accountlines => $accountlines, >+ cashup_in_progress => $cashup_in_progress, >+ reconciliation_note_avs => $reconciliation_note_avs, >+ reconciliation_note_required => C4::Context->preference('CashupReconciliationNoteRequired'), > ); > > my $transactions_range_from = $input->param('trange_f'); >@@ -102,12 +118,44 @@ 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 ); >+ } elsif ( $@->isa('Koha::Exceptions::Object::BadValue') ) { >+ $template->param( error_cashup_no_transactions => 1 ); >+ } else { >+ $template->param( error_cashup_start => 1 ); >+ } >+ } else { >+ >+ # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern) >+ my $redirect_to = $input->param('redirect_to') || 'register'; >+ >+ if ( $redirect_to eq 'registers' ) { >+ print $input->redirect("/cgi-bin/koha/pos/registers.pl?cashup_start_success=1"); >+ } else { >+ 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'); > >- if ( defined $amount && $amount =~ /^\d+(?:\.\d{1,2})?$/ ) { >+ if ( defined $amount && $amount =~ /^-?\d+(?:\.\d{1,2})?$/ ) { > > # Sanitize and limit note length > if ( defined $reconciliation_note ) { >@@ -116,19 +164,49 @@ if ( !$registers->count ) { > $reconciliation_note = undef if $reconciliation_note eq ''; > } > >- my $cashup = $cash_register->add_cashup( >- { >- manager_id => $logged_in_user->id, >- amount => $amount, >- reconciliation_note => $reconciliation_note >+ my $cashup; >+ eval { >+ $cashup = $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 ); >+ } elsif ( $@->isa('Koha::Exceptions::MissingParameter') ) { >+ >+ # Check if this is a reconciliation note error specifically >+ if ( $@->error =~ /Reconciliation note is required/ ) { >+ $template->param( error_reconciliation_note_required => 1 ); >+ } else { >+ $template->param( error_cashup_missing_param => 1, error_message => $@ ); >+ } >+ } elsif ( $@->isa('Koha::Exceptions::Account::AmountNotPositive') ) { >+ $template->param( error_cashup_amount_invalid => 1 ); >+ } else { >+ >+ # Log the actual exception for debugging >+ $template->param( error_cashup_complete => 1, error_details => "$@" ); > } >- ); >+ } else { > >- # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern) >- print $input->redirect( >- "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid . "#cashup-" . $cashup->id ); >- exit; >+ # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern) >+ my $redirect_to = $input->param('redirect_to') || 'register'; > >+ if ( $redirect_to eq 'registers' ) { >+ print $input->redirect("/cgi-bin/koha/pos/registers.pl?cashup_complete_success=1"); >+ } else { >+ print $input->redirect( >+ "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid . "#cashup-" . $cashup->id ); >+ } >+ exit; >+ } > } else { > $template->param( error_cashup_amount => 1 ); > } >diff --git a/pos/registers.pl b/pos/registers.pl >index 6718449dd32..8e4543772c7 100755 >--- a/pos/registers.pl >+++ b/pos/registers.pl >@@ -42,6 +42,17 @@ my $logged_in_user = Koha::Patrons->find($loggedinuser) or die "Not logged in"; > my $library = Koha::Libraries->find( C4::Context->userenv->{'branch'} ); > $template->param( library => $library ); > >+# Get authorized values for reconciliation notes if configured >+my $note_av_category = C4::Context->preference('CashupReconciliationNoteAuthorisedValue'); >+my $reconciliation_note_avs; >+if ($note_av_category) { >+ require Koha::AuthorisedValues; >+ $reconciliation_note_avs = Koha::AuthorisedValues->search( >+ { category => $note_av_category }, >+ { order_by => { '-asc' => 'lib' } } >+ ); >+} >+ > my $registers = Koha::Cash::Registers->search( > { branch => $library->id, archived => 0 }, > { order_by => { '-asc' => 'name' } } >@@ -50,37 +61,177 @@ my $registers = Koha::Cash::Registers->search( > if ( !$registers->count ) { > $template->param( error_registers => 1 ); > } else { >- $template->param( registers => $registers ); >+ $template->param( >+ registers => $registers, >+ reconciliation_note_avs => $reconciliation_note_avs, >+ reconciliation_note_required => C4::Context->preference('CashupReconciliationNoteRequired'), >+ ); >+} >+ >+# 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'); >- my $redirect_url = "/cgi-bin/koha/pos/registers.pl"; >- if ($registerid) { >- my $register = Koha::Cash::Registers->find( { id => $registerid } ); >- my $cashup = $register->add_cashup( >- { >- manager_id => $logged_in_user->id, >- amount => $register->outstanding_accountlines->total >- } >- ); >- $redirect_url .= "#cashup-" . $cashup->id; >- } else { >- for my $register ( $registers->as_list ) { >- $register->add_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 { >+ $register->start_cashup( > { > manager_id => $logged_in_user->id, >- amount => $register->outstanding_accountlines->total > } > ); >+ $success_count++; >+ }; >+ if ($@) { >+ if ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) { >+ push @errors, "Register " . $register->name . ": Cashup already in progress"; >+ } elsif ( $@->isa('Koha::Exceptions::Object::BadValue') ) { >+ push @errors, "Register " . $register->name . ": No cash transactions to cashup"; >+ } 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 { >+ >+ # 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 { >+ # Get the amount from the request parameter >+ # For quick cashup, this will be the expected amount set by JavaScript >+ # For two-stage cashup completion, this will be the user-entered actual amount >+ my $amount = $input->param('amount'); >+ >+ # If no amount provided, calculate expected amount (backwards compatibility) >+ unless ( defined $amount && $amount ne '' ) { >+ $amount = >+ $register->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) * -1; >+ } >+ >+ # Get optional reconciliation note >+ my $reconciliation_note = $input->param('reconciliation_note'); >+ >+ # Complete the cashup >+ my %cashup_params = ( >+ manager_id => $logged_in_user->id, >+ amount => $amount, >+ ); >+ >+ # Add reconciliation note if provided >+ if ( defined $reconciliation_note && $reconciliation_note ne '' ) { >+ $cashup_params{reconciliation_note} = $reconciliation_note; >+ } >+ >+ $register->add_cashup( \%cashup_params ); >+ $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($redirect_url); >- 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 08c7e1572f8..6157b1791f5 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 => 11; > > use Test::Exception; > >@@ -29,6 +29,7 @@ use Koha::Account; > use Koha::Account::CreditTypes; > use Koha::Account::DebitTypes; > >+use t::lib::Mocks; > use t::lib::TestBuilder; > > my $builder = t::lib::TestBuilder->new; >@@ -173,6 +174,9 @@ subtest 'cashup' => sub { > > $schema->storage->txn_begin; > >+ # Ensure reconciliation notes are not required for these tests >+ t::lib::Mocks::mock_preference( 'CashupReconciliationNoteRequired', 0 ); >+ > my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } ); > my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); > >@@ -260,7 +264,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 +277,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,55 +334,20 @@ 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, >- } >- } >- ); >- } >+ # Ensure reconciliation notes are not required for these tests >+ t::lib::Mocks::mock_preference( 'CashupReconciliationNoteRequired', 0 ); > > my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } ); > my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); >@@ -371,9 +359,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 +374,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,10 +418,12 @@ subtest 'cashup_reconciliation' => sub { > ); > > is( $reconciliation_lines->count, 0, 'No reconciliation accountlines created for balanced cashup' ); >+ >+ $schema->storage->txn_rollback; > }; > > subtest 'surplus_cashup' => sub { >- plan tests => 7; >+ plan tests => 10; > > $schema->storage->txn_begin; > >@@ -437,13 +437,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( > { >@@ -488,6 +490,7 @@ subtest 'cashup_reconciliation' => sub { > amount => -10.00, > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); >@@ -520,7 +523,7 @@ subtest 'cashup_reconciliation' => sub { > }; > > subtest 'deficit_cashup' => sub { >- plan tests => 7; >+ plan tests => 10; > > $schema->storage->txn_begin; > >@@ -534,13 +537,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( > { >@@ -585,6 +590,7 @@ subtest 'cashup_reconciliation' => sub { > amount => -20.00, > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); >@@ -631,6 +637,7 @@ subtest 'cashup_reconciliation' => sub { > amount => -10.00, > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); >@@ -683,6 +690,7 @@ subtest 'cashup_reconciliation' => sub { > amount => -10.00, > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); >@@ -722,6 +730,7 @@ subtest 'cashup_reconciliation' => sub { > amount => -10.00, > credit_type_code => 'PAYMENT', > debit_type_code => undef, >+ payment_type => 'CASH', > } > } > ); >@@ -751,3 +760,962 @@ 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', >+ } >+ } >+ ); >+ >+ throws_ok { >+ $register3->start_cashup( { manager_id => 99999999 } ); >+ } >+ 'Koha::Exceptions::Object::FKConstraint', 'start_cashup throws FK constraint exception 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 (can be positive or negative, but not zero) >+ 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 => 6; >+ >+ my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ >+ # Zero amount is now valid (for non-cash transaction scenarios) >+ my $zero_cashup; >+ lives_ok { >+ $zero_cashup = $register3->add_cashup( { manager_id => $manager->id, amount => '0.00' } ); >+ } >+ 'Zero amount is accepted for non-cash transaction scenarios'; >+ is( $zero_cashup->amount + 0, 0, 'Zero amount stored correctly' ); >+ >+ # Negative amount is now valid (for float deficits) >+ my $negative_cashup; >+ lives_ok { >+ $negative_cashup = $register3->add_cashup( { manager_id => $manager->id, amount => '-5.00' } ); >+ } >+ 'Negative amount is accepted for float deficit scenarios'; >+ is( $negative_cashup->amount + 0, -5, 'Negative amount stored correctly' ); >+ >+ # 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' } ); >+ >+ throws_ok { >+ $register9->add_cashup( { manager_id => 99999999, amount => '10.00' } ); >+ } >+ 'Koha::Exceptions::Object::FKConstraint', 'add_cashup throws FK constraint exception with invalid manager_id'; >+ }; >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'required_reconciliation_note' => sub { >+ plan tests => 4; >+ >+ $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' } ); >+ my $account = $patron->account; >+ >+ # Create a cash transaction >+ 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] >+ } >+ ); >+ >+ # Enable the required note preference >+ t::lib::Mocks::mock_preference( 'CashupReconciliationNoteRequired', 1 ); >+ >+ # Test 1: Missing note with discrepancy throws exception >+ throws_ok { >+ $register->add_cashup( >+ { >+ manager_id => $manager->id, >+ amount => '15.00' # Creates discrepancy >+ } >+ ); >+ } >+ 'Koha::Exceptions::MissingParameter', >+ 'Missing reconciliation note with discrepancy throws MissingParameter exception when preference enabled'; >+ >+ # Test 2: Note provided with discrepancy succeeds >+ my $cashup1; >+ lives_ok { >+ $cashup1 = $register->add_cashup( >+ { >+ manager_id => $manager->id, >+ amount => '15.00', >+ reconciliation_note => 'Found extra money' >+ } >+ ); >+ } >+ 'Cashup with note and discrepancy succeeds when preference enabled'; >+ >+ # Test 3: No note with no discrepancy succeeds (note only required for discrepancies) >+ my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } ); >+ my $fine2 = $account->add_debit( >+ { >+ amount => '20.00', >+ type => 'OVERDUE', >+ interface => 'cron' >+ } >+ ); >+ >+ my $payment2 = $account->pay( >+ { >+ cash_register => $register2->id, >+ amount => '20.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine2] >+ } >+ ); >+ >+ my $cashup2; >+ lives_ok { >+ $cashup2 = $register2->add_cashup( >+ { >+ manager_id => $manager->id, >+ amount => '20.00' # Exact amount, no discrepancy >+ } >+ ); >+ } >+ 'Cashup without note succeeds when there is no discrepancy'; >+ >+ # Test 4: Preference disabled allows missing note even with discrepancy >+ t::lib::Mocks::mock_preference( 'CashupReconciliationNoteRequired', 0 ); >+ >+ my $register3 = $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 => $register3->id, >+ amount => '10.00', >+ credit_type => 'PAYMENT', >+ payment_type => 'CASH', >+ lines => [$fine3] >+ } >+ ); >+ >+ my $cashup3; >+ lives_ok { >+ $cashup3 = $register3->add_cashup( >+ { >+ manager_id => $manager->id, >+ amount => '15.00' # Creates discrepancy >+ } >+ ); >+ } >+ 'Missing note with discrepancy succeeds when preference disabled'; >+ >+ $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.53.0 >
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 40445
:
185289
|
185290
|
185291
|
185292
|
185809
|
186636
|
186637
|
186638
|
186639
|
186640
|
186641
|
186642
|
186643
|
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
|
190361
|
190362
|
190363
|
190364
|
190365
|
190366
|
190367
|
190368
|
190369
|
190370
|
190371
|
190372
|
190373
|
190374
|
190375
|
190376
|
190379
|
190380
|
190388
|
190389
|
190404
|
190405
|
190406
|
190407
|
190408
|
190409
|
190410
|
190411
|
190412
|
190413
|
190414
|
190415
|
191273
|
191274
|
191275
|
191276
|
191277
|
191278
|
191279
|
191280
|
191281
|
191282
|
191283
|
191284
|
191285
|
191286
|
191287
|
191288
|
193123
|
193124
|
193125
|
193126
|
193127
|
193128
|
193129
|
193130
|
193131
|
193132
|
193133
|
193134
|
193135
|
193136
|
193137
|
193138
|
193139
|
193140
|
193141
|
193180
|
193181
| 193182 |
193183