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>
Signed-off-by: Andrew Fuerste Henry <andrew@bywatersolutions.com>
Signed-off-by: Pedro Amorim <pedro.amorim@openfifth.co.uk>
This commit is contained in:
Martin Renvoize 2026-02-16 07:49:16 +00:00 committed by Pedro Amorim
parent 6d5c2f463f
commit 9db47a47c1
No known key found for this signature in database
GPG Key ID: 06B75BA7F3894D55
11 changed files with 2923 additions and 372 deletions

View File

@ -16,6 +16,9 @@ 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 Try::Tiny qw( catch try );
use Koha::Account;
use Koha::Account::Line;
@ -25,6 +28,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);
@ -115,35 +119,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} }
@ -157,6 +140,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
@ -223,6 +240,89 @@ 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, translating raw DBIC exceptions
my $schema = $self->_result->result_source->schema;
my $rs;
try {
$rs = $self->_result->add_to_cash_register_actions(
{
code => 'CASHUP_START',
manager_id => $manager_id,
amount => $expected_amount
}
)->discard_changes;
} catch {
$schema->translate_exception($_) if ref($_) eq 'DBIx::Class::Exception';
$_->rethrow();
};
return Koha::Cash::Register::Cashup->_new_from_dbic($rs);
}
=head3 add_cashup
my $cashup = $cash_register->add_cashup(
@ -233,33 +333,103 @@ 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;
# For backward compatibility, if no actual amount is specified, use expected amount
$amount //= abs($expected_amount);
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);
}
}
# 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" );
}
# If notes are restricted to an authorised value category, reject anything
# that isn't one of the allowed values (the UI only offers a matching
# select, but the API/form param isn't otherwise constrained server-side)
my $note_av_category = C4::Context->preference('CashupReconciliationNoteAuthorisedValue');
if ( $note_av_category && defined $reconciliation_note ) {
require Koha::AuthorisedValues;
my $valid = Koha::AuthorisedValues->search(
{
category => $note_av_category,
authorised_value => $reconciliation_note,
}
)->count;
unless ($valid) {
Koha::Exceptions::BadParameter->throw(
error => "Reconciliation note is not a valid authorised value for this category",
parameter => 'reconciliation_note',
);
}
}
}
# Use database transaction to ensure consistency
my $schema = $self->_result->result_source->schema;
@ -267,82 +437,185 @@ 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
try {
# Create the cashup action (raw DBIC call; Koha::Object stores below are already translated)
my $rs = $self->_result->add_to_cash_register_actions(
{
code => 'CASHUP',
manager_id => $manager_id,
amount => $amount
}
)->discard_changes;
$cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs);
# Create reconciliation accountline if there's a difference
if ( $difference != 0 ) {
# 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
$reconciliation_date = \[ 'DATE_SUB(?, INTERVAL 1 SECOND)', $cashup_start->timestamp ];
} 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 => $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();
# Record the account offset
my $account_offset = Koha::Account::Offset->new(
{
credit_id => $surplus->id,
type => 'CREATE',
amount => -abs($difference) # Offsets match the line amount
}
)->store();
} else {
# Deficit: less cash found than expected
my $deficit = Koha::Account::Line->new(
{
date => $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(
{
debit_id => $deficit->id,
type => 'CREATE',
amount => abs($difference) # Debits have positive offsets
}
)->store();
}
}
)->discard_changes;
$cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs);
# Create reconciliation accountline if there's a difference
if ( $difference != 0 ) {
if ( $difference > 0 ) {
# Surplus: more cash found than expected (credits are negative amounts)
my $surplus = Koha::Account::Line->new(
{
date => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
amount => -abs($difference), # Credits are negative
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
}
)->store();
# Record the account offset
my $account_offset = Koha::Account::Offset->new(
{
credit_id => $surplus->id,
type => 'CREATE',
amount => -abs($difference) # Offsets match the line amount
}
)->store();
} else {
# Deficit: less cash found than expected
my $deficit = Koha::Account::Line->new(
{
date => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
amount => abs($difference),
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
}
)->store();
my $account_offset = Koha::Account::Offset->new(
{
debit_id => $deficit->id,
type => 'CREATE',
amount => abs($difference) # Debits have positive offsets
}
)->store();
}
}
} catch {
$schema->translate_exception($_) if ref($_) eq 'DBIx::Class::Exception';
$_->rethrow();
};
}
);
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

View File

@ -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

View File

@ -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 %] -->

View File

@ -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 %] -->

View File

@ -52,6 +52,47 @@
<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 %]
@ -60,9 +101,28 @@
<div id="error_message" class="alert alert-warning"> Account credit refunds are not available for anonymous transactions. Please choose a different refund type. </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 %]
@ -85,7 +145,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 %]
@ -372,60 +432,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">
@ -470,12 +476,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 nonce="[% Koha.CSPNonce | $raw %]">
@ -640,57 +649,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 %]

View File

@ -45,9 +45,105 @@
<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_start_success && cashup_start_error_details ) %]
<div class="alert alert-warning">
<strong>The following registers had errors:</strong>
<ul>
[% FOREACH error IN cashup_start_error_details %]
<li>[% error | html %]</li>
[% END %]
</ul>
</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_complete_success && cashup_complete_error_details ) %]
<div class="alert alert-warning">
<strong>The following registers had errors:</strong>
<ul>
[% FOREACH error IN cashup_complete_error_details %]
<li>[% error | html %]</li>
[% END %]
</ul>
</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 +160,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 +178,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 +204,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 +223,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 +257,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 +280,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 +375,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 nonce="[% Koha.CSPNonce | $raw %]">
$(document).ready(function () {
@ -227,20 +396,95 @@
<script nonce="[% Koha.CSPNonce | $raw %]">
$(document).ready(function () {
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 list = $("#selected_registers_list").empty();
selectedNames.forEach(function(name) {
$("<li>").text(name).appendTo(list);
});
});
// 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

View File

@ -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();
var expectedAmount = parseFloat(expectedText.unformat_price()) || 0;
var difference = actualAmount - expectedAmount;
if ($(this).val() && !isNaN(actualAmount)) {
var reconciliationText = "";
var reconciliationClass = "";
var hasDiscrepancy = false;
if (difference > 0) {
reconciliationText = __("Surplus: %s").format(
difference.format_price()
);
reconciliationClass = "success";
hasDiscrepancy = true;
} else if (difference < 0) {
reconciliationText = __("Deficit: %s").format(
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("");
});
}

View File

@ -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,28 +118,90 @@ 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');
# add_cashup() sanitizes (trims, limits length, empty-to-undef) the note itself
my $reconciliation_note = $input->param('reconciliation_note');
if ( defined $amount && $amount =~ /^\d+(?:\.\d{1,2})?$/ ) {
if ( defined $amount && $amount =~ /^-?\d+(?:\.\d{1,2})?$/ ) {
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 );
}

View File

@ -19,8 +19,9 @@
use Modern::Perl;
use CGI;
use C4::Auth qw( get_template_and_user );
use C4::Output qw( output_html_with_http_headers );
use URI::Escape qw( uri_escape uri_unescape );
use C4::Auth qw( get_template_and_user );
use C4::Output qw( output_html_with_http_headers );
use C4::Context;
use Koha::Cash::Registers;
@ -42,6 +43,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 +62,189 @@ 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_start_error_detail = $input->param('cashup_start_error_detail');
my $cashup_complete_success = $input->param('cashup_complete_success');
my $cashup_complete_errors = $input->param('cashup_complete_errors');
my $cashup_complete_error_detail = $input->param('cashup_complete_error_detail');
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_start_error_detail) {
$template->param(
cashup_start_error_details => [ map { uri_unescape($_) } split( /,/, $cashup_start_error_detail ) ] );
}
if ($cashup_complete_success) {
$template->param( cashup_complete_success => $cashup_complete_success );
}
if ($cashup_complete_errors) {
$template->param( cashup_complete_errors => $cashup_complete_errors );
}
if ($cashup_complete_error_detail) {
$template->param(
cashup_complete_error_details => [ map { uri_unescape($_) } split( /,/, $cashup_complete_error_detail ) ] );
}
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";
}
}
}
# 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_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);
push @params, "cashup_start_error_detail=" . join( ',', map { uri_escape($_) } @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";
}
}
}
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);
push @params, "cashup_complete_error_detail=" . join( ',', map { uri_escape($_) } @errors );
}
if (@params) {
$redirect_url .= "?" . join( "&", @params );
}
print $input->redirect($redirect_url);
exit;
}
} else {
$template->param( error_cashup_permission => 1 );
}

File diff suppressed because it is too large Load Diff

View File

@ -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;