View | Details | Raw Unified | Return to bug 40445
Collapse All | Expand All

(-)a/Koha/Cash/Register.pm (-40 / +262 lines)
Lines 16-21 package Koha::Cash::Register; Link Here
16
# along with Koha; if not, see <https://www.gnu.org/licenses>.
16
# along with Koha; if not, see <https://www.gnu.org/licenses>.
17
17
18
use Modern::Perl;
18
use Modern::Perl;
19
use DateTime;
20
use Scalar::Util qw( looks_like_number );
19
21
20
use Koha::Account;
22
use Koha::Account;
21
use Koha::Account::Lines;
23
use Koha::Account::Lines;
Lines 23-28 use Koha::Account::Offsets; Link Here
23
use Koha::Cash::Register::Actions;
25
use Koha::Cash::Register::Actions;
24
use Koha::Cash::Register::Cashups;
26
use Koha::Cash::Register::Cashups;
25
use Koha::Database;
27
use Koha::Database;
28
use Koha::DateUtils qw( dt_from_string );
26
29
27
use base qw(Koha::Object);
30
use base qw(Koha::Object);
28
31
Lines 113-147 Return a set of accountlines linked to this cash register since the last cashup Link Here
113
sub outstanding_accountlines {
116
sub outstanding_accountlines {
114
    my ( $self, $conditions, $attrs ) = @_;
117
    my ( $self, $conditions, $attrs ) = @_;
115
118
116
    my $since = $self->_result->search_related(
119
    # Find the start timestamp for the current "open" session
117
        'cash_register_actions',
120
    my $start_timestamp = $self->_get_session_start_timestamp;
118
        { 'code' => 'CASHUP' },
119
        {
120
            order_by => { '-desc' => [ 'timestamp', 'id' ] },
121
            rows     => 1
122
        }
123
    );
124
121
125
    my $local_conditions =
122
    my $local_conditions =
126
        $since->count
123
        $start_timestamp
127
        ? { 'date' => { '>' => $since->get_column('timestamp')->as_query } }
124
        ? { 'date' => { '>' => $start_timestamp } }
128
        : {};
125
        : {};
129
126
130
    # Exclude reconciliation accountlines from outstanding accountlines
131
    $local_conditions->{'-and'} = [
132
        {
133
            '-or' => [
134
                { 'credit_type_code' => { '!=' => 'CASHUP_SURPLUS' } },
135
                { 'credit_type_code' => undef }
136
            ]
137
        },
138
        {
139
            '-or' => [
140
                { 'debit_type_code' => { '!=' => 'CASHUP_DEFICIT' } },
141
                { 'debit_type_code' => undef }
142
            ]
143
        }
144
    ];
145
    my $merged_conditions =
127
    my $merged_conditions =
146
        $conditions
128
        $conditions
147
        ? { %{$conditions}, %{$local_conditions} }
129
        ? { %{$conditions}, %{$local_conditions} }
Lines 155-160 sub outstanding_accountlines { Link Here
155
    return Koha::Account::Lines->_new_from_dbic($rs);
137
    return Koha::Account::Lines->_new_from_dbic($rs);
156
}
138
}
157
139
140
=head3 cashup_in_progress
141
142
Check if there is currently a cashup in progress (CASHUP_START without corresponding CASHUP).
143
Returns the CASHUP_START action if in progress, undef otherwise.
144
145
=cut
146
147
sub cashup_in_progress {
148
    my ($self) = @_;
149
150
    my $last_start = $self->_result->search_related(
151
        'cash_register_actions',
152
        { 'code'   => 'CASHUP_START' },
153
        { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
154
    )->single;
155
156
    return unless $last_start;
157
158
    my $last_completion = $self->cashups(
159
        {},
160
        { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
161
    )->single;
162
163
    # If we have a start but no completion, or the start is more recent than completion
164
    if ( !$last_completion
165
        || DateTime->compare( dt_from_string( $last_start->timestamp ), dt_from_string( $last_completion->timestamp ) )
166
        > 0 )
167
    {
168
        return Koha::Cash::Register::Action->_new_from_dbic($last_start);
169
    }
170
171
    return;
172
}
173
158
=head3 store
174
=head3 store
159
175
160
Local store method to prevent direct manipulation of the 'branch_default' field
176
Local store method to prevent direct manipulation of the 'branch_default' field
Lines 221-226 sub drop_default { Link Here
221
    return $self;
237
    return $self;
222
}
238
}
223
239
240
=head3 start_cashup
241
242
    my $cashup_start = $cash_register->start_cashup(
243
        {
244
            manager_id => $logged_in_user->id,
245
        }
246
    );
247
248
Start a new cashup period. This marks the beginning of the cash counting process
249
and creates a snapshot point for calculating outstanding amounts. Returns the
250
CASHUP_START action.
251
252
=cut
253
254
sub start_cashup {
255
    my ( $self, $params ) = @_;
256
257
    # check for mandatory params
258
    my @mandatory = ('manager_id');
259
    for my $param (@mandatory) {
260
        unless ( defined( $params->{$param} ) ) {
261
            Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" );
262
        }
263
    }
264
    my $manager_id = $params->{manager_id};
265
266
    # Check if there's already a cashup in progress
267
    my $last_cashup_start_rs = $self->_result->search_related(
268
        'cash_register_actions',
269
        { 'code'   => 'CASHUP_START' },
270
        { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
271
    )->single;
272
273
    my $last_cashup_completed = $self->cashups(
274
        {},
275
        { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
276
    )->single;
277
278
    # If we have a CASHUP_START that's more recent than the last CASHUP, there's already an active cashup
279
    if (
280
        $last_cashup_start_rs
281
        && (
282
            !$last_cashup_completed || DateTime->compare(
283
                dt_from_string( $last_cashup_start_rs->timestamp ),
284
                dt_from_string( $last_cashup_completed->timestamp )
285
            ) > 0
286
        )
287
        )
288
    {
289
        Koha::Exceptions::Object::DuplicateID->throw( error => "A cashup is already in progress for this register" );
290
    }
291
292
    my $expected_amount = abs( $self->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) );
293
294
    # Create the CASHUP_START action
295
    my $rs = $self->_result->add_to_cash_register_actions(
296
        {
297
            code       => 'CASHUP_START',
298
            manager_id => $manager_id,
299
            amount     => $expected_amount
300
        }
301
    )->discard_changes;
302
303
    return Koha::Cash::Register::Cashup->_new_from_dbic($rs);
304
}
305
224
=head3 add_cashup
306
=head3 add_cashup
225
307
226
    my $cashup = $cash_register->add_cashup(
308
    my $cashup = $cash_register->add_cashup(
Lines 231-263 sub drop_default { Link Here
231
        }
313
        }
232
    );
314
    );
233
315
234
Add a new cashup action to the till, returns the added action.
316
Complete a cashup period started with start_cashup(). This performs the actual
235
If amount differs from expected amount, creates surplus/deficit accountlines.
317
reconciliation against the amount counted and creates surplus/deficit accountlines
318
if needed. Returns the completed CASHUP action.
236
319
237
=cut
320
=cut
238
321
239
sub add_cashup {
322
sub add_cashup {
240
    my ( $self, $params ) = @_;
323
    my ( $self, $params ) = @_;
241
324
242
    my $manager_id          = $params->{manager_id};
325
    # check for mandatory params
243
    my $amount              = $params->{amount};
326
    my @mandatory = ( 'manager_id', 'amount' );
244
    my $reconciliation_note = $params->{reconciliation_note};
327
    for my $param (@mandatory) {
328
        unless ( defined( $params->{$param} ) ) {
329
            Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" );
330
        }
331
    }
332
    my $manager_id = $params->{manager_id};
333
334
    # Validate amount should always be a positive value
335
    my $amount = $params->{amount};
336
    unless ( looks_like_number($amount) && $amount > 0 ) {
337
        Koha::Exceptions::Account::AmountNotPositive->throw( error => 'Cashup amount passed is not positive number' );
338
    }
245
339
246
    # Sanitize reconciliation note - treat empty/whitespace-only as undef
340
    # Sanitize reconciliation note - treat empty/whitespace-only as undef
341
    my $reconciliation_note = $params->{reconciliation_note};
247
    if ( defined $reconciliation_note ) {
342
    if ( defined $reconciliation_note ) {
248
        $reconciliation_note = substr( $reconciliation_note, 0, 1000 );    # Limit length
343
        $reconciliation_note = substr( $reconciliation_note, 0, 1000 );    # Limit length
249
        $reconciliation_note =~ s/^\s+|\s+$//g;                            # Trim whitespace
344
        $reconciliation_note =~ s/^\s+|\s+$//g;                            # Trim whitespace
250
        $reconciliation_note = undef if $reconciliation_note eq '';        # Empty after trim = undef
345
        $reconciliation_note = undef if $reconciliation_note eq '';        # Empty after trim = undef
251
    }
346
    }
252
347
253
    # Calculate expected amount from outstanding accountlines
348
    # Find the most recent CASHUP_START to determine if we're in two-phase mode
254
    my $expected_amount = $self->outstanding_accountlines->total;
349
    my $cashup_start;
350
    my $cashup_start_rs = $self->_result->search_related(
351
        'cash_register_actions',
352
        { 'code'   => 'CASHUP_START' },
353
        { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
354
    )->single;
355
356
    if ($cashup_start_rs) {
255
357
256
    # For backward compatibility, if no actual amount is specified, use expected amount
358
        # Two-phase mode: Check if this CASHUP_START has already been completed
257
    $amount //= abs($expected_amount);
359
        my $existing_completion = $self->_result->search_related(
360
            'cash_register_actions',
361
            {
362
                'code'      => 'CASHUP',
363
                'timestamp' => { '>' => $cashup_start_rs->timestamp }
364
            },
365
            { rows => 1 }
366
        )->single;
367
368
        if ( !$existing_completion ) {
369
            $cashup_start = Koha::Cash::Register::Cashup->_new_from_dbic($cashup_start_rs);
370
        }
371
372
    }
373
374
    # Calculate expected amount from session accountlines
375
    my $expected_amount = (
376
          $cashup_start
377
        ? $cashup_start->accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } )
378
        : $self->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } )
379
    ) * -1;
258
380
259
    # Calculate difference (actual - expected)
381
    # Calculate difference (actual - expected)
260
    my $difference = $amount - abs($expected_amount);
382
    my $difference = $amount - $expected_amount;
261
383
262
    # Use database transaction to ensure consistency
384
    # Use database transaction to ensure consistency
263
    my $schema = $self->_result->result_source->schema;
385
    my $schema = $self->_result->result_source->schema;
Lines 279-292 sub add_cashup { Link Here
279
            # Create reconciliation accountline if there's a difference
401
            # Create reconciliation accountline if there's a difference
280
            if ( $difference != 0 ) {
402
            if ( $difference != 0 ) {
281
403
404
                # Determine reconciliation date based on mode
405
                my $reconciliation_date;
406
                if ($cashup_start) {
407
408
                    # Two-phase mode: Backdate reconciliation lines to just before the CASHUP_START timestamp
409
                    # This ensures they belong to the previous session, not the current one
410
                    my $timestamp_str = "DATE_SUB('" . $cashup_start->timestamp . "', INTERVAL 1 SECOND)";
411
                    $reconciliation_date = \$timestamp_str;
412
                } else {
413
414
                    # Legacy mode: Use the original backdating approach
415
                    $reconciliation_date = \'DATE_SUB(NOW(), INTERVAL 1 SECOND)';
416
                }
417
282
                if ( $difference > 0 ) {
418
                if ( $difference > 0 ) {
283
419
284
                    # Surplus: more cash found than expected (credits are negative amounts)
420
                    # Surplus: more cash found than expected (credits are negative amounts)
285
                    my $surplus = Koha::Account::Line->new(
421
                    my $surplus = Koha::Account::Line->new(
286
                        {
422
                        {
287
                            date             => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
423
                            date             => $reconciliation_date,
288
                            amount           => -abs($difference),                             # Credits are negative
424
                            amount           => -abs($difference),      # Credits are negative
289
                            description      => 'Cash register surplus found during cashup',
290
                            credit_type_code => 'CASHUP_SURPLUS',
425
                            credit_type_code => 'CASHUP_SURPLUS',
291
                            manager_id       => $manager_id,
426
                            manager_id       => $manager_id,
292
                            interface        => 'intranet',
427
                            interface        => 'intranet',
Lines 309-317 sub add_cashup { Link Here
309
                    # Deficit: less cash found than expected
444
                    # Deficit: less cash found than expected
310
                    my $deficit = Koha::Account::Line->new(
445
                    my $deficit = Koha::Account::Line->new(
311
                        {
446
                        {
312
                            date            => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
447
                            date            => $reconciliation_date,
313
                            amount          => abs($difference),
448
                            amount          => abs($difference),
314
                            description     => 'Cash register deficit found during cashup',
315
                            debit_type_code => 'CASHUP_DEFICIT',
449
                            debit_type_code => 'CASHUP_DEFICIT',
316
                            manager_id      => $manager_id,
450
                            manager_id      => $manager_id,
317
                            interface       => 'intranet',
451
                            interface       => 'intranet',
Lines 335-340 sub add_cashup { Link Here
335
    return $cashup;
469
    return $cashup;
336
}
470
}
337
471
472
=head3 _get_session_start_timestamp
473
474
Internal method to determine the start timestamp for the current "open" session.
475
This handles the following cashup scenarios:
476
477
=over 4
478
479
=item 1. No cashups ever → undef (returns all accountlines)
480
481
=item 2. Quick cashup completed → Uses CASHUP timestamp
482
483
=item 3. Two-phase started → Uses CASHUP_START timestamp
484
485
=item 4. Two-phase completed → Uses the CASHUP_START timestamp that led to the last CASHUP
486
487
=item 5. Mixed workflows → Correctly distinguishes between quick and two-phase cashups
488
489
=back
490
491
=cut
492
493
sub _get_session_start_timestamp {
494
    my ($self) = @_;
495
496
    # Check if there's a cashup in progress (CASHUP_START without corresponding CASHUP)
497
    my $cashup_in_progress = $self->cashup_in_progress;
498
499
    if ($cashup_in_progress) {
500
501
        # Scenario 3: Two-phase cashup started - return accountlines since CASHUP_START
502
        return $cashup_in_progress->timestamp;
503
    }
504
505
    # No cashup in progress - find the most recent cashup completion
506
    my $last_cashup = $self->cashups(
507
        {},
508
        {
509
            order_by => { '-desc' => [ 'timestamp', 'id' ] },
510
            rows     => 1
511
        }
512
    )->single;
513
514
    if ( !$last_cashup ) {
515
516
        # Scenario 1: No cashups have ever taken place - return all accountlines
517
        return;
518
    }
519
520
    # Find if this CASHUP was part of a two-phase workflow
521
    my $corresponding_start = $self->_result->search_related(
522
        'cash_register_actions',
523
        {
524
            'code'      => 'CASHUP_START',
525
            'timestamp' => { '<' => $last_cashup->timestamp }
526
        },
527
        {
528
            order_by => { '-desc' => [ 'timestamp', 'id' ] },
529
            rows     => 1
530
        }
531
    )->single;
532
533
    if ($corresponding_start) {
534
535
        # Check if this CASHUP_START was completed by this CASHUP
536
        # (no other CASHUP between them)
537
        my $intervening_cashup = $self->_result->search_related(
538
            'cash_register_actions',
539
            {
540
                'code'      => 'CASHUP',
541
                'timestamp' => {
542
                    '>' => $corresponding_start->timestamp,
543
                    '<' => $last_cashup->timestamp
544
                }
545
            },
546
            { rows => 1 }
547
        )->single;
548
549
        if ( !$intervening_cashup ) {
550
551
            # Scenario 4: Two-phase cashup completed - return accountlines since the CASHUP_START
552
            return $corresponding_start->timestamp;
553
        }
554
    }
555
556
    # Scenarios 2 & 5: Quick cashup (or orphaned CASHUP) - return accountlines since CASHUP
557
    return $last_cashup->timestamp;
558
}
559
338
=head3 to_api_mapping
560
=head3 to_api_mapping
339
561
340
This method returns the mapping for representing a Koha::Cash::Register object
562
This method returns the mapping for representing a Koha::Cash::Register object
(-)a/Koha/Cash/Register/Cashup.pm (-17 / +127 lines)
Lines 61-83 Return a hashref containing a summary of transactions that make up this cashup. Link Here
61
sub summary {
61
sub summary {
62
    my ($self) = @_;
62
    my ($self) = @_;
63
    my $summary;
63
    my $summary;
64
    my $prior_cashup = Koha::Cash::Register::Cashups->search(
65
        {
66
            'timestamp' => { '<' => $self->timestamp },
67
            register_id => $self->register_id
68
        },
69
        {
70
            order_by => { '-desc' => [ 'timestamp', 'id' ] },
71
            rows     => 1
72
        }
73
    );
74
64
75
    my $previous = $prior_cashup->single;
65
    # Get the session boundaries for this cashup
66
    my ( $session_start, $session_end ) = $self->_get_session_boundaries;
76
67
77
    my $conditions =
68
    my $conditions;
78
        $previous
69
    if ( $session_start && $session_end ) {
79
        ? { 'date' => { '-between' => [ $previous->_result->get_column('timestamp'), $self->timestamp ] } }
70
80
        : { 'date' => { '<'        => $self->timestamp } };
71
        # Complete session: between start and end (exclusive)
72
        $conditions = {
73
            'date' => {
74
                '>' => $session_start,
75
                '<' => $session_end
76
            }
77
        };
78
    } elsif ($session_end) {
79
80
        # Session from beginning to end
81
        $conditions = { 'date' => { '<' => $session_end } };
82
    } else {
83
84
        # Shouldn't happen for a completed cashup, but fallback
85
        $conditions = { 'date' => { '<' => $self->timestamp } };
86
    }
81
87
82
    my $payout_transactions = $self->register->accountlines->search(
88
    my $payout_transactions = $self->register->accountlines->search(
83
        {
89
        {
Lines 191-198 sub summary { Link Here
191
    my $deficit_total = $deficit_lines->count ? $deficit_lines->total : undef;
197
    my $deficit_total = $deficit_lines->count ? $deficit_lines->total : undef;
192
198
193
    $summary = {
199
    $summary = {
194
        from_date      => $previous ? $previous->timestamp : undef,
200
        from_date      => $session_start,
195
        to_date        => $self->timestamp,
201
        to_date        => $session_end,
196
        income_grouped => \@income,
202
        income_grouped => \@income,
197
        income_total   => abs($income_total),
203
        income_total   => abs($income_total),
198
        payout_grouped => \@payout,
204
        payout_grouped => \@payout,
Lines 208-213 sub summary { Link Here
208
    return $summary;
214
    return $summary;
209
}
215
}
210
216
217
=head3 accountlines
218
219
Fetch the accountlines associated with this cashup
220
221
=cut
222
223
sub accountlines {
224
    my ($self) = @_;
225
226
    # Get the session boundaries for this cashup
227
    my ( $session_start, $session_end ) = $self->_get_session_boundaries;
228
229
    my $conditions;
230
    if ( $session_start && $session_end ) {
231
232
        # Complete session: between start and end (exclusive)
233
        $conditions = {
234
            'date' => {
235
                '>' => $session_start,
236
                '<' => $session_end
237
            }
238
        };
239
    } elsif ($session_end) {
240
241
        # Session from beginning to end
242
        $conditions = { 'date' => { '<' => $session_end } };
243
    } else {
244
245
        # Shouldn't happen for a completed cashup, but fallback
246
        $conditions = { 'date' => { '<' => $self->timestamp } };
247
    }
248
249
    return $self->register->accountlines->search($conditions);
250
}
251
252
=head3 _get_session_boundaries
253
254
Internal method to determine the session boundaries for this cashup.
255
Returns ($session_start, $session_end) timestamps.
256
257
=cut
258
259
sub _get_session_boundaries {
260
    my ($self) = @_;
261
262
    my $session_end = $self->_get_session_end;
263
264
    # Find the previous CASHUP
265
    my $session_start;
266
    my $previous_cashup = $self->register->cashups(
267
        { 'timestamp' => { '<' => $session_end } },
268
        {
269
            order_by => { '-desc' => [ 'timestamp', 'id' ] },
270
            rows     => 1
271
        }
272
    )->single;
273
274
    $session_start = $previous_cashup ? $previous_cashup->_get_session_end : undef;
275
276
    return ( $session_start, $session_end );
277
}
278
279
sub _get_session_end {
280
    my ($self) = @_;
281
282
    my $session_end = $self->timestamp;
283
284
    # Find if this CASHUP was part of a two-phase workflow
285
    my $nearest_start = $self->register->_result->search_related(
286
        'cash_register_actions',
287
        {
288
            'code'      => 'CASHUP_START',
289
            'timestamp' => { '<' => $session_end }
290
        },
291
        {
292
            order_by => { '-desc' => [ 'timestamp', 'id' ] },
293
            rows     => 1
294
        }
295
    )->single;
296
297
    if ($nearest_start) {
298
299
        # Check if this CASHUP_START was completed by this CASHUP
300
        # (no other CASHUP between them)
301
        my $intervening_cashup = $self->register->cashups(
302
            {
303
                'timestamp' => {
304
                    '>' => $nearest_start->timestamp,
305
                    '<' => $session_end
306
                }
307
            },
308
            { rows => 1 }
309
        )->single;
310
311
        if ( !$intervening_cashup ) {
312
313
            # Two-phase workflow: session runs to CASHUP_START
314
            $session_end = $nearest_start->timestamp;
315
        }
316
    }
317
318
    return $session_end;
319
}
320
211
=head3 to_api_mapping
321
=head3 to_api_mapping
212
322
213
This method returns the mapping for representing a Koha::Cash::Register::Cashup object
323
This method returns the mapping for representing a Koha::Cash::Register::Cashup object
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/pos/register.tt (-10 / +95 lines)
Lines 52-64 Link Here
52
            <div id="error_message" class="alert alert-warning"> Invalid amount entered for cashup. Please enter a valid monetary amount. </div>
52
            <div id="error_message" class="alert alert-warning"> Invalid amount entered for cashup. Please enter a valid monetary amount. </div>
53
        [% END %]
53
        [% END %]
54
54
55
        [% IF ( error_cashup_in_progress ) %]
56
            <div id="error_message" class="alert alert-warning"> A cashup is already in progress for this register. </div>
57
        [% END %]
58
59
        [% IF ( error_no_cashup_start ) %]
60
            <div id="error_message" class="alert alert-warning"> No cashup session has been started. Please start a cashup before attempting to complete it. </div>
61
        [% END %]
62
63
        [% IF ( error_cashup_already_completed ) %]
64
            <div id="error_message" class="alert alert-warning"> This cashup session has already been completed. </div>
65
        [% END %]
66
67
        [% IF ( error_cashup_start ) %]
68
            <div id="error_message" class="alert alert-warning"> Failed to start cashup. Please try again. </div>
69
        [% END %]
70
71
        [% IF ( error_cashup_complete ) %]
72
            <div id="error_message" class="alert alert-warning"> Failed to complete cashup. Please try again. </div>
73
        [% END %]
74
55
        [% IF ( error_refund_permission ) %]
75
        [% IF ( error_refund_permission ) %]
56
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform refund actions. </div>
76
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform refund actions. </div>
57
        [% END %]
77
        [% END %]
58
78
79
        [% IF cashup_in_progress %]
80
            <div class="alert alert-warning">
81
                <i class="fa-solid fa-info-circle"></i>
82
                Cashup in progress - started [% cashup_in_progress.timestamp | $KohaDates with_hours => 1 %]. You can continue to make transactions while counting cash.
83
            </div>
84
        [% END %]
85
59
        [% IF ( CAN_user_cash_management_cashup ) %]
86
        [% IF ( CAN_user_cash_management_cashup ) %]
60
            <div id="toolbar" class="btn-toolbar">
87
            <div id="toolbar" class="btn-toolbar">
61
                <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>
88
                [% IF cashup_in_progress %]
89
                    <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>
90
                [% ELSE %]
91
                    <button type="button" class="btn btn-default" data-bs-toggle="modal" data-bs-target="#triggerCashupModal"> <i class="fa fa-money-bill-alt"></i> Record cashup </button>
92
                [% END %]
62
            </div>
93
            </div>
63
        [% END %]
94
        [% END %]
64
95
Lines 375-396 Link Here
375
        <div class="modal-dialog">
406
        <div class="modal-dialog">
376
            <div class="modal-content">
407
            <div class="modal-content">
377
                <div class="modal-header">
408
                <div class="modal-header">
378
                    <h1 class="modal-title" id="confirmCashupLabel">Confirm cashup of <em>[% register.description | html %]</em></h1>
409
                    <h1 class="modal-title" id="confirmCashupLabel">
410
                        [% IF cashup_in_progress %]
411
                            Complete cashup of <em>[% register.description | html %]</em>
412
                        [% ELSE %]
413
                            Confirm cashup of <em>[% register.description | html %]</em>
414
                        [% END %]
415
                    </h1>
379
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
416
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
380
                </div>
417
                </div>
381
                <div class="modal-body">
418
                <div class="modal-body">
382
                    <fieldset class="rows">
419
                    <fieldset class="rows">
383
                        <ol>
420
                        <ol>
384
                            <li>
421
                            <li>
385
                                <span class="label">Expected amount to remove:</span>
422
                                <span class="label">
386
                                <span id="expected_amount" class="expected-amount">[% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %]</span>
423
                                    [% IF cashup_in_progress %]
387
                            </li>
424
                                        Expected cashup amount:
388
                            <li>
425
                                    [% ELSE %]
389
                                <span class="label">Float to remain:</span>
426
                                        Expected amount to remove:
390
                                <span>[% register.starting_float | $Price %]</span>
427
                                    [% END %]
428
                                </span>
429
                                <span id="expected_amount" class="expected-amount">[% cashup_in_progress.amount | $Price %]</span>
391
                            </li>
430
                            </li>
392
                            <li>
431
                            <li>
393
                                <label class="required" for="amount">Actual amount removed from register:</label>
432
                                <label class="required" for="amount">
433
                                    [% IF cashup_in_progress %]
434
                                        Actual cashup amount counted:
435
                                    [% ELSE %]
436
                                        Actual amount removed from register:
437
                                    [% END %]
438
                                </label>
394
                                <input type="text" inputmode="decimal" pattern="^\d+(\.\d{2})?$" id="amount" name="amount" required="required" />
439
                                <input type="text" inputmode="decimal" pattern="^\d+(\.\d{2})?$" id="amount" name="amount" required="required" />
395
                                <span class="required">Required</span>
440
                                <span class="required">Required</span>
396
                            </li>
441
                            </li>
Lines 410-416 Link Here
410
                <div class="modal-footer">
455
                <div class="modal-footer">
411
                    <input type="hidden" name="registerid" value="[% register.id | html %]" />
456
                    <input type="hidden" name="registerid" value="[% register.id | html %]" />
412
                    <input type="hidden" name="op" value="cud-cashup" />
457
                    <input type="hidden" name="op" value="cud-cashup" />
413
                    <button type="submit" class="btn btn-primary" id="pos_cashup_confirm">Confirm cashup</button>
458
                    <button type="submit" class="btn btn-primary" id="pos_cashup_confirm"> [% IF cashup_in_progress %]Complete cashup[% ELSE %]Confirm cashup[% END %] </button>
414
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
459
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
415
                </div>
460
                </div>
416
                <!-- /.modal-footer -->
461
                <!-- /.modal-footer -->
Lines 465-470 Link Here
465
</div>
510
</div>
466
<!-- /#issueRefundModal -->
511
<!-- /#issueRefundModal -->
467
512
513
<!-- Trigger cashup modal -->
514
<div class="modal" id="triggerCashupModal" tabindex="-1" role="dialog" aria-labelledby="triggerCashupLabel">
515
    <form method="post" class="validated">
516
        [% INCLUDE 'csrf-token.inc' %]
517
        <div class="modal-dialog">
518
            <div class="modal-content">
519
                <div class="modal-header">
520
                    <h1 class="modal-title" id="triggerCashupLabel"> Cashup for <em>[% register.description | html %]</em> </h1>
521
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
522
                </div>
523
                <div class="modal-body">
524
                    <p><strong>Choose how to proceed with the cashup:</strong></p>
525
                    <p><strong>Start cashup</strong></p>
526
                    <ul>
527
                        <li>Remove cash from the register for counting</li>
528
                        <li>The register can continue operating during counting</li>
529
                        <li>Complete the cashup once counted</li>
530
                    </ul>
531
                    <p><strong>Quick cashup</strong></p>
532
                    <ul>
533
                        <li>Confirm you have removed [% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %] cash from the register to bank immediately</li>
534
                    </ul>
535
                    <p>Remember to leave the float amount of <strong>[% register.starting_float | $Price %]</strong> in the register.</p>
536
                </div>
537
                <div class="modal-footer">
538
                    <input type="hidden" name="registerid" value="[% register.id | html %]" />
539
                    <input type="hidden" name="op" value="cud-cashup_start" />
540
                    <input type="hidden" name="amount" value="" />
541
                    <button type="submit" class="btn btn-primary">Start cashup</button>
542
                    <button type="button" class="btn btn-success" onclick="this.form.op.value='cud-cashup'; this.form.amount.value='[% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | html %]'; this.form.submit();"
543
                        >Quick cashup</button
544
                    >
545
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
546
                </div>
547
            </div>
548
        </div>
549
    </form>
550
</div>
551
<!-- /#triggerCashupModal -->
552
468
[% INCLUDE 'modals/cashup_summary.inc' %]
553
[% INCLUDE 'modals/cashup_summary.inc' %]
469
554
470
[% MACRO jsinclude BLOCK %]
555
[% MACRO jsinclude BLOCK %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/pos/registers.tt (-22 / +416 lines)
Lines 45-53 Link Here
45
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform cashup actions. </div>
45
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform cashup actions. </div>
46
        [% END %]
46
        [% END %]
47
47
48
        [% IF CAN_user_cash_management_cashup %]
48
        [% IF ( error_cashup_start && cashup_errors ) %]
49
            <div id="toolbar" class="btn-toolbar">
49
            <div class="alert alert-warning">
50
                <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>
50
                <strong>Some registers failed to start cashup:</strong>
51
                <ul>
52
                    [% FOREACH error IN cashup_errors %]
53
                        <li>[% error | html %]</li>
54
                    [% END %]
55
                </ul>
56
            </div>
57
        [% END %]
58
59
        [% IF ( error_cashup_complete && cashup_errors ) %]
60
            <div class="alert alert-warning">
61
                <strong>Some registers failed to complete cashup:</strong>
62
                <ul>
63
                    [% FOREACH error IN cashup_errors %]
64
                        <li>[% error | html %]</li>
65
                    [% END %]
66
                </ul>
67
            </div>
68
        [% END %]
69
70
        [% IF ( cashup_start_success ) %]
71
            <div class="alert alert-success">
72
                <i class="fa fa-check"></i>
73
                [% IF cashup_start_success == 1 %]
74
                    Successfully started cashup for 1 register.
75
                [% ELSE %]
76
                    Successfully started cashup for [% cashup_start_success | html %] registers.
77
                [% END %]
78
                [% IF ( cashup_start_errors ) %]
79
                    [% IF cashup_start_errors == 1 %]
80
                        However, 1 register had errors.
81
                    [% ELSE %]
82
                        However, [% cashup_start_errors | html %] registers had errors.
83
                    [% END %]
84
                [% END %]
85
            </div>
86
        [% END %]
87
88
        [% IF ( cashup_complete_success ) %]
89
            <div class="alert alert-success">
90
                <i class="fa fa-check"></i>
91
                [% IF cashup_complete_success == 1 %]
92
                    Successfully completed cashup for 1 register.
93
                [% ELSE %]
94
                    Successfully completed cashup for [% cashup_complete_success | html %] registers.
95
                [% END %]
96
                [% IF ( cashup_complete_errors ) %]
97
                    [% IF cashup_complete_errors == 1 %]
98
                        However, 1 register had errors.
99
                    [% ELSE %]
100
                        However, [% cashup_complete_errors | html %] registers had errors.
101
                    [% END %]
102
                [% END %]
103
            </div>
104
        [% END %]
105
106
        [% IF ( cashup_start_errors && !cashup_start_success ) %]
107
            <div class="alert alert-warning">
108
                <i class="fa fa-exclamation-triangle"></i>
109
                [% IF cashup_start_errors == 1 %]
110
                    Failed to start cashup for 1 register.
111
                [% ELSE %]
112
                    Failed to start cashup for [% cashup_start_errors | html %] registers.
113
                [% END %]
114
            </div>
115
        [% END %]
116
117
        [% IF ( cashup_complete_errors && !cashup_complete_success ) %]
118
            <div class="alert alert-warning">
119
                <i class="fa fa-exclamation-triangle"></i>
120
                [% IF cashup_complete_errors == 1 %]
121
                    Failed to complete cashup for 1 register.
122
                [% ELSE %]
123
                    Failed to complete cashup for [% cashup_complete_errors | html %] registers.
124
                [% END %]
51
            </div>
125
            </div>
52
        [% END %]
126
        [% END %]
53
127
Lines 64-69 Link Here
64
            <table id="registers" class="table_registers">
138
            <table id="registers" class="table_registers">
65
                <thead>
139
                <thead>
66
                    <tr>
140
                    <tr>
141
                        [% IF CAN_user_cash_management_cashup %]
142
                            <th class="no-export"><input type="checkbox" id="select_all_registers" title="Select all available registers" /></th>
143
                        [% END %]
67
                        <th>Register name</th>
144
                        <th>Register name</th>
68
                        <th>Register description</th>
145
                        <th>Register description</th>
69
                        <th>Last cashup</th>
146
                        <th>Last cashup</th>
Lines 80-85 Link Here
80
                    [% SET bankable = 0, ctotal = 0, dtotal = 0, cctotal = 0, cdtotal = 0 %]
157
                    [% SET bankable = 0, ctotal = 0, dtotal = 0, cctotal = 0, cdtotal = 0 %]
81
                    [% FOREACH register IN registers %]
158
                    [% FOREACH register IN registers %]
82
                        <tr>
159
                        <tr>
160
                            [% IF CAN_user_cash_management_cashup %]
161
                                <td>
162
                                    [% IF register.cashup_in_progress %]
163
                                        <input type="checkbox" class="register_checkbox" value="[% register.id | html %]" disabled title="Cashup in progress" />
164
                                    [% ELSE %]
165
                                        <input type="checkbox" class="register_checkbox" value="[% register.id | html %]" />
166
                                    [% END %]
167
                                </td>
168
                            [% END %]
83
                            <td><a href="/cgi-bin/koha/pos/register.pl?registerid=[% register.id | uri %]">[% register.name | html %]</a></td>
169
                            <td><a href="/cgi-bin/koha/pos/register.pl?registerid=[% register.id | uri %]">[% register.name | html %]</a></td>
84
                            <td>[% register.description | html %]</td>
170
                            <td>[% register.description | html %]</td>
85
                            <td>
171
                            <td>
Lines 112-128 Link Here
112
                            </td>
198
                            </td>
113
                            [% IF CAN_user_cash_management_cashup %]
199
                            [% IF CAN_user_cash_management_cashup %]
114
                                <td>
200
                                <td>
115
                                    <button
201
                                    [% IF register.cashup_in_progress %]
116
                                        type="button"
202
                                        <button
117
                                        class="cashup_individual btn btn-xs btn-default"
203
                                            type="button"
118
                                        data-bs-toggle="modal"
204
                                            class="btn btn-xs btn-primary pos_complete_cashup"
119
                                        data-bs-target="#confirmCashupModal"
205
                                            data-bs-toggle="modal"
120
                                        data-register="[% register.description | html %]"
206
                                            data-bs-target="#confirmCashupModal"
121
                                        data-bankable="[% rbankable | $Price %]"
207
                                            data-register="[% register.description | html %]"
122
                                        data-float="[% register.starting_float | $Price %]"
208
                                            data-bankable="[% rbankable | $Price %]"
123
                                        data-registerid="[% register.id | html %]"
209
                                            data-float="[% register.starting_float | $Price %]"
124
                                        ><i class="fa-solid fa-money-bill-1"></i> Record cashup</button
210
                                            data-registerid="[% register.id | html %]"
125
                                    >
211
                                            ><i class="fa-solid fa-check"></i> Complete cashup</button
212
                                        >
213
                                    [% ELSE %]
214
                                        <button
215
                                            type="button"
216
                                            class="cashup_individual btn btn-xs btn-default"
217
                                            data-bs-toggle="modal"
218
                                            data-bs-target="#triggerCashupModalRegister"
219
                                            data-register="[% register.description | html %]"
220
                                            data-bankable="[% rbankable | $Price %]"
221
                                            data-float="[% register.starting_float | $Price %]"
222
                                            data-registerid="[% register.id | html %]"
223
                                            ><i class="fa-solid fa-money-bill-1"></i> Record cashup</button
224
                                        >
225
                                    [% END %]
126
                                </td>
226
                                </td>
127
                            [% END %]
227
                            [% END %]
128
                        </tr>
228
                        </tr>
Lines 130-142 Link Here
130
                </tbody>
230
                </tbody>
131
                <tfoot>
231
                <tfoot>
132
                    <tr>
232
                    <tr>
133
                        <td colspan="4" align="right">Totals:</td>
233
                        [% IF CAN_user_cash_management_cashup %]
234
                            <td colspan="5" align="right">Totals:</td>
235
                        [% ELSE %]
236
                            <td colspan="4" align="right">Totals:</td>
237
                        [% END %]
134
                        <td>[% bankable | $Price %]</td>
238
                        <td>[% bankable | $Price %]</td>
135
                        <td>[% ctotal | $Price %] ([% cctotal | $Price %])</td>
239
                        <td>[% ctotal | $Price %] ([% cctotal | $Price %])</td>
136
                        <td>[% dtotal | $Price %] ([% cdtotal | $Price %])</td>
240
                        <td>[% dtotal | $Price %] ([% cdtotal | $Price %])</td>
137
                        [% IF CAN_user_cash_management_cashup %]
241
                        [% IF CAN_user_cash_management_cashup %]
138
                            <td>
242
                            <td>
139
                                <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>
243
                                <button type="button" id="cashup_selected_btn" class="btn btn-xs btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupSelectedModal" disabled
244
                                    ><i class="fa-solid fa-money-bill-1"></i> Cashup selected</button
245
                                >
140
                            </td>
246
                            </td>
141
                        [% END %]
247
                        [% END %]
142
                    </tr>
248
                    </tr>
Lines 147-170 Link Here
147
    [% END %]
253
    [% END %]
148
[% END %]
254
[% END %]
149
255
256
<!-- Trigger cashup modal for individual registers -->
257
<div class="modal" id="triggerCashupModalRegister" tabindex="-1" role="dialog" aria-labelledby="triggerCashupLabelRegister">
258
    <form method="post" class="validated">
259
        [% INCLUDE 'csrf-token.inc' %]
260
        <div class="modal-dialog">
261
            <div class="modal-content">
262
                <div class="modal-header">
263
                    <h1 class="modal-title" id="triggerCashupLabelRegister">
264
                        Cashup for <em><span id="register_desc"></span></em>
265
                    </h1>
266
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
267
                </div>
268
                <div class="modal-body">
269
                    <p><strong>Choose how to proceed with the cashup:</strong></p>
270
                    <p><strong>Start cashup</strong></p>
271
                    <ul>
272
                        <li>Remove cash from the register for counting</li>
273
                        <li>The register can continue operating during counting</li>
274
                        <li>Complete the cashup once counted</li>
275
                    </ul>
276
                    <p><strong>Quick cashup</strong></p>
277
                    <ul>
278
                        <li>Confirm you have removed <span id="expected_amount_display"></span> cash from the register to bank immediately</li>
279
                    </ul>
280
                    <p
281
                        >Remember to leave the float amount of <strong><span id="float_amount_display"></span></strong> in the register.</p
282
                    >
283
                </div>
284
                <div class="modal-footer">
285
                    <input type="hidden" name="registerid" id="register_id_field" value="" />
286
                    <input type="hidden" name="op" value="cud-cashup_start" />
287
                    <input type="hidden" name="amount" value="" />
288
                    <button type="submit" class="btn btn-primary">Start cashup</button>
289
                    <button type="button" class="btn btn-success" id="quick_cashup_btn">Quick cashup</button>
290
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
291
                </div>
292
            </div>
293
        </div>
294
    </form>
295
</div>
296
<!-- /#triggerCashupModalRegister -->
297
298
<!-- Confirm cashup selected modal -->
299
<div class="modal" id="confirmCashupSelectedModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupSelectedLabel">
300
    <form method="post" class="validated">
301
        [% INCLUDE 'csrf-token.inc' %]
302
        <div class="modal-dialog">
303
            <div class="modal-content">
304
                <div class="modal-header">
305
                    <h1 class="modal-title" id="confirmCashupSelectedLabel">Cashup selected registers</h1>
306
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
307
                </div>
308
                <div class="modal-body">
309
                    <p
310
                        ><strong>Choose how to proceed with the cashup for <span id="selected_count">0</span> selected register(s):</strong></p
311
                    >
312
313
                    <div class="row">
314
                        <div class="col-md-6">
315
                            <div class="card">
316
                                <div class="card-body">
317
                                    <h5 class="card-title"><i class="fa-solid fa-play"></i> Start cashup for selected</h5>
318
                                    <p class="card-text">Begin two-phase cashup for all selected registers. Cash can be removed for counting while registers continue operating.</p>
319
                                    <ul class="small">
320
                                        <li>Remove cash from each register for counting</li>
321
                                        <li>Registers continue operating during counting</li>
322
                                        <li>Complete each register individually later</li>
323
                                    </ul>
324
                                </div>
325
                            </div>
326
                        </div>
327
                        <div class="col-md-6">
328
                            <div class="card">
329
                                <div class="card-body">
330
                                    <h5 class="card-title"><i class="fa-solid fa-lightning"></i> Quick cashup for selected</h5>
331
                                    <p class="card-text">Complete cashup immediately for all selected registers using expected amounts (no reconciliation needed).</p>
332
                                    <ul class="small">
333
                                        <li>Uses expected amounts for each register</li>
334
                                        <li>No individual reconciliation</li>
335
                                        <li>Completes all selected registers immediately</li>
336
                                    </ul>
337
                                </div>
338
                            </div>
339
                        </div>
340
                    </div>
341
342
                    <div class="mt-3">
343
                        <h6>Selected registers:</h6>
344
                        <ul id="selected_registers_list"></ul>
345
                    </div>
346
                </div>
347
                <div class="modal-footer">
348
                    <input type="hidden" name="registerid" id="selected_registers_field" value="" />
349
                    <input type="hidden" name="op" id="selected_operation" value="" />
350
                    <button type="button" class="btn btn-primary" id="start_selected_btn">Start cashup for selected</button>
351
                    <button type="button" class="btn btn-success" id="quick_selected_btn">Quick cashup for selected</button>
352
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
353
                </div>
354
            </div>
355
        </div>
356
    </form>
357
</div>
358
<!-- /#confirmCashupSelectedModal -->
359
150
<!-- Confirm cashup modal -->
360
<!-- Confirm cashup modal -->
151
<div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel">
361
<div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel">
152
    <form id="cashup_individual_form" method="post" enctype="multipart/form-data">
362
    <form id="cashup_individual_form" method="post" enctype="multipart/form-data" class="validated">
153
        [% INCLUDE 'csrf-token.inc' %]
363
        [% INCLUDE 'csrf-token.inc' %]
154
        <div class="modal-dialog">
364
        <div class="modal-dialog">
155
            <div class="modal-content">
365
            <div class="modal-content">
156
                <div class="modal-header">
366
                <div class="modal-header">
157
                    <h1 class="modal-title" id="confirmCashupLabel"
367
                    <h1 class="modal-title" id="confirmCashupLabel">
158
                        >Confirm cashup of <em><span id="registerc"></span></em
368
                        Confirm cashup of <em><span id="registerc"></span></em>
159
                    ></h1>
369
                    </h1>
160
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
370
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
161
                </div>
371
                </div>
162
                <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>
372
                <div class="modal-body">
373
                    <fieldset class="rows">
374
                        <ol>
375
                            <li>
376
                                <span class="label"> Expected cashup amount: </span>
377
                                <span id="cashc" class="expected-amount"></span>
378
                            </li>
379
                            <li>
380
                                <label class="required" for="amount"> Actual cashup amount counted: </label>
381
                                <input type="text" inputmode="decimal" pattern="^\d+(\.\d{2})?$" id="amount" name="amount" required="required" />
382
                                <span class="required">Required</span>
383
                            </li>
384
                            <li id="reconciliation_display" style="display: none;">
385
                                <span class="label">Reconciliation:</span>
386
                                <span id="reconciliation_text"></span>
387
                            </li>
388
                            <li id="reconciliation_note_field" style="display: none;">
389
                                <label for="reconciliation_note">Note (optional):</label>
390
                                <textarea id="reconciliation_note" name="reconciliation_note" rows="3" cols="40" maxlength="1000" placeholder="Enter a note explaining the surplus or deficit..."></textarea>
391
                                <div class="hint">Maximum 1000 characters</div>
392
                            </li>
393
                        </ol>
394
                    </fieldset>
395
                </div>
163
                <!-- /.modal-body -->
396
                <!-- /.modal-body -->
164
                <div class="modal-footer">
397
                <div class="modal-footer">
165
                    <input type="hidden" name="registerid" id="cashup_registerid" value="" />
398
                    <input type="hidden" name="registerid" id="cashup_registerid" value="" />
166
                    <input type="hidden" name="op" value="cud-cashup" />
399
                    <input type="hidden" name="op" value="cud-cashup" />
167
                    <button type="submit" class="btn btn-primary" id="cashup_confirm">Confirm</button>
400
                    <button type="submit" class="btn btn-primary" id="cashup_confirm"> Complete cashup</button>
168
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
401
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
169
                </div>
402
                </div>
170
                <!-- /.modal-footer -->
403
                <!-- /.modal-footer -->
Lines 225-234 Link Here
225
            $("#outgoing").text('[% dtotal | $Price %] ([% cdtotal | $Price %])');
458
            $("#outgoing").text('[% dtotal | $Price %] ([% cdtotal | $Price %])');
226
459
227
            var registers_table = $("#registers").kohaTable({
460
            var registers_table = $("#registers").kohaTable({
461
                columnDefs: [{ targets: [ -1, 0 ], orderable: false }],
228
                searching: false,
462
                searching: false,
229
                paginationType: "full",
463
                paginationType: "full",
230
            });
464
            });
231
465
466
            // Real-time reconciliation calculation for cashup modal
467
            $("#amount").on("input", function() {
468
                var actualAmount = parseFloat($(this).val()) || 0;
469
                var expectedText = $("#expected_amount").text().replace(/[£$,]/g, '');
470
                var expectedAmount = parseFloat(expectedText) || 0;
471
                var difference = actualAmount - expectedAmount;
472
473
                if ($(this).val() && !isNaN(actualAmount)) {
474
                    var reconciliationText = "";
475
                    var reconciliationClass = "";
476
                    var hasDiscrepancy = false;
477
478
                    if (difference > 0) {
479
                        reconciliationText = "Surplus: " + difference.format_price();
480
                        reconciliationClass = "success";
481
                        hasDiscrepancy = true;
482
                    } else if (difference < 0) {
483
                        reconciliationText = "Deficit: " + Math.abs(difference).format_price();
484
                        reconciliationClass = "warning";
485
                        hasDiscrepancy = true;
486
                    } else {
487
                        reconciliationText = "Balanced - no surplus or deficit";
488
                        reconciliationClass = "success";
489
                        hasDiscrepancy = false;
490
                    }
491
492
                    $("#reconciliation_text").text(reconciliationText)
493
                        .removeClass("success warning")
494
                        .addClass(reconciliationClass);
495
                    $("#reconciliation_display").show();
496
497
                    // Show/hide note field based on whether there's a discrepancy
498
                    if (hasDiscrepancy) {
499
                        $("#reconciliation_note_field").show();
500
                    } else {
501
                        $("#reconciliation_note_field").hide();
502
                        $("#reconciliation_note").val(''); // Clear note when balanced
503
                    }
504
                } else {
505
                    $("#reconciliation_display").hide();
506
                    $("#reconciliation_note_field").hide();
507
                }
508
            });
509
510
            // Reset modal when opened
232
            $("#confirmCashupModal").on("shown.bs.modal", function(e){
511
            $("#confirmCashupModal").on("shown.bs.modal", function(e){
233
               var button = $(e.relatedTarget);
512
               var button = $(e.relatedTarget);
234
               var register = button.data('register');
513
               var register = button.data('register');
Lines 239-244 Link Here
239
               $('#floatc').text(rfloat);
518
               $('#floatc').text(rfloat);
240
               var rid = button.data('registerid');
519
               var rid = button.data('registerid');
241
               $('#cashup_registerid').val(rid);
520
               $('#cashup_registerid').val(rid);
521
               $("#amount").val('').focus();
522
               $("#reconciliation_display").hide();
523
               $("#reconciliation_note_field").hide();
524
               $("#reconciliation_note").val('');
525
            });
526
527
            // Handle the new trigger cashup modal for individual registers
528
            $("#triggerCashupModalRegister").on("shown.bs.modal", function(e){
529
               var button = $(e.relatedTarget);
530
               var register = button.data('register');
531
               $("#register_desc").text(register);
532
               var bankable = button.data('bankable');
533
               $("#expected_amount_display").text(bankable);
534
               var rfloat = button.data('float');
535
               $('#float_amount_display').text(rfloat);
536
               var rid = button.data('registerid');
537
               $('#register_id_field').val(rid);
538
539
               // Store bankable amount for quick cashup
540
               $('#triggerCashupModalRegister').data('bankable-amount', bankable.replace(/[^0-9.-]/g, ''));
541
            });
542
543
            // Handle Quick cashup button click
544
            $("#quick_cashup_btn").on("click", function(e){
545
               e.preventDefault();
546
               var form = $(this).closest('form');
547
               var bankableAmount = $('#triggerCashupModalRegister').data('bankable-amount');
548
549
               // Change operation to cud-cashup (quick cashup)
550
               form.find('input[name="op"]').val('cud-cashup');
551
552
               // Set the amount to the expected bankable amount
553
               form.find('input[name="amount"]').val(bankableAmount);
554
555
               // Submit the form
556
               form.submit();
557
            });
558
559
            // Select all registers functionality
560
            $("#select_all_registers").on("change", function() {
561
                var isChecked = $(this).is(":checked");
562
                $(".register_checkbox:not(:disabled)").prop("checked", isChecked);
563
                updateCashupSelectedButton();
564
            });
565
566
            // Individual checkbox change handler
567
            $(".register_checkbox").on("change", function() {
568
                updateCashupSelectedButton();
569
570
                // Update select all checkbox state
571
                var totalCheckboxes = $(".register_checkbox:not(:disabled)").length;
572
                var checkedCheckboxes = $(".register_checkbox:not(:disabled):checked").length;
573
574
                if (checkedCheckboxes === 0) {
575
                    $("#select_all_registers").prop("indeterminate", false).prop("checked", false);
576
                } else if (checkedCheckboxes === totalCheckboxes) {
577
                    $("#select_all_registers").prop("indeterminate", false).prop("checked", true);
578
                } else {
579
                    $("#select_all_registers").prop("indeterminate", true);
580
                }
581
            });
582
583
            // Update cashup selected button state
584
            function updateCashupSelectedButton() {
585
                var selectedCount = $(".register_checkbox:checked").length;
586
                var button = $("#cashup_selected_btn");
587
588
                if (selectedCount > 0) {
589
                    button.prop("disabled", false).removeClass("btn-default").addClass("btn-primary");
590
                } else {
591
                    button.prop("disabled", true).removeClass("btn-primary").addClass("btn-default");
592
                }
593
            }
594
595
            // Handle cashup selected modal
596
            $("#confirmCashupSelectedModal").on("shown.bs.modal", function(e) {
597
                var selectedCheckboxes = $(".register_checkbox:checked");
598
                var selectedCount = selectedCheckboxes.length;
599
                var selectedIds = [];
600
                var selectedNames = [];
601
602
                selectedCheckboxes.each(function() {
603
                    var registerRow = $(this).closest("tr");
604
                    var registerId = $(this).val();
605
                    var registerName = registerRow.find("td:nth-child(2) a").text(); // Second column (after checkbox)
606
607
                    selectedIds.push(registerId);
608
                    selectedNames.push(registerName);
609
                });
610
611
                $("#selected_count").text(selectedCount);
612
                $("#selected_registers_field").val(selectedIds.join(","));
613
614
                // Populate register list
615
                var listHtml = "";
616
                selectedNames.forEach(function(name) {
617
                    listHtml += "<li>" + name + "</li>";
618
                });
619
                $("#selected_registers_list").html(listHtml);
620
            });
621
622
            // Handle start cashup for selected
623
            $("#start_selected_btn").on("click", function(e) {
624
                e.preventDefault();
625
                var form = $(this).closest("form");
626
                form.find("#selected_operation").val("cud-cashup_start");
627
                form.submit();
628
            });
629
630
            // Handle quick cashup for selected
631
            $("#quick_selected_btn").on("click", function(e) {
632
                e.preventDefault();
633
                var form = $(this).closest("form");
634
                form.find("#selected_operation").val("cud-cashup");
635
                form.submit();
242
            });
636
            });
243
        });
637
        });
244
    </script>
638
    </script>
(-)a/pos/register.pl (-15 / +53 lines)
Lines 63-73 if ( !$registers->count ) { Link Here
63
        registers  => $registers,
63
        registers  => $registers,
64
    );
64
    );
65
65
66
    my $cash_register = Koha::Cash::Registers->find( { id => $registerid } );
66
    my $cash_register      = Koha::Cash::Registers->find( { id => $registerid } );
67
    my $accountlines  = $cash_register->outstanding_accountlines();
67
    my $accountlines       = $cash_register->outstanding_accountlines();
68
    my $cashup_in_progress = $cash_register->cashup_in_progress();
69
68
    $template->param(
70
    $template->param(
69
        register     => $cash_register,
71
        register           => $cash_register,
70
        accountlines => $accountlines
72
        accountlines       => $accountlines,
73
        cashup_in_progress => $cashup_in_progress,
71
    );
74
    );
72
75
73
    my $transactions_range_from = $input->param('trange_f');
76
    my $transactions_range_from = $input->param('trange_f');
Lines 102-108 if ( !$registers->count ) { Link Here
102
    $template->param( trange_t => $end, );
105
    $template->param( trange_t => $end, );
103
106
104
    my $op = $input->param('op') // '';
107
    my $op = $input->param('op') // '';
105
    if ( $op eq 'cud-cashup' ) {
108
    if ( $op eq 'cud-cashup_start' ) {
109
        if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
110
            eval {
111
                $cash_register->start_cashup(
112
                    {
113
                        manager_id => $logged_in_user->id,
114
                    }
115
                );
116
            };
117
            if ($@) {
118
                if ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
119
                    $template->param( error_cashup_in_progress => 1 );
120
                } else {
121
                    $template->param( error_cashup_start => 1 );
122
                }
123
            } else {
124
125
                # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
126
                print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid );
127
                exit;
128
            }
129
        } else {
130
            $template->param( error_cashup_permission => 1 );
131
        }
132
    } elsif ( $op eq 'cud-cashup' ) {
106
        if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
133
        if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
107
            my $amount              = $input->param('amount');
134
            my $amount              = $input->param('amount');
108
            my $reconciliation_note = $input->param('reconciliation_note');
135
            my $reconciliation_note = $input->param('reconciliation_note');
Lines 116-133 if ( !$registers->count ) { Link Here
116
                    $reconciliation_note = undef if $reconciliation_note eq '';
143
                    $reconciliation_note = undef if $reconciliation_note eq '';
117
                }
144
                }
118
145
119
                $cash_register->add_cashup(
146
                eval {
120
                    {
147
                    $cash_register->add_cashup(
121
                        manager_id          => $logged_in_user->id,
148
                        {
122
                        amount              => $amount,
149
                            manager_id          => $logged_in_user->id,
123
                        reconciliation_note => $reconciliation_note
150
                            amount              => $amount,
151
                            reconciliation_note => $reconciliation_note
152
                        }
153
                    );
154
                };
155
                if ($@) {
156
                    if ( $@->isa('Koha::Exceptions::Object::BadValue') ) {
157
                        $template->param( error_no_cashup_start => 1 );
158
                    } elsif ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
159
                        $template->param( error_cashup_already_completed => 1 );
160
                    } else {
161
                        $template->param( error_cashup_complete => 1 );
124
                    }
162
                    }
125
                );
163
                } else {
126
127
                # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
128
                print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid );
129
                exit;
130
164
165
                    # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
166
                    print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid );
167
                    exit;
168
                }
131
            } else {
169
            } else {
132
                $template->param( error_cashup_amount => 1 );
170
                $template->param( error_cashup_amount => 1 );
133
            }
171
            }
(-)a/pos/registers.pl (-13 / +136 lines)
Lines 53-84 if ( !$registers->count ) { Link Here
53
    $template->param( registers => $registers );
53
    $template->param( registers => $registers );
54
}
54
}
55
55
56
# Handle success/error messages from redirects
57
my $cashup_start_success    = $input->param('cashup_start_success');
58
my $cashup_start_errors     = $input->param('cashup_start_errors');
59
my $cashup_complete_success = $input->param('cashup_complete_success');
60
my $cashup_complete_errors  = $input->param('cashup_complete_errors');
61
62
if ($cashup_start_success) {
63
    $template->param( cashup_start_success => $cashup_start_success );
64
}
65
if ($cashup_start_errors) {
66
    $template->param( cashup_start_errors => $cashup_start_errors );
67
}
68
if ($cashup_complete_success) {
69
    $template->param( cashup_complete_success => $cashup_complete_success );
70
}
71
if ($cashup_complete_errors) {
72
    $template->param( cashup_complete_errors => $cashup_complete_errors );
73
}
74
56
my $op = $input->param('op') // '';
75
my $op = $input->param('op') // '';
57
if ( $op eq 'cud-cashup' ) {
76
if ( $op eq 'cud-cashup_start' ) {
58
    if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
77
    if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
59
        my $registerid = $input->param('registerid');
78
        my $registerid_param = $input->param('registerid');
60
        if ($registerid) {
79
        my @register_ids     = split( ',', $registerid_param );
61
            my $register = Koha::Cash::Registers->find( { id => $registerid } );
80
        my @errors           = ();
62
            $register->add_cashup(
81
        my $success_count    = 0;
63
                {
82
64
                    manager_id => $logged_in_user->id,
83
        foreach my $register_id (@register_ids) {
65
                    amount     => $register->outstanding_accountlines->total
84
            $register_id =~ s/^\s+|\s+$//g;    # Trim whitespace
85
            next unless $register_id;
86
87
            my $register = Koha::Cash::Registers->find( { id => $register_id } );
88
            next unless $register;
89
90
            eval {
91
                $register->start_cashup(
92
                    {
93
                        manager_id => $logged_in_user->id,
94
                    }
95
                );
96
                $success_count++;
97
            };
98
            if ($@) {
99
                if ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
100
                    push @errors, "Register " . $register->name . ": Cashup already in progress";
101
                } else {
102
                    push @errors, "Register " . $register->name . ": Failed to start cashup";
66
                }
103
                }
104
            }
105
        }
106
107
        if ( @errors && $success_count == 0 ) {
108
109
            # All failed - stay on page to show errors
110
            $template->param(
111
                error_cashup_start => 1,
112
                cashup_errors      => \@errors
67
            );
113
            );
68
        } else {
114
        } else {
69
            for my $register ( $registers->as_list ) {
115
116
            # Some or all succeeded - redirect with coded parameters
117
            my $redirect_url = "/cgi-bin/koha/pos/registers.pl";
118
            my @params;
119
120
            if ( $success_count > 0 ) {
121
                push @params, "cashup_start_success=" . $success_count;
122
            }
123
            if (@errors) {
124
                push @params, "cashup_start_errors=" . scalar(@errors);
125
            }
126
127
            if (@params) {
128
                $redirect_url .= "?" . join( "&", @params );
129
            }
130
131
            print $input->redirect($redirect_url);
132
            exit;
133
        }
134
    } else {
135
        $template->param( error_cashup_permission => 1 );
136
    }
137
} elsif ( $op eq 'cud-cashup' ) {
138
    if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
139
        my $registerid_param = $input->param('registerid');
140
        my @register_ids     = split( ',', $registerid_param );
141
        my @errors           = ();
142
        my $success_count    = 0;
143
144
        foreach my $register_id (@register_ids) {
145
            $register_id =~ s/^\s+|\s+$//g;    # Trim whitespace
146
            next unless $register_id;
147
148
            my $register = Koha::Cash::Registers->find( { id => $register_id } );
149
            next unless $register;
150
151
            eval {
152
                # Quick cashup: calculate expected amount from outstanding accountlines
153
                my $expected_amount =
154
                    $register->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) * -1;
155
156
                # Quick cashup assumes actual amount equals expected (no reconciliation needed)
70
                $register->add_cashup(
157
                $register->add_cashup(
71
                    {
158
                    {
72
                        manager_id => $logged_in_user->id,
159
                        manager_id => $logged_in_user->id,
73
                        amount     => $register->outstanding_accountlines->total
160
                        amount     => $expected_amount,
161
162
                        # No reconciliation_note = quick cashup assumes correct amounts
74
                    }
163
                    }
75
                );
164
                );
165
                $success_count++;
166
            };
167
            if ($@) {
168
                if ( $@->isa('Koha::Exceptions::Object::BadValue') ) {
169
                    push @errors, "Register " . $register->name . ": No cashup session to complete";
170
                } elsif ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
171
                    push @errors, "Register " . $register->name . ": Cashup already completed";
172
                } else {
173
                    push @errors, "Register " . $register->name . ": Failed to complete cashup";
174
                }
76
            }
175
            }
77
        }
176
        }
78
177
79
        # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
178
        if ( @errors && $success_count == 0 ) {
80
        print $input->redirect("/cgi-bin/koha/pos/registers.pl");
179
81
        exit;
180
            # All failed - stay on page to show errors
181
            $template->param(
182
                error_cashup_complete => 1,
183
                cashup_errors         => \@errors
184
            );
185
        } else {
186
187
            # Some or all succeeded - redirect with coded parameters
188
            my $redirect_url = "/cgi-bin/koha/pos/registers.pl";
189
            my @params;
190
191
            if ( $success_count > 0 ) {
192
                push @params, "cashup_complete_success=" . $success_count;
193
            }
194
            if (@errors) {
195
                push @params, "cashup_complete_errors=" . scalar(@errors);
196
            }
197
198
            if (@params) {
199
                $redirect_url .= "?" . join( "&", @params );
200
            }
201
202
            print $input->redirect($redirect_url);
203
            exit;
204
        }
82
    } else {
205
    } else {
83
        $template->param( error_cashup_permission => 1 );
206
        $template->param( error_cashup_permission => 1 );
84
    }
207
    }
(-)a/t/db_dependent/Koha/Cash/Register.t (-64 / +703 lines)
Lines 20-26 Link Here
20
use Modern::Perl;
20
use Modern::Perl;
21
21
22
use Test::NoWarnings;
22
use Test::NoWarnings;
23
use Test::More tests => 6;
23
use Test::More tests => 10;
24
24
25
use Test::Exception;
25
use Test::Exception;
26
26
Lines 260-266 subtest 'cashup' => sub { Link Here
260
    subtest 'outstanding_accountlines' => sub {
260
    subtest 'outstanding_accountlines' => sub {
261
        plan tests => 6;
261
        plan tests => 6;
262
262
263
        my $accountlines = $register->outstanding_accountlines;
263
        $schema->storage->txn_begin;
264
265
        my $test_register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
266
        my $accountlines  = $test_register->outstanding_accountlines;
264
        is(
267
        is(
265
            ref($accountlines), 'Koha::Account::Lines',
268
            ref($accountlines), 'Koha::Account::Lines',
266
            'Koha::Cash::Register->outstanding_accountlines should always return a Koha::Account::Lines set'
269
            'Koha::Cash::Register->outstanding_accountlines should always return a Koha::Account::Lines set'
Lines 270-308 subtest 'cashup' => sub { Link Here
270
            'Koha::Cash::Register->outstanding_accountlines should always return the correct number of accountlines'
273
            'Koha::Cash::Register->outstanding_accountlines should always return the correct number of accountlines'
271
        );
274
        );
272
275
276
        my $test_patron = $builder->build_object( { class => 'Koha::Patrons' } );
277
273
        my $accountline1 = $builder->build_object(
278
        my $accountline1 = $builder->build_object(
274
            {
279
            {
275
                class => 'Koha::Account::Lines',
280
                class => 'Koha::Account::Lines',
276
                value => { register_id => $register->id, date => \'NOW() - INTERVAL 5 MINUTE' },
281
                value => {
282
                    register_id  => $test_register->id,
283
                    amount       => -2.50,
284
                    date         => \'SYSDATE() - INTERVAL 5 MINUTE',
285
                    payment_type => 'CASH'
286
                },
277
            }
287
            }
278
        );
288
        );
279
        my $accountline2 = $builder->build_object(
289
        my $accountline2 = $builder->build_object(
280
            {
290
            {
281
                class => 'Koha::Account::Lines',
291
                class => 'Koha::Account::Lines',
282
                value => { register_id => $register->id, date => \'NOW() - INTERVAL 5 MINUTE' },
292
                value => {
293
                    register_id  => $test_register->id,
294
                    amount       => -1.50,
295
                    date         => \'SYSDATE() - INTERVAL 5 MINUTE',
296
                    payment_type => 'CASH'
297
                },
283
            }
298
            }
284
        );
299
        );
285
300
286
        $accountlines = $register->outstanding_accountlines;
301
        $accountlines = $test_register->outstanding_accountlines;
287
        is( $accountlines->count, 2, 'No cashup, all accountlines returned' );
302
        is( $accountlines->count, 2, 'No cashup, all accountlines returned' );
288
303
289
        my $cashup3 = $register->add_cashup( { manager_id => $patron->id, amount => '2.50' } );
304
        # Calculate expected amount for this cashup
305
        my $expected_amount =
306
            ( $test_register->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) ) * -1;
307
        my $cashup3 = $test_register->add_cashup( { manager_id => $test_patron->id, amount => $expected_amount } );
290
308
291
        $accountlines = $register->outstanding_accountlines;
309
        $accountlines = $test_register->outstanding_accountlines;
292
        is( $accountlines->count, 0, 'Cashup added, no accountlines returned' );
310
        is( $accountlines->count, 0, 'Cashup added, no accountlines returned' );
293
311
294
        my $accountline3 = $builder->build_object(
312
        my $accountline3 = $builder->build_object(
295
            {
313
            {
296
                class => 'Koha::Account::Lines',
314
                class => 'Koha::Account::Lines',
297
                value => { register_id => $register->id },
315
                value => {
316
                    register_id  => $test_register->id,
317
                    amount       => 1.50,
318
                    date         => \'SYSDATE() + INTERVAL 5 MINUTE',
319
                    payment_type => 'CASH'
320
                },
298
            }
321
            }
299
        );
322
        );
300
323
301
        # Fake the cashup timestamp to make sure it's before the accountline we just added,
324
        $accountlines = $test_register->outstanding_accountlines;
302
        # we can't trust that these two actions are more than a second apart in a test
303
        $cashup3->timestamp( \'NOW() - INTERVAL 2 MINUTE' )->store;
304
305
        $accountlines = $register->outstanding_accountlines;
306
        is(
325
        is(
307
            $accountlines->count, 1,
326
            $accountlines->count, 1,
308
            'Accountline added, one accountline returned'
327
            'Accountline added, one accountline returned'
Lines 311-366 subtest 'cashup' => sub { Link Here
311
            $accountlines->next->id,
330
            $accountlines->next->id,
312
            $accountline3->id, 'Correct accountline returned'
331
            $accountline3->id, 'Correct accountline returned'
313
        );
332
        );
333
334
        $schema->storage->txn_rollback;
314
    };
335
    };
315
336
316
    $schema->storage->txn_rollback;
337
    $schema->storage->txn_rollback;
317
};
338
};
318
339
319
subtest 'cashup_reconciliation' => sub {
340
subtest 'cashup_reconciliation' => sub {
320
    plan tests => 5;
341
    plan tests => 6;
321
342
322
    $schema->storage->txn_begin;
343
    $schema->storage->txn_begin;
323
344
324
    # Ensure required account types for reconciliation exist (they should already exist from mandatory data)
325
    use Koha::Account::CreditTypes;
326
    use Koha::Account::DebitTypes;
327
328
    my $surplus_credit_type = Koha::Account::CreditTypes->find( { code => 'CASHUP_SURPLUS' } );
329
    if ( !$surplus_credit_type ) {
330
        $surplus_credit_type = $builder->build_object(
331
            {
332
                class => 'Koha::Account::CreditTypes',
333
                value => {
334
                    code                  => 'CASHUP_SURPLUS',
335
                    description           => 'Cash register surplus found during cashup',
336
                    can_be_added_manually => 0,
337
                    credit_number_enabled => 0,
338
                    is_system             => 1,
339
                    archived              => 0,
340
                }
341
            }
342
        );
343
    }
344
345
    my $deficit_debit_type = Koha::Account::DebitTypes->find( { code => 'CASHUP_DEFICIT' } );
346
    if ( !$deficit_debit_type ) {
347
        $deficit_debit_type = $builder->build_object(
348
            {
349
                class => 'Koha::Account::DebitTypes',
350
                value => {
351
                    code                => 'CASHUP_DEFICIT',
352
                    description         => 'Cash register deficit found during cashup',
353
                    can_be_invoiced     => 0,
354
                    can_be_sold         => 0,
355
                    default_amount      => undef,
356
                    is_system           => 1,
357
                    archived            => 0,
358
                    restricts_checkouts => 0,
359
                }
360
            }
361
        );
362
    }
363
364
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
345
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
365
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
346
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
366
347
Lines 371-379 subtest 'cashup_reconciliation' => sub { Link Here
371
            value => {
352
            value => {
372
                register_id      => $register->id,
353
                register_id      => $register->id,
373
                borrowernumber   => $patron->id,
354
                borrowernumber   => $patron->id,
374
                amount           => -10.00,          # Credit (payment)
355
                amount           => -10.00,                             # Credit (payment)
375
                credit_type_code => 'PAYMENT',
356
                credit_type_code => 'PAYMENT',
376
                debit_type_code  => undef,
357
                debit_type_code  => undef,
358
                payment_type     => 'CASH',
359
                date             => \'SYSDATE() - INTERVAL 1 MINUTE',
360
                timestamp        => \'SYSDATE() - INTERVAL 1 MINUTE',
377
            }
361
            }
378
        }
362
        }
379
    );
363
    );
Lines 383-402 subtest 'cashup_reconciliation' => sub { Link Here
383
            value => {
367
            value => {
384
                register_id      => $register->id,
368
                register_id      => $register->id,
385
                borrowernumber   => $patron->id,
369
                borrowernumber   => $patron->id,
386
                amount           => -5.00,           # Credit (payment)
370
                amount           => -5.00,                              # Credit (payment)
387
                credit_type_code => 'PAYMENT',
371
                credit_type_code => 'PAYMENT',
388
                debit_type_code  => undef,
372
                debit_type_code  => undef,
373
                payment_type     => 'CASH',
374
                date             => \'SYSDATE() - INTERVAL 1 MINUTE',
375
                timestamp        => \'SYSDATE() - INTERVAL 1 MINUTE',
389
            }
376
            }
390
        }
377
        }
391
    );
378
    );
392
379
393
    my $expected_amount = $register->outstanding_accountlines->total;    # Should be -15.00
380
    my $expected_amount =
381
        $register->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } );    # Should be -15.00
382
    is( $expected_amount, -15.00, "Expected cash amount is calculated correctly" );
394
383
395
    subtest 'balanced_cashup' => sub {
384
    subtest 'balanced_cashup' => sub {
396
        plan tests => 3;
385
        plan tests => 3;
397
386
387
        $schema->storage->txn_begin;
388
398
        # Test exact match - no surplus/deficit accountlines should be created
389
        # Test exact match - no surplus/deficit accountlines should be created
399
        my $amount = abs($expected_amount);                              # 15.00 actual matches 15.00 expected
390
        my $amount = abs($expected_amount);    # 15.00 actual matches 15.00 expected
400
391
401
        my $cashup = $register->add_cashup(
392
        my $cashup = $register->add_cashup(
402
            {
393
            {
Lines 420-425 subtest 'cashup_reconciliation' => sub { Link Here
420
        );
411
        );
421
412
422
        is( $reconciliation_lines->count, 0, 'No reconciliation accountlines created for balanced cashup' );
413
        is( $reconciliation_lines->count, 0, 'No reconciliation accountlines created for balanced cashup' );
414
415
        $schema->storage->txn_rollback;
423
    };
416
    };
424
417
425
    subtest 'surplus_cashup' => sub {
418
    subtest 'surplus_cashup' => sub {
Lines 437-449 subtest 'cashup_reconciliation' => sub { Link Here
437
                    amount           => -20.00,           # Credit (payment)
430
                    amount           => -20.00,           # Credit (payment)
438
                    credit_type_code => 'PAYMENT',
431
                    credit_type_code => 'PAYMENT',
439
                    debit_type_code  => undef,
432
                    debit_type_code  => undef,
433
                    payment_type     => 'CASH',
440
                }
434
                }
441
            }
435
            }
442
        );
436
        );
443
437
444
        my $expected = abs( $register2->outstanding_accountlines->total );    # 20.00
438
        my $expected =
445
        my $actual   = 25.00;                                                 # 5.00 surplus
439
            abs( $register2->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) );    # 20.00
446
        my $surplus  = $actual - $expected;
440
        my $actual  = 25.00;                 # 5.00 surplus
441
        my $surplus = $actual - $expected;
447
442
448
        my $cashup = $register2->add_cashup(
443
        my $cashup = $register2->add_cashup(
449
            {
444
            {
Lines 485-490 subtest 'cashup_reconciliation' => sub { Link Here
485
                    amount           => -10.00,
480
                    amount           => -10.00,
486
                    credit_type_code => 'PAYMENT',
481
                    credit_type_code => 'PAYMENT',
487
                    debit_type_code  => undef,
482
                    debit_type_code  => undef,
483
                    payment_type     => 'CASH',
488
                }
484
                }
489
            }
485
            }
490
        );
486
        );
Lines 531-543 subtest 'cashup_reconciliation' => sub { Link Here
531
                    amount           => -30.00,           # Credit (payment)
527
                    amount           => -30.00,           # Credit (payment)
532
                    credit_type_code => 'PAYMENT',
528
                    credit_type_code => 'PAYMENT',
533
                    debit_type_code  => undef,
529
                    debit_type_code  => undef,
530
                    payment_type     => 'CASH',
534
                }
531
                }
535
            }
532
            }
536
        );
533
        );
537
534
538
        my $expected = abs( $register3->outstanding_accountlines->total );    # 30.00
535
        my $expected =
539
        my $actual   = 25.00;                                                 # 5.00 deficit
536
            abs( $register3->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) );    # 30.00
540
        my $deficit  = $expected - $actual;
537
        my $actual  = 25.00;                 # 5.00 deficit
538
        my $deficit = $expected - $actual;
541
539
542
        my $cashup = $register3->add_cashup(
540
        my $cashup = $register3->add_cashup(
543
            {
541
            {
Lines 579-584 subtest 'cashup_reconciliation' => sub { Link Here
579
                    amount           => -20.00,
577
                    amount           => -20.00,
580
                    credit_type_code => 'PAYMENT',
578
                    credit_type_code => 'PAYMENT',
581
                    debit_type_code  => undef,
579
                    debit_type_code  => undef,
580
                    payment_type     => 'CASH',
582
                }
581
                }
583
            }
582
            }
584
        );
583
        );
Lines 625-630 subtest 'cashup_reconciliation' => sub { Link Here
625
                    amount           => -10.00,
624
                    amount           => -10.00,
626
                    credit_type_code => 'PAYMENT',
625
                    credit_type_code => 'PAYMENT',
627
                    debit_type_code  => undef,
626
                    debit_type_code  => undef,
627
                    payment_type     => 'CASH',
628
                }
628
                }
629
            }
629
            }
630
        );
630
        );
Lines 677-682 subtest 'cashup_reconciliation' => sub { Link Here
677
                    amount           => -10.00,
677
                    amount           => -10.00,
678
                    credit_type_code => 'PAYMENT',
678
                    credit_type_code => 'PAYMENT',
679
                    debit_type_code  => undef,
679
                    debit_type_code  => undef,
680
                    payment_type     => 'CASH',
680
                }
681
                }
681
            }
682
            }
682
        );
683
        );
Lines 716-721 subtest 'cashup_reconciliation' => sub { Link Here
716
                    amount           => -10.00,
717
                    amount           => -10.00,
717
                    credit_type_code => 'PAYMENT',
718
                    credit_type_code => 'PAYMENT',
718
                    debit_type_code  => undef,
719
                    debit_type_code  => undef,
720
                    payment_type     => 'CASH',
719
                }
721
                }
720
            }
722
            }
721
        );
723
        );
Lines 745-747 subtest 'cashup_reconciliation' => sub { Link Here
745
747
746
    $schema->storage->txn_rollback;
748
    $schema->storage->txn_rollback;
747
};
749
};
750
751
subtest 'two_phase_cashup_workflow' => sub {
752
    plan tests => 15;
753
754
    $schema->storage->txn_begin;
755
756
    # Create test data
757
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
758
    my $library  = $builder->build_object( { class => 'Koha::Libraries' } );
759
    my $register = $builder->build_object(
760
        {
761
            class => 'Koha::Cash::Registers',
762
            value => {
763
                branch         => $library->branchcode,
764
                starting_float => 0,
765
            }
766
        }
767
    );
768
769
    # Add some test transactions
770
    my $account_line1 = $builder->build_object(
771
        {
772
            class => 'Koha::Account::Lines',
773
            value => {
774
                amount           => 10.00,
775
                date             => \'SYSDATE() - INTERVAL 1 MINUTE',
776
                register_id      => undef,
777
                debit_type_code  => 'OVERDUE',
778
                credit_type_code => undef,
779
                payment_type     => undef,
780
            }
781
        }
782
    );
783
784
    my $account_line2 = $builder->build_object(
785
        {
786
            class => 'Koha::Account::Lines',
787
            value => {
788
                amount           => -5.00,
789
                date             => \'SYSDATE() - INTERVAL 1 MINUTE',
790
                register_id      => $register->id,
791
                debit_type_code  => undef,
792
                credit_type_code => 'PAYMENT',
793
                payment_type     => 'CASH'
794
            }
795
        }
796
    );
797
798
    # Test 1: start_cashup creates CASHUP_START action
799
    my $cashup_start = $register->start_cashup( { manager_id => $manager->id } );
800
801
    is(
802
        ref $cashup_start, 'Koha::Cash::Register::Cashup',
803
        'start_cashup returns Cash::Register::Cashup object'
804
    );
805
806
    my $start_action = Koha::Cash::Register::Actions->search(
807
        {
808
            register_id => $register->id,
809
            code        => 'CASHUP_START'
810
        }
811
    )->next;
812
813
    ok( $start_action, 'CASHUP_START action created in database' );
814
    is( $start_action->manager_id, $manager->id, 'CASHUP_START has correct manager_id' );
815
816
    # Test 2: cashup_in_progress detects active cashup
817
    my $in_progress = $register->cashup_in_progress;
818
    ok( $in_progress, 'cashup_in_progress detects active cashup' );
819
    is( $in_progress->id, $start_action->id, 'cashup_in_progress returns correct CASHUP_START action' );
820
821
    # Test 3: Cannot start another cashup while one is in progress
822
    throws_ok {
823
        $register->start_cashup( { manager_id => $manager->id } );
824
    }
825
    'Koha::Exceptions::Object::DuplicateID',
826
        'Cannot start second cashup while one is in progress';
827
828
    # Test 4: outstanding_accountlines behavior during active cashup
829
    my $outstanding = $register->outstanding_accountlines;
830
    is( $outstanding->count, 0, 'outstanding_accountlines returns 0 during active cashup' );
831
832
    # Test 5: Add transaction after cashup start (should appear in outstanding)
833
    my $account_line3 = $builder->build_object(
834
        {
835
            class => 'Koha::Account::Lines',
836
            value => {
837
                amount           => -8.00,
838
                date             => \'SYSDATE() + INTERVAL 1 MINUTE',
839
                register_id      => $register->id,
840
                debit_type_code  => undef,
841
                credit_type_code => 'PAYMENT',
842
                payment_type     => 'CASH',
843
            }
844
        }
845
    );
846
847
    # This new transaction should appear in outstanding (it's after CASHUP_START)
848
    $outstanding = $register->outstanding_accountlines;
849
    is( $outstanding->count, 1, 'New transaction after CASHUP_START appears in outstanding' );
850
851
    # Test 6: outstanding_accountlines correctly handles session boundaries
852
    my $session_accountlines = $register->outstanding_accountlines;
853
    my $session_total        = $session_accountlines->total;
854
    is(
855
        $session_total, -8.00,
856
        'outstanding_accountlines correctly calculates session totals with CASHUP_START cutoff'
857
    );
858
859
    # Test 7: Complete cashup with exact amount (no reconciliation)
860
    my $expected_cashup_amount = 5.00;                    # CASH PAYMENT prior to CASHUP_START
861
    my $cashup_complete        = $register->add_cashup(
862
        {
863
            manager_id => $manager->id,
864
            amount     => $expected_cashup_amount
865
        }
866
    );
867
868
    is(
869
        ref $cashup_complete, 'Koha::Cash::Register::Cashup',
870
        'add_cashup returns Cashup object'
871
    );
872
873
    # Check no reconciliation lines were created
874
    my $surplus_lines = $cashup_complete->accountlines->search(
875
        {
876
            register_id      => $register->id,
877
            credit_type_code => 'CASHUP_SURPLUS'
878
        }
879
    );
880
    my $deficit_lines = $cashup_complete->accountlines->search(
881
        {
882
            register_id     => $register->id,
883
            debit_type_code => 'CASHUP_DEFICIT'
884
        }
885
    );
886
887
    is( $surplus_lines->count, 0, 'No surplus lines created for exact cashup' );
888
    is( $deficit_lines->count, 0, 'No deficit lines created for exact cashup' );
889
890
    # Test 8: cashup_in_progress returns undef after completion
891
    $in_progress = $register->cashup_in_progress;
892
    is( $in_progress, undef, 'cashup_in_progress returns undef after completion' );
893
894
    # Test 9: outstanding_accountlines now includes new transaction
895
    $outstanding = $register->outstanding_accountlines;
896
    is( $outstanding->count, 1,     'outstanding_accountlines includes transaction after completion' );
897
    is( $outstanding->total, -8.00, 'outstanding_accountlines total is correct after completion' );
898
899
    $schema->storage->txn_rollback;
900
};
901
902
subtest 'cashup_in_progress' => sub {
903
    plan tests => 6;
904
905
    $schema->storage->txn_begin;
906
907
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
908
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
909
910
    # Test 1: No cashups ever performed
911
    subtest 'no_cashups_ever' => sub {
912
        plan tests => 1;
913
914
        my $in_progress = $register->cashup_in_progress;
915
        is( $in_progress, undef, 'cashup_in_progress returns undef when no cashups have ever been performed' );
916
    };
917
918
    # Test 2: Only quick cashups performed
919
    subtest 'only_quick_cashups' => sub {
920
        plan tests => 2;
921
922
        # Add a quick cashup
923
        my $quick_cashup = $register->add_cashup( { manager_id => $manager->id, amount => '10.00' } );
924
        $quick_cashup->timestamp( \'NOW() - INTERVAL 30 MINUTE' )->store();
925
926
        my $in_progress = $register->cashup_in_progress;
927
        is( $in_progress, undef, 'cashup_in_progress returns undef after quick cashup completion' );
928
929
        # Add another quick cashup
930
        my $quick_cashup2 = $register->add_cashup( { manager_id => $manager->id, amount => '5.00' } );
931
932
        $in_progress = $register->cashup_in_progress;
933
        is( $in_progress, undef, 'cashup_in_progress returns undef after multiple quick cashups' );
934
    };
935
936
    # Test 3: Multiple CASHUP_START actions
937
    subtest 'multiple_start_actions' => sub {
938
        plan tests => 2;
939
940
        my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
941
942
        # Create multiple CASHUP_START actions
943
        my $start1 = $register2->start_cashup( { manager_id => $manager->id } );
944
        $start1->timestamp( \'NOW() - INTERVAL 60 MINUTE' )->store();
945
946
        # Complete the first one
947
        my $complete1 = $register2->add_cashup( { manager_id => $manager->id, amount => '1.00' } );
948
        $complete1->timestamp( \'NOW() - INTERVAL 50 MINUTE' )->store();
949
950
        # Start another one
951
        my $start2 = $register2->start_cashup( { manager_id => $manager->id } );
952
953
        my $in_progress = $register2->cashup_in_progress;
954
        is( ref($in_progress), 'Koha::Cash::Register::Action', 'Returns most recent CASHUP_START when multiple exist' );
955
        is( $in_progress->id,  $start2->id, 'Returns the correct (most recent) CASHUP_START action' );
956
    };
957
958
    # Test 4: Mixed quick and two-phase workflows
959
    subtest 'mixed_workflows' => sub {
960
        plan tests => 3;
961
962
        my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
963
964
        # Quick cashup first
965
        my $quick = $register3->add_cashup( { manager_id => $manager->id, amount => '5.00' } );
966
        $quick->timestamp( \'NOW() - INTERVAL 40 MINUTE' )->store();
967
968
        # Start two-phase
969
        my $start = $register3->start_cashup( { manager_id => $manager->id } );
970
        $start->timestamp( \'NOW() - INTERVAL 30 MINUTE' )->store();
971
972
        my $in_progress = $register3->cashup_in_progress;
973
        is( ref($in_progress), 'Koha::Cash::Register::Action', 'Detects two-phase in progress after quick cashup' );
974
        is( $in_progress->id,  $start->id,                     'Returns correct CASHUP_START after mixed workflow' );
975
976
        # Complete two-phase
977
        my $complete = $register3->add_cashup( { manager_id => $manager->id, amount => '3.00' } );
978
979
        $in_progress = $register3->cashup_in_progress;
980
        is( $in_progress, undef, 'Returns undef after completing two-phase in mixed workflow' );
981
    };
982
983
    # Test 5: Timestamp edge cases
984
    subtest 'timestamp_edge_cases' => sub {
985
        plan tests => 2;
986
987
        my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
988
989
        # Create CASHUP_START
990
        my $start      = $register4->start_cashup( { manager_id => $manager->id } );
991
        my $start_time = $start->timestamp;
992
993
        # Create CASHUP with exactly the same timestamp (edge case)
994
        my $complete = $register4->add_cashup( { manager_id => $manager->id, amount => '1.00' } );
995
        $complete->timestamp($start_time)->store();
996
997
        my $in_progress = $register4->cashup_in_progress;
998
        is( $in_progress, undef, 'Handles same timestamp edge case correctly' );
999
1000
        # Test with CASHUP timestamp slightly before CASHUP_START (edge case)
1001
        my $register5 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1002
        my $start2    = $register5->start_cashup( { manager_id => $manager->id } );
1003
1004
        my $complete2 = $register5->add_cashup( { manager_id => $manager->id, amount => '1.00' } );
1005
        $complete2->timestamp( \'NOW() - INTERVAL 1 MINUTE' )->store();
1006
1007
        $in_progress = $register5->cashup_in_progress;
1008
        is(
1009
            ref($in_progress), 'Koha::Cash::Register::Action',
1010
            'Correctly identifies active cashup when completion is backdated'
1011
        );
1012
    };
1013
1014
    # Test 6: Performance with many cashups
1015
    subtest 'performance_with_many_cashups' => sub {
1016
        plan tests => 1;
1017
1018
        my $register6 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1019
1020
        # Create many quick cashups
1021
        for my $i ( 1 .. 10 ) {
1022
            my $cashup    = $register6->add_cashup( { manager_id => $manager->id, amount => '1.00' } );
1023
            my $timestamp = "NOW() - INTERVAL $i MINUTE";
1024
            $cashup->timestamp( \$timestamp )->store();
1025
        }
1026
1027
        # Start a two-phase cashup
1028
        my $start = $register6->start_cashup( { manager_id => $manager->id } );
1029
1030
        my $in_progress = $register6->cashup_in_progress;
1031
        is( ref($in_progress), 'Koha::Cash::Register::Action', 'Performs correctly with many previous cashups' );
1032
    };
1033
1034
    $schema->storage->txn_rollback;
1035
};
1036
1037
subtest 'start_cashup_parameter_validation' => sub {
1038
    plan tests => 5;
1039
1040
    $schema->storage->txn_begin;
1041
1042
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1043
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
1044
1045
    # Test 1: Valid parameters
1046
    subtest 'valid_parameters' => sub {
1047
        plan tests => 3;
1048
1049
        my $register1 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1050
1051
        my $cashup_start = $register1->start_cashup( { manager_id => $manager->id } );
1052
1053
        is( ref($cashup_start),        'Koha::Cash::Register::Cashup', 'start_cashup returns correct object type' );
1054
        is( $cashup_start->manager_id, $manager->id,                   'manager_id set correctly' );
1055
        is( $cashup_start->code,       'CASHUP_START',                 'code set correctly to CASHUP_START' );
1056
    };
1057
1058
    # Test 2: Missing manager_id
1059
    subtest 'missing_manager_id' => sub {
1060
        plan tests => 1;
1061
1062
        my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1063
1064
        eval { $register2->start_cashup( {} ); };
1065
        ok( $@, 'start_cashup fails when manager_id is missing' );
1066
    };
1067
1068
    # Test 3: Invalid manager_id
1069
    subtest 'invalid_manager_id' => sub {
1070
        plan tests => 1;
1071
1072
        my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1073
1074
        eval { $register3->start_cashup( { manager_id => 99999999 } ); };
1075
        ok( $@, 'start_cashup fails with invalid manager_id' );
1076
    };
1077
1078
    # Test 4: Duplicate start_cashup prevention
1079
    subtest 'duplicate_prevention' => sub {
1080
        plan tests => 2;
1081
1082
        my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1083
1084
        # First start should succeed
1085
        my $first_start = $register4->start_cashup( { manager_id => $manager->id } );
1086
        ok( $first_start, 'First start_cashup succeeds' );
1087
1088
        # Second start should fail
1089
        throws_ok {
1090
            $register4->start_cashup( { manager_id => $manager->id } );
1091
        }
1092
        'Koha::Exceptions::Object::DuplicateID',
1093
            'Second start_cashup throws DuplicateID exception';
1094
    };
1095
1096
    # Test 5: Database transaction integrity
1097
    subtest 'transaction_integrity' => sub {
1098
        plan tests => 3;
1099
1100
        my $register5 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1101
1102
        # Add some transactions to establish expected amount
1103
        my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
1104
        my $account = $patron->account;
1105
1106
        my $fine = $account->add_debit(
1107
            {
1108
                amount    => '15.00',
1109
                type      => 'OVERDUE',
1110
                interface => 'cron'
1111
            }
1112
        );
1113
1114
        my $payment = $account->pay(
1115
            {
1116
                cash_register => $register5->id,
1117
                amount        => '15.00',
1118
                credit_type   => 'PAYMENT',
1119
                payment_type  => 'CASH',
1120
                lines         => [$fine]
1121
            }
1122
        );
1123
1124
        my $initial_action_count = $register5->_result->search_related('cash_register_actions')->count;
1125
1126
        my $start = $register5->start_cashup( { manager_id => $manager->id } );
1127
1128
        # Verify action was created
1129
        my $final_action_count = $register5->_result->search_related('cash_register_actions')->count;
1130
        is( $final_action_count, $initial_action_count + 1, 'CASHUP_START action created in database' );
1131
1132
        # Verify expected amount calculation
1133
        ok( $start->amount >= 0, 'Expected amount calculated correctly' );
1134
1135
        # Verify timestamp is set
1136
        ok( defined $start->timestamp, 'Timestamp is set on CASHUP_START action' );
1137
    };
1138
1139
    $schema->storage->txn_rollback;
1140
};
1141
1142
subtest 'add_cashup' => sub {
1143
    plan tests => 5;
1144
1145
    $schema->storage->txn_begin;
1146
1147
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1148
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
1149
1150
    # Test 1: Valid parameters
1151
    subtest 'valid_parameters' => sub {
1152
        plan tests => 3;
1153
1154
        my $register1 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1155
1156
        my $cashup = $register1->add_cashup( { manager_id => $manager->id, amount => '10.00' } );
1157
1158
        is( ref($cashup),        'Koha::Cash::Register::Cashup', 'add_cashup returns correct object type' );
1159
        is( $cashup->manager_id, $manager->id,                   'manager_id set correctly' );
1160
        is( $cashup->amount,     '10.000000',                    'amount set correctly' );
1161
    };
1162
1163
    # Test 2: Missing required parameters
1164
    subtest 'missing_parameters' => sub {
1165
        plan tests => 3;
1166
1167
        my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1168
1169
        # Missing manager_id
1170
        eval { $register2->add_cashup( { amount => '10.00' } ); };
1171
        ok( $@, 'add_cashup fails when manager_id is missing' );
1172
1173
        # Missing amount
1174
        eval { $register2->add_cashup( { manager_id => $manager->id } ); };
1175
        ok( $@, 'add_cashup fails when amount is missing' );
1176
1177
        # Missing both
1178
        eval { $register2->add_cashup( {} ); };
1179
        ok( $@, 'add_cashup fails when both parameters are missing' );
1180
    };
1181
1182
    # Test 3: Invalid amount parameter
1183
    subtest 'invalid_amount' => sub {
1184
        plan tests => 4;
1185
1186
        my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1187
1188
        # Zero amount
1189
        throws_ok {
1190
            $register3->add_cashup( { manager_id => $manager->id, amount => '0.00' } );
1191
        }
1192
        'Koha::Exceptions::Account::AmountNotPositive',
1193
            'Zero amount throws AmountNotPositive exception';
1194
1195
        # Negative amount
1196
        throws_ok {
1197
            $register3->add_cashup( { manager_id => $manager->id, amount => '-5.00' } );
1198
        }
1199
        'Koha::Exceptions::Account::AmountNotPositive',
1200
            'Negative amount throws AmountNotPositive exception';
1201
1202
        # Non-numeric amount
1203
        throws_ok {
1204
            $register3->add_cashup( { manager_id => $manager->id, amount => 'invalid' } );
1205
        }
1206
        'Koha::Exceptions::Account::AmountNotPositive',
1207
            'Non-numeric amount throws AmountNotPositive exception';
1208
1209
        # Empty string amount
1210
        throws_ok {
1211
            $register3->add_cashup( { manager_id => $manager->id, amount => '' } );
1212
        }
1213
        'Koha::Exceptions::Account::AmountNotPositive',
1214
            'Empty string amount throws AmountNotPositive exception';
1215
    };
1216
1217
    # Test 4: Reconciliation note handling
1218
    subtest 'reconciliation_note_handling' => sub {
1219
        plan tests => 4;
1220
1221
        my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1222
        my $patron    = $builder->build_object( { class => 'Koha::Patrons' } );
1223
        my $account   = $patron->account;
1224
1225
        # Create transaction to enable surplus creation
1226
        my $fine = $account->add_debit(
1227
            {
1228
                amount    => '10.00',
1229
                type      => 'OVERDUE',
1230
                interface => 'cron'
1231
            }
1232
        );
1233
1234
        my $payment = $account->pay(
1235
            {
1236
                cash_register => $register4->id,
1237
                amount        => '10.00',
1238
                credit_type   => 'PAYMENT',
1239
                payment_type  => 'CASH',
1240
                lines         => [$fine]
1241
            }
1242
        );
1243
1244
        # Test normal note
1245
        my $cashup1 = $register4->add_cashup(
1246
            {
1247
                manager_id          => $manager->id,
1248
                amount              => '15.00',                        # Creates surplus
1249
                reconciliation_note => 'Found extra money in drawer'
1250
            }
1251
        );
1252
1253
        my $surplus1 = Koha::Account::Lines->search(
1254
            {
1255
                register_id      => $register4->id,
1256
                credit_type_code => 'CASHUP_SURPLUS'
1257
            }
1258
        )->next;
1259
        is( $surplus1->note, 'Found extra money in drawer', 'Normal reconciliation note stored correctly' );
1260
1261
        # Test very long note (should be truncated)
1262
        my $register5 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1263
        my $long_note = 'x' x 1500;    # Longer than 1000 character limit
1264
1265
        my $fine2 = $account->add_debit(
1266
            {
1267
                amount    => '10.00',
1268
                type      => 'OVERDUE',
1269
                interface => 'cron'
1270
            }
1271
        );
1272
1273
        my $payment2 = $account->pay(
1274
            {
1275
                cash_register => $register5->id,
1276
                amount        => '10.00',
1277
                credit_type   => 'PAYMENT',
1278
                payment_type  => 'CASH',
1279
                lines         => [$fine2]
1280
            }
1281
        );
1282
1283
        my $cashup2 = $register5->add_cashup(
1284
            {
1285
                manager_id          => $manager->id,
1286
                amount              => '15.00',
1287
                reconciliation_note => $long_note
1288
            }
1289
        );
1290
1291
        my $surplus2 = Koha::Account::Lines->search(
1292
            {
1293
                register_id      => $register5->id,
1294
                credit_type_code => 'CASHUP_SURPLUS'
1295
            }
1296
        )->next;
1297
        is( length( $surplus2->note ), 1000, 'Long reconciliation note truncated to 1000 characters' );
1298
1299
        # Test whitespace-only note (should be undef)
1300
        my $register6 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1301
1302
        my $fine3 = $account->add_debit(
1303
            {
1304
                amount    => '10.00',
1305
                type      => 'OVERDUE',
1306
                interface => 'cron'
1307
            }
1308
        );
1309
1310
        my $payment3 = $account->pay(
1311
            {
1312
                cash_register => $register6->id,
1313
                amount        => '10.00',
1314
                credit_type   => 'PAYMENT',
1315
                payment_type  => 'CASH',
1316
                lines         => [$fine3]
1317
            }
1318
        );
1319
1320
        my $cashup3 = $register6->add_cashup(
1321
            {
1322
                manager_id          => $manager->id,
1323
                amount              => '15.00',
1324
                reconciliation_note => '   '           # Whitespace only
1325
            }
1326
        );
1327
1328
        my $surplus3 = Koha::Account::Lines->search(
1329
            {
1330
                register_id      => $register6->id,
1331
                credit_type_code => 'CASHUP_SURPLUS'
1332
            }
1333
        )->next;
1334
        is( $surplus3->note, undef, 'Whitespace-only reconciliation note stored as undef' );
1335
1336
        # Test empty string note (should be undef)
1337
        my $register7 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1338
1339
        my $fine4 = $account->add_debit(
1340
            {
1341
                amount    => '10.00',
1342
                type      => 'OVERDUE',
1343
                interface => 'cron'
1344
            }
1345
        );
1346
1347
        my $payment4 = $account->pay(
1348
            {
1349
                cash_register => $register7->id,
1350
                amount        => '10.00',
1351
                credit_type   => 'PAYMENT',
1352
                payment_type  => 'CASH',
1353
                lines         => [$fine4]
1354
            }
1355
        );
1356
1357
        my $cashup4 = $register7->add_cashup(
1358
            {
1359
                manager_id          => $manager->id,
1360
                amount              => '15.00',
1361
                reconciliation_note => ''              # Empty string
1362
            }
1363
        );
1364
1365
        my $surplus4 = Koha::Account::Lines->search(
1366
            {
1367
                register_id      => $register7->id,
1368
                credit_type_code => 'CASHUP_SURPLUS'
1369
            }
1370
        )->next;
1371
        is( $surplus4->note, undef, 'Empty string reconciliation note stored as undef' );
1372
    };
1373
1374
    # Test 5: Invalid manager_id
1375
    subtest 'invalid_manager_id' => sub {
1376
        plan tests => 1;
1377
1378
        my $register9 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1379
1380
        eval { $register9->add_cashup( { manager_id => 99999999, amount => '10.00' } ); };
1381
        ok( $@, 'add_cashup fails with invalid manager_id' );
1382
        diag($@);
1383
    };
1384
1385
    $schema->storage->txn_rollback;
1386
};
(-)a/t/db_dependent/Koha/Cash/Register/Cashup.t (-2 / +276 lines)
Lines 19-27 Link Here
19
19
20
use Modern::Perl;
20
use Modern::Perl;
21
use Test::NoWarnings;
21
use Test::NoWarnings;
22
use Test::More tests => 4;
22
use Test::More tests => 6;
23
23
24
use Koha::Database;
24
use Koha::Database;
25
use Koha::DateUtils qw( dt_from_string );
25
26
26
use t::lib::TestBuilder;
27
use t::lib::TestBuilder;
27
28
Lines 359-362 subtest 'summary' => sub { Link Here
359
    $schema->storage->txn_rollback;
360
    $schema->storage->txn_rollback;
360
};
361
};
361
362
363
subtest 'accountlines' => sub {
364
    plan tests => 3;
365
366
    $schema->storage->txn_begin;
367
368
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
369
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
370
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
371
372
    # Test 1: Basic functionality
373
    subtest 'basic_accountlines_functionality' => sub {
374
        plan tests => 2;
375
376
        my $account = $patron->account;
377
        my $fine    = $account->add_debit(
378
            {
379
                amount    => '10.00',
380
                type      => 'OVERDUE',
381
                interface => 'cron'
382
            }
383
        );
384
        $fine->date( \'NOW() - INTERVAL 30 MINUTE' )->store;
385
386
        my $payment = $account->pay(
387
            {
388
                cash_register => $register->id,
389
                amount        => '10.00',
390
                credit_type   => 'PAYMENT',
391
                payment_type  => 'CASH',
392
                lines         => [$fine]
393
            }
394
        );
395
        my $payment_line = Koha::Account::Lines->find( $payment->{payment_id} );
396
        $payment_line->date( \'NOW() - INTERVAL 25 MINUTE' )->store;
397
398
        # Cashup
399
        my $cashup = $register->add_cashup( { manager_id => $manager->id, amount => '10.00' } );
400
401
        # Check accountlines method exists and returns correct type
402
        my $accountlines = $cashup->accountlines;
403
        is( ref($accountlines), 'Koha::Account::Lines', 'accountlines returns Koha::Account::Lines object' );
404
        ok( $accountlines->count >= 0, 'accountlines returns a valid count' );
405
    };
406
407
    # Test 2: Two-phase workflow basics
408
    subtest 'two_phase_basics' => sub {
409
        plan tests => 3;
410
411
        my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
412
        my $account2  = $patron->account;
413
414
        # Start cashup first
415
        my $cashup_start = $register2->start_cashup( { manager_id => $manager->id } );
416
417
        # Add transaction after start
418
        my $fine = $account2->add_debit(
419
            {
420
                amount    => '5.00',
421
                type      => 'OVERDUE',
422
                interface => 'cron'
423
            }
424
        );
425
426
        my $payment = $account2->pay(
427
            {
428
                cash_register => $register2->id,
429
                amount        => '5.00',
430
                credit_type   => 'PAYMENT',
431
                payment_type  => 'CASH',
432
                lines         => [$fine]
433
            }
434
        );
435
436
        # Complete cashup
437
        my $cashup_complete = $register2->add_cashup( { manager_id => $manager->id, amount => '5.00' } );
438
439
        # Check accountlines
440
        my $accountlines = $cashup_complete->accountlines;
441
        is( ref($accountlines), 'Koha::Account::Lines', 'Two-phase accountlines returns correct type' );
442
        ok( $accountlines->count >= 0, 'Two-phase accountlines returns valid count' );
443
444
        # Check filtering capability
445
        my $filtered = $accountlines->search( { payment_type => 'CASH' } );
446
        ok( defined $filtered, 'Accountlines can be filtered' );
447
    };
448
449
    # Test 3: Reconciliation inclusion
450
    subtest 'reconciliation_inclusion' => sub {
451
        plan tests => 2;
452
453
        my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
454
        my $account3  = $patron->account;
455
456
        # Create transaction
457
        my $fine = $account3->add_debit(
458
            {
459
                amount    => '20.00',
460
                type      => 'OVERDUE',
461
                interface => 'cron'
462
            }
463
        );
464
465
        my $payment = $account3->pay(
466
            {
467
                cash_register => $register3->id,
468
                amount        => '20.00',
469
                credit_type   => 'PAYMENT',
470
                payment_type  => 'CASH',
471
                lines         => [$fine]
472
            }
473
        );
474
475
        # Cashup with surplus to create reconciliation line
476
        my $cashup = $register3->add_cashup(
477
            {
478
                manager_id => $manager->id,
479
                amount     => '25.00'         # Creates surplus
480
            }
481
        );
482
483
        my $accountlines = $cashup->accountlines;
484
        ok( $accountlines->count >= 1, 'Accountlines includes transactions when surplus created' );
485
486
        # Verify surplus line exists
487
        my $surplus_lines = $accountlines->search( { credit_type_code => 'CASHUP_SURPLUS' } );
488
        is( $surplus_lines->count, 1, 'Surplus reconciliation line is included' );
489
    };
490
491
    $schema->storage->txn_rollback;
492
};
493
494
subtest 'summary_session_boundaries' => sub {
495
    plan tests => 4;
496
497
    $schema->storage->txn_begin;
498
499
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
500
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
501
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
502
503
    # Test 1: Basic summary functionality
504
    subtest 'basic_summary_functionality' => sub {
505
        plan tests => 3;
506
507
        my $account = $patron->account;
508
509
        # Create a simple transaction and cashup
510
        my $fine = $account->add_debit(
511
            {
512
                amount    => '10.00',
513
                type      => 'OVERDUE',
514
                interface => 'cron'
515
            }
516
        );
517
518
        my $payment = $account->pay(
519
            {
520
                cash_register => $register->id,
521
                amount        => '10.00',
522
                credit_type   => 'PAYMENT',
523
                payment_type  => 'CASH',
524
                lines         => [$fine]
525
            }
526
        );
527
528
        my $cashup  = $register->add_cashup( { manager_id => $manager->id, amount => '10.00' } );
529
        my $summary = $cashup->summary;
530
531
        # Basic summary structure validation
532
        ok( defined $summary->{from_date} || !defined $summary->{from_date}, 'Summary has from_date field' );
533
        ok( defined $summary->{to_date},                                     'Summary has to_date field' );
534
        ok( defined $summary->{total},                                       'Summary has total field' );
535
    };
536
537
    # Test 2: Two-phase workflow basic functionality
538
    subtest 'two_phase_basic_functionality' => sub {
539
        plan tests => 4;
540
541
        my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
542
        my $account   = $patron->account;
543
544
        # Start two-phase cashup
545
        my $cashup_start = $register2->start_cashup( { manager_id => $manager->id } );
546
        ok( defined $cashup_start, 'Two-phase cashup can be started' );
547
548
        # Create transaction during session
549
        my $fine = $account->add_debit(
550
            {
551
                amount    => '15.00',
552
                type      => 'OVERDUE',
553
                interface => 'cron'
554
            }
555
        );
556
557
        my $payment = $account->pay(
558
            {
559
                cash_register => $register2->id,
560
                amount        => '15.00',
561
                credit_type   => 'PAYMENT',
562
                payment_type  => 'CASH',
563
                lines         => [$fine]
564
            }
565
        );
566
567
        # Complete two-phase cashup
568
        my $cashup_complete = $register2->add_cashup( { manager_id => $manager->id, amount => '15.00' } );
569
        ok( defined $cashup_complete, 'Two-phase cashup can be completed' );
570
571
        my $summary = $cashup_complete->summary;
572
        ok( defined $summary,          'Two-phase completed cashup has summary' );
573
        ok( defined $summary->{total}, 'Two-phase summary has total' );
574
    };
575
576
    # Test 3: Reconciliation functionality
577
    subtest 'reconciliation_functionality' => sub {
578
        plan tests => 2;
579
580
        my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
581
        my $account   = $patron->account;
582
583
        # Create transaction with surplus
584
        my $fine = $account->add_debit(
585
            {
586
                amount    => '20.00',
587
                type      => 'OVERDUE',
588
                interface => 'cron'
589
            }
590
        );
591
592
        my $payment = $account->pay(
593
            {
594
                cash_register => $register3->id,
595
                amount        => '20.00',
596
                credit_type   => 'PAYMENT',
597
                payment_type  => 'CASH',
598
                lines         => [$fine]
599
            }
600
        );
601
602
        # Cashup with surplus
603
        my $cashup = $register3->add_cashup(
604
            {
605
                manager_id => $manager->id,
606
                amount     => '25.00'         # Creates 5.00 surplus
607
            }
608
        );
609
610
        my $summary      = $cashup->summary;
611
        my $accountlines = $cashup->accountlines;
612
613
        ok( defined $summary, 'Cashup with reconciliation has summary' );
614
615
        # Check surplus reconciliation exists
616
        my $surplus_lines = $accountlines->search( { credit_type_code => 'CASHUP_SURPLUS' } );
617
        is( $surplus_lines->count, 1, 'Surplus reconciliation line is created and included' );
618
    };
619
620
    # Test 4: Edge cases
621
    subtest 'edge_cases' => sub {
622
        plan tests => 2;
623
624
        my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
625
626
        # Empty cashup
627
        my $empty_cashup = $register4->add_cashup( { manager_id => $manager->id, amount => '1.00' } );
628
        my $summary      = $empty_cashup->summary;
629
630
        ok( defined $summary,          'Empty cashup has summary' );
631
        ok( defined $summary->{total}, 'Empty cashup summary has total' );
632
    };
633
634
    $schema->storage->txn_rollback;
635
};
636
362
1;
637
1;
363
- 

Return to bug 40445