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 <http://www.gnu.org/licenses>.
16
# along with Koha; if not, see <http://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/pos/register.pl (-14 / +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-132 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
164
127
                # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
165
                    # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
128
                print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid );
166
                    print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid );
129
                exit;
167
                    exit;
168
                }
130
169
131
            } else {
170
            } else {
132
                $template->param( error_cashup_amount => 1 );
171
                $template->param( error_cashup_amount => 1 );
(-)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