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

(-)a/Koha/Cash/Register.pm (-65 / +315 lines)
Lines 16-21 package Koha::Cash::Register; Link Here
16
# along with Koha; if not, see <https://www.gnu.org/licenses>.
16
# along with Koha; if not, see <https://www.gnu.org/licenses>.
17
17
18
use Modern::Perl;
18
use Modern::Perl;
19
use DateTime;
20
use Scalar::Util qw( looks_like_number );
19
21
20
use Koha::Account;
22
use Koha::Account;
21
use Koha::Account::Lines;
23
use Koha::Account::Lines;
Lines 23-28 use Koha::Account::Offsets; Link Here
23
use Koha::Cash::Register::Actions;
25
use Koha::Cash::Register::Actions;
24
use Koha::Cash::Register::Cashups;
26
use Koha::Cash::Register::Cashups;
25
use Koha::Database;
27
use Koha::Database;
28
use Koha::DateUtils qw( dt_from_string );
26
29
27
use base qw(Koha::Object);
30
use base qw(Koha::Object);
28
31
Lines 113-147 Return a set of accountlines linked to this cash register since the last cashup Link Here
113
sub outstanding_accountlines {
116
sub outstanding_accountlines {
114
    my ( $self, $conditions, $attrs ) = @_;
117
    my ( $self, $conditions, $attrs ) = @_;
115
118
116
    my $since = $self->_result->search_related(
119
    # Find the start timestamp for the current "open" session
117
        'cash_register_actions',
120
    my $start_timestamp = $self->_get_session_start_timestamp;
118
        { 'code' => 'CASHUP' },
119
        {
120
            order_by => { '-desc' => [ 'timestamp', 'id' ] },
121
            rows     => 1
122
        }
123
    );
124
121
125
    my $local_conditions =
122
    my $local_conditions =
126
        $since->count
123
        $start_timestamp
127
        ? { 'date' => { '>' => $since->get_column('timestamp')->as_query } }
124
        ? { 'date' => { '>' => $start_timestamp } }
128
        : {};
125
        : {};
129
126
130
    # Exclude reconciliation accountlines from outstanding accountlines
131
    $local_conditions->{'-and'} = [
132
        {
133
            '-or' => [
134
                { 'credit_type_code' => { '!=' => 'CASHUP_SURPLUS' } },
135
                { 'credit_type_code' => undef }
136
            ]
137
        },
138
        {
139
            '-or' => [
140
                { 'debit_type_code' => { '!=' => 'CASHUP_DEFICIT' } },
141
                { 'debit_type_code' => undef }
142
            ]
143
        }
144
    ];
145
    my $merged_conditions =
127
    my $merged_conditions =
146
        $conditions
128
        $conditions
147
        ? { %{$conditions}, %{$local_conditions} }
129
        ? { %{$conditions}, %{$local_conditions} }
Lines 155-160 sub outstanding_accountlines { Link Here
155
    return Koha::Account::Lines->_new_from_dbic($rs);
137
    return Koha::Account::Lines->_new_from_dbic($rs);
156
}
138
}
157
139
140
=head3 cashup_in_progress
141
142
Check if there is currently a cashup in progress (CASHUP_START without corresponding CASHUP).
143
Returns the CASHUP_START action if in progress, undef otherwise.
144
145
=cut
146
147
sub cashup_in_progress {
148
    my ($self) = @_;
149
150
    my $last_start = $self->_result->search_related(
151
        'cash_register_actions',
152
        { 'code'   => 'CASHUP_START' },
153
        { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
154
    )->single;
155
156
    return unless $last_start;
157
158
    my $last_completion = $self->cashups(
159
        {},
160
        { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
161
    )->single;
162
163
    # If we have a start but no completion, or the start is more recent than completion
164
    if ( !$last_completion
165
        || DateTime->compare( dt_from_string( $last_start->timestamp ), dt_from_string( $last_completion->timestamp ) )
166
        > 0 )
167
    {
168
        return Koha::Cash::Register::Action->_new_from_dbic($last_start);
169
    }
170
171
    return;
172
}
173
158
=head3 store
174
=head3 store
159
175
160
Local store method to prevent direct manipulation of the 'branch_default' field
176
Local store method to prevent direct manipulation of the 'branch_default' field
Lines 221-226 sub drop_default { Link Here
221
    return $self;
237
    return $self;
222
}
238
}
223
239
240
=head3 start_cashup
241
242
    my $cashup_start = $cash_register->start_cashup(
243
        {
244
            manager_id => $logged_in_user->id,
245
        }
246
    );
247
248
Start a new cashup period. This marks the beginning of the cash counting process
249
and creates a snapshot point for calculating outstanding amounts. Returns the
250
CASHUP_START action.
251
252
=cut
253
254
sub start_cashup {
255
    my ( $self, $params ) = @_;
256
257
    # check for mandatory params
258
    my @mandatory = ('manager_id');
259
    for my $param (@mandatory) {
260
        unless ( defined( $params->{$param} ) ) {
261
            Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" );
262
        }
263
    }
264
    my $manager_id = $params->{manager_id};
265
266
    # Check if there's already a cashup in progress
267
    my $last_cashup_start_rs = $self->_result->search_related(
268
        'cash_register_actions',
269
        { 'code'   => 'CASHUP_START' },
270
        { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
271
    )->single;
272
273
    my $last_cashup_completed = $self->cashups(
274
        {},
275
        { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
276
    )->single;
277
278
    # If we have a CASHUP_START that's more recent than the last CASHUP, there's already an active cashup
279
    if (
280
        $last_cashup_start_rs
281
        && (
282
            !$last_cashup_completed || DateTime->compare(
283
                dt_from_string( $last_cashup_start_rs->timestamp ),
284
                dt_from_string( $last_cashup_completed->timestamp )
285
            ) > 0
286
        )
287
        )
288
    {
289
        Koha::Exceptions::Object::DuplicateID->throw( error => "A cashup is already in progress for this register" );
290
    }
291
292
    my $expected_amount = $self->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) * -1;
293
294
    # Prevent starting a cashup when there are no transactions at all
295
    my $total_transactions = $self->outstanding_accountlines->total() * -1;
296
    unless ( $total_transactions != 0 ) {
297
        Koha::Exceptions::Object::BadValue->throw(
298
            error => "Cannot start cashup with no transactions",
299
            type  => 'amount',
300
            value => $total_transactions
301
        );
302
    }
303
304
    # Create the CASHUP_START action using centralized exception handling
305
    my $schema = $self->_result->result_source->schema;
306
    my $rs     = $schema->safe_do(
307
        sub {
308
            return $self->_result->add_to_cash_register_actions(
309
                {
310
                    code       => 'CASHUP_START',
311
                    manager_id => $manager_id,
312
                    amount     => $expected_amount
313
                }
314
            )->discard_changes;
315
        }
316
    );
317
318
    return Koha::Cash::Register::Cashup->_new_from_dbic($rs);
319
}
320
224
=head3 add_cashup
321
=head3 add_cashup
225
322
226
    my $cashup = $cash_register->add_cashup(
323
    my $cashup = $cash_register->add_cashup(
Lines 231-263 sub drop_default { Link Here
231
        }
328
        }
232
    );
329
    );
233
330
234
Add a new cashup action to the till, returns the added action.
331
Complete a cashup period started with start_cashup(). This performs the actual
235
If amount differs from expected amount, creates surplus/deficit accountlines.
332
reconciliation against the amount counted and creates surplus/deficit accountlines
333
if needed. Returns the completed CASHUP action.
236
334
237
=cut
335
=cut
238
336
239
sub add_cashup {
337
sub add_cashup {
240
    my ( $self, $params ) = @_;
338
    my ( $self, $params ) = @_;
241
339
242
    my $manager_id          = $params->{manager_id};
340
    # check for mandatory params
243
    my $amount              = $params->{amount};
341
    my @mandatory = ( 'manager_id', 'amount' );
244
    my $reconciliation_note = $params->{reconciliation_note};
342
    for my $param (@mandatory) {
343
        unless ( defined( $params->{$param} ) ) {
344
            Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" );
345
        }
346
    }
347
    my $manager_id = $params->{manager_id};
348
349
    # Validate amount is a valid number
350
    my $amount = $params->{amount};
351
    unless ( looks_like_number($amount) ) {
352
        Koha::Exceptions::Account::AmountNotPositive->throw( error => 'Cashup amount must be a valid number' );
353
    }
245
354
246
    # Sanitize reconciliation note - treat empty/whitespace-only as undef
355
    # Sanitize reconciliation note - treat empty/whitespace-only as undef
356
    my $reconciliation_note = $params->{reconciliation_note};
247
    if ( defined $reconciliation_note ) {
357
    if ( defined $reconciliation_note ) {
248
        $reconciliation_note = substr( $reconciliation_note, 0, 1000 );    # Limit length
358
        $reconciliation_note = substr( $reconciliation_note, 0, 1000 );    # Limit length
249
        $reconciliation_note =~ s/^\s+|\s+$//g;                            # Trim whitespace
359
        $reconciliation_note =~ s/^\s+|\s+$//g;                            # Trim whitespace
250
        $reconciliation_note = undef if $reconciliation_note eq '';        # Empty after trim = undef
360
        $reconciliation_note = undef if $reconciliation_note eq '';        # Empty after trim = undef
251
    }
361
    }
252
362
253
    # Calculate expected amount from outstanding accountlines
363
    # Find the most recent CASHUP_START to determine if we're in two-phase mode
254
    my $expected_amount = $self->outstanding_accountlines->total;
364
    my $cashup_start;
365
    my $cashup_start_rs = $self->_result->search_related(
366
        'cash_register_actions',
367
        { 'code'   => 'CASHUP_START' },
368
        { order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
369
    )->single;
370
371
    if ($cashup_start_rs) {
372
373
        # Two-phase mode: Check if this CASHUP_START has already been completed
374
        my $existing_completion = $self->_result->search_related(
375
            'cash_register_actions',
376
            {
377
                'code'      => 'CASHUP',
378
                'timestamp' => { '>' => $cashup_start_rs->timestamp }
379
            },
380
            { rows => 1 }
381
        )->single;
382
383
        if ( !$existing_completion ) {
384
            $cashup_start = Koha::Cash::Register::Cashup->_new_from_dbic($cashup_start_rs);
385
        }
386
387
    }
255
388
256
    # For backward compatibility, if no actual amount is specified, use expected amount
389
    # Calculate expected amount from session accountlines
257
    $amount //= abs($expected_amount);
390
    my $expected_amount = (
391
          $cashup_start
392
        ? $cashup_start->accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } )
393
        : $self->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } )
394
    ) * -1;
258
395
259
    # Calculate difference (actual - expected)
396
    # Calculate difference (actual - expected)
260
    my $difference = $amount - abs($expected_amount);
397
    my $difference = $amount - $expected_amount;
398
399
    # Validate reconciliation note requirement if there's a discrepancy
400
    if ( $difference != 0 ) {
401
        my $note_required = C4::Context->preference('CashupReconciliationNoteRequired') // 0;
402
403
        if ( $note_required && !defined $reconciliation_note ) {
404
            Koha::Exceptions::MissingParameter->throw(
405
                error => "Reconciliation note is required when cashup amount differs from expected amount" );
406
        }
407
    }
261
408
262
    # Use database transaction to ensure consistency
409
    # Use database transaction to ensure consistency
263
    my $schema = $self->_result->result_source->schema;
410
    my $schema = $self->_result->result_source->schema;
Lines 265-300 sub add_cashup { Link Here
265
412
266
    $schema->txn_do(
413
    $schema->txn_do(
267
        sub {
414
        sub {
268
            # Create the cashup action with actual amount
415
            # Create the cashup action - safe_do handles exception translation
269
            my $rs = $self->_result->add_to_cash_register_actions(
416
            my $rs = $schema->safe_do(
270
                {
417
                sub {
271
                    code       => 'CASHUP',
418
                    return $self->_result->add_to_cash_register_actions(
272
                    manager_id => $manager_id,
419
                        {
273
                    amount     => $amount
420
                            code       => 'CASHUP',
421
                            manager_id => $manager_id,
422
                            amount     => $amount
423
                        }
424
                    )->discard_changes;
274
                }
425
                }
275
            )->discard_changes;
426
            );
276
277
            $cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs);
427
            $cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs);
278
428
279
            # Create reconciliation accountline if there's a difference
429
            # Create reconciliation accountline if there's a difference
280
            if ( $difference != 0 ) {
430
            if ( $difference != 0 ) {
281
431
432
                # Determine reconciliation date based on mode
433
                my $reconciliation_date;
434
                if ($cashup_start) {
435
436
                    # Two-phase mode: Backdate reconciliation lines to just before the CASHUP_START timestamp
437
                    # This ensures they belong to the previous session, not the current one
438
                    my $timestamp_str = "DATE_SUB('" . $cashup_start->timestamp . "', INTERVAL 1 SECOND)";
439
                    $reconciliation_date = \$timestamp_str;
440
                } else {
441
442
                    # Legacy mode: Use the original backdating approach
443
                    $reconciliation_date = \'DATE_SUB(NOW(), INTERVAL 1 SECOND)';
444
                }
445
282
                if ( $difference > 0 ) {
446
                if ( $difference > 0 ) {
283
447
284
                    # Surplus: more cash found than expected (credits are negative amounts)
448
                    # Surplus: more cash found than expected (credits are negative amounts)
285
                    my $surplus = Koha::Account::Line->new(
449
                    my $surplus = Koha::Account::Line->new(
286
                        {
450
                        {
287
                            date                => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
451
                            date              => $reconciliation_date,
288
                            amount              => -abs($difference),                             # Credits are negative
452
                            amount            => -abs($difference),      # Credits are negative
289
                            amountoutstanding   => 0,
453
                            amountoutstanding => 0,
290
                            description         => 'Cash register surplus found during cashup',
454
                            credit_type_code  => 'CASHUP_SURPLUS',
291
                            credit_type_code    => 'CASHUP_SURPLUS',
455
                            manager_id        => $manager_id,
292
                            payment_type        => 'CASH',
456
                            interface         => 'intranet',
293
                            manager_id          => $manager_id,
457
                            branchcode        => $self->branch,
294
                            interface           => 'intranet',
458
                            register_id       => $self->id,
295
                            branchcode          => $self->branch,
459
                            payment_type      => 'CASH',
296
                            register_id         => $self->id,
460
                            note              => $reconciliation_note
297
                            note                => $reconciliation_note
298
                        }
461
                        }
299
                    )->store();
462
                    )->store();
300
463
Lines 312-328 sub add_cashup { Link Here
312
                    # Deficit: less cash found than expected
475
                    # Deficit: less cash found than expected
313
                    my $deficit = Koha::Account::Line->new(
476
                    my $deficit = Koha::Account::Line->new(
314
                        {
477
                        {
315
                            date                => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
478
                            date              => $reconciliation_date,
316
                            amount              => abs($difference),
479
                            amount            => abs($difference),
317
                            amountoutstanding   => 0,
480
                            amountoutstanding => 0,
318
                            description         => 'Cash register deficit found during cashup',
481
                            debit_type_code   => 'CASHUP_DEFICIT',
319
                            debit_type_code     => 'CASHUP_DEFICIT',
482
                            manager_id        => $manager_id,
320
                            payment_type        => 'CASH',
483
                            interface         => 'intranet',
321
                            manager_id          => $manager_id,
484
                            branchcode        => $self->branch,
322
                            interface           => 'intranet',
485
                            register_id       => $self->id,
323
                            branchcode          => $self->branch,
486
                            payment_type      => 'CASH',
324
                            register_id         => $self->id,
487
                            note              => $reconciliation_note
325
                            note                => $reconciliation_note
326
                        }
488
                        }
327
                    )->store();
489
                    )->store();
328
                    my $account_offset = Koha::Account::Offset->new(
490
                    my $account_offset = Koha::Account::Offset->new(
Lines 341-346 sub add_cashup { Link Here
341
    return $cashup;
503
    return $cashup;
342
}
504
}
343
505
506
=head3 _get_session_start_timestamp
507
508
Internal method to determine the start timestamp for the current "open" session.
509
This handles the following cashup scenarios:
510
511
=over 4
512
513
=item 1. No cashups ever → undef (returns all accountlines)
514
515
=item 2. Quick cashup completed → Uses CASHUP timestamp
516
517
=item 3. Two-phase started → Uses CASHUP_START timestamp
518
519
=item 4. Two-phase completed → Uses the CASHUP_START timestamp that led to the last CASHUP
520
521
=item 5. Mixed workflows → Correctly distinguishes between quick and two-phase cashups
522
523
=back
524
525
=cut
526
527
sub _get_session_start_timestamp {
528
    my ($self) = @_;
529
530
    # Check if there's a cashup in progress (CASHUP_START without corresponding CASHUP)
531
    my $cashup_in_progress = $self->cashup_in_progress;
532
533
    if ($cashup_in_progress) {
534
535
        # Scenario 3: Two-phase cashup started - return accountlines since CASHUP_START
536
        return $cashup_in_progress->timestamp;
537
    }
538
539
    # No cashup in progress - find the most recent cashup completion
540
    my $last_cashup = $self->cashups(
541
        {},
542
        {
543
            order_by => { '-desc' => [ 'timestamp', 'id' ] },
544
            rows     => 1
545
        }
546
    )->single;
547
548
    if ( !$last_cashup ) {
549
550
        # Scenario 1: No cashups have ever taken place - return all accountlines
551
        return;
552
    }
553
554
    # Find if this CASHUP was part of a two-phase workflow
555
    my $corresponding_start = $self->_result->search_related(
556
        'cash_register_actions',
557
        {
558
            'code'      => 'CASHUP_START',
559
            'timestamp' => { '<' => $last_cashup->timestamp }
560
        },
561
        {
562
            order_by => { '-desc' => [ 'timestamp', 'id' ] },
563
            rows     => 1
564
        }
565
    )->single;
566
567
    if ($corresponding_start) {
568
569
        # Check if this CASHUP_START was completed by this CASHUP
570
        # (no other CASHUP between them)
571
        my $intervening_cashup = $self->_result->search_related(
572
            'cash_register_actions',
573
            {
574
                'code'      => 'CASHUP',
575
                'timestamp' => {
576
                    '>' => $corresponding_start->timestamp,
577
                    '<' => $last_cashup->timestamp
578
                }
579
            },
580
            { rows => 1 }
581
        )->single;
582
583
        if ( !$intervening_cashup ) {
584
585
            # Scenario 4: Two-phase cashup completed - return accountlines since the CASHUP_START
586
            return $corresponding_start->timestamp;
587
        }
588
    }
589
590
    # Scenarios 2 & 5: Quick cashup (or orphaned CASHUP) - return accountlines since CASHUP
591
    return $last_cashup->timestamp;
592
}
593
344
=head3 to_api_mapping
594
=head3 to_api_mapping
345
595
346
This method returns the mapping for representing a Koha::Cash::Register object
596
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 198-205 sub summary { Link Here
198
    my $deficit_note = $deficit_record ? $deficit_record->note : undef;
204
    my $deficit_note = $deficit_record ? $deficit_record->note : undef;
199
205
200
    $summary = {
206
    $summary = {
201
        from_date      => $previous ? $previous->timestamp : undef,
207
        from_date      => $session_start,
202
        to_date        => $self->timestamp,
208
        to_date        => $session_end,
203
        income_grouped => \@income,
209
        income_grouped => \@income,
204
        income_total   => abs($income_total),
210
        income_total   => abs($income_total),
205
        payout_grouped => \@payout,
211
        payout_grouped => \@payout,
Lines 217-222 sub summary { Link Here
217
    return $summary;
223
    return $summary;
218
}
224
}
219
225
226
=head3 accountlines
227
228
Fetch the accountlines associated with this cashup
229
230
=cut
231
232
sub accountlines {
233
    my ($self) = @_;
234
235
    # Get the session boundaries for this cashup
236
    my ( $session_start, $session_end ) = $self->_get_session_boundaries;
237
238
    my $conditions;
239
    if ( $session_start && $session_end ) {
240
241
        # Complete session: between start and end (exclusive)
242
        $conditions = {
243
            'date' => {
244
                '>' => $session_start,
245
                '<' => $session_end
246
            }
247
        };
248
    } elsif ($session_end) {
249
250
        # Session from beginning to end
251
        $conditions = { 'date' => { '<' => $session_end } };
252
    } else {
253
254
        # Shouldn't happen for a completed cashup, but fallback
255
        $conditions = { 'date' => { '<' => $self->timestamp } };
256
    }
257
258
    return $self->register->accountlines->search($conditions);
259
}
260
261
=head3 _get_session_boundaries
262
263
Internal method to determine the session boundaries for this cashup.
264
Returns ($session_start, $session_end) timestamps.
265
266
=cut
267
268
sub _get_session_boundaries {
269
    my ($self) = @_;
270
271
    my $session_end = $self->_get_session_end;
272
273
    # Find the previous CASHUP
274
    my $session_start;
275
    my $previous_cashup = $self->register->cashups(
276
        { 'timestamp' => { '<' => $session_end } },
277
        {
278
            order_by => { '-desc' => [ 'timestamp', 'id' ] },
279
            rows     => 1
280
        }
281
    )->single;
282
283
    $session_start = $previous_cashup ? $previous_cashup->_get_session_end : undef;
284
285
    return ( $session_start, $session_end );
286
}
287
288
sub _get_session_end {
289
    my ($self) = @_;
290
291
    my $session_end = $self->timestamp;
292
293
    # Find if this CASHUP was part of a two-phase workflow
294
    my $nearest_start = $self->register->_result->search_related(
295
        'cash_register_actions',
296
        {
297
            'code'      => 'CASHUP_START',
298
            'timestamp' => { '<' => $session_end }
299
        },
300
        {
301
            order_by => { '-desc' => [ 'timestamp', 'id' ] },
302
            rows     => 1
303
        }
304
    )->single;
305
306
    if ($nearest_start) {
307
308
        # Check if this CASHUP_START was completed by this CASHUP
309
        # (no other CASHUP between them)
310
        my $intervening_cashup = $self->register->cashups(
311
            {
312
                'timestamp' => {
313
                    '>' => $nearest_start->timestamp,
314
                    '<' => $session_end
315
                }
316
            },
317
            { rows => 1 }
318
        )->single;
319
320
        if ( !$intervening_cashup ) {
321
322
            # Two-phase workflow: session runs to CASHUP_START
323
            $session_end = $nearest_start->timestamp;
324
        }
325
    }
326
327
    return $session_end;
328
}
329
220
=head3 to_api_mapping
330
=head3 to_api_mapping
221
331
222
This method returns the mapping for representing a Koha::Cash::Register::Cashup object
332
This method returns the mapping for representing a Koha::Cash::Register::Cashup object
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/confirm_cashup.inc (+106 lines)
Line 0 Link Here
1
[% USE raw %]
2
<!-- Confirm cashup modal -->
3
<!-- Parameters:
4
     - modal_id: ID for the modal (required)
5
     - reconciliation_note_avs: Authorized values for notes (optional)
6
     - reconciliation_note_required: Whether note is required (optional)
7
     - cashup_in_progress: Whether completing in-progress cashup (optional)
8
     - form_action: Form action URL (optional, defaults to current page)
9
     - redirect_to: Where to redirect after completion (optional: 'register' or 'registers')
10
-->
11
<div class="modal" id="[% modal_id | html %]" tabindex="-1" role="dialog" aria-labelledby="[% modal_id | html %]Label">
12
    <form method="post" enctype="multipart/form-data" class="validated confirm-cashup-form" [% IF form_action %]action="[% form_action | html %]"[% END %]>
13
        [% INCLUDE 'csrf-token.inc' %]
14
        <div class="modal-dialog">
15
            <div class="modal-content">
16
                <div class="modal-header">
17
                    <h1 class="modal-title" id="[% modal_id | html %]Label">
18
                        [% IF cashup_in_progress %]
19
                            Complete cashup of <em><span class="register-name">[% register.description | html %]</span></em>
20
                        [% ELSE %]
21
                            Confirm cashup of <em><span class="register-name"></span></em>
22
                        [% END %]
23
                    </h1>
24
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
25
                </div>
26
                <div class="modal-body">
27
                    <fieldset class="rows">
28
                        <ol>
29
                            <li>
30
                                <span class="label expected-amount-label">
31
                                    [% IF cashup_in_progress %]
32
                                        [% IF cashup_in_progress.amount < 0 %]
33
                                            Expected amount to add:
34
                                        [% ELSE %]
35
                                            Expected cashup amount:
36
                                        [% END %]
37
                                    [% ELSE %]
38
                                        Expected cashup amount:
39
                                    [% END %]
40
                                </span>
41
                                <span class="expected-amount">[% IF cashup_in_progress %][% cashup_in_progress.amount | $Price %][% END %]</span>
42
                            </li>
43
                            <li>
44
                                <label class="required actual-amount-label" for="cashup_amount">
45
                                    [% IF cashup_in_progress %]
46
                                        [% IF cashup_in_progress.amount < 0 %]
47
                                            Actual amount added to register:
48
                                        [% ELSE %]
49
                                            Actual cashup amount counted:
50
                                        [% END %]
51
                                    [% ELSE %]
52
                                        Actual cashup amount counted:
53
                                    [% END %]
54
                                </label>
55
                                <input type="text" inputmode="decimal" pattern="^-?\d+(\.\d{2})?$" id="cashup_amount" name="amount" class="cashup-amount-input" required="required" />
56
                                <span class="required">Required</span>
57
                            </li>
58
                            <li class="reconciliation-display" style="display: none;">
59
                                <span class="label">Reconciliation:</span>
60
                                <span class="reconciliation-text"></span>
61
                            </li>
62
                            <li class="reconciliation-note-field" style="display: none;">
63
                                <label class="reconciliation-note-label" for="cashup_reconciliation_note"> Note[% IF reconciliation_note_required %](required)[% ELSE %](optional)[% END %]: </label>
64
                                [% IF reconciliation_note_avs %]
65
                                    <select id="cashup_reconciliation_note" class="reconciliation-note-input" name="reconciliation_note">
66
                                        <option value="">-- Select a reason --</option>
67
                                        [% FOREACH av IN reconciliation_note_avs %]
68
                                            <option value="[% av.authorised_value | html %]">[% av.lib | html %]</option>
69
                                        [% END %]
70
                                    </select>
71
                                    [% IF reconciliation_note_required %]
72
                                        <span class="required">Required</span>
73
                                    [% END %]
74
                                [% ELSE %]
75
                                    <textarea
76
                                        id="cashup_reconciliation_note"
77
                                        class="reconciliation-note-input"
78
                                        name="reconciliation_note"
79
                                        rows="3"
80
                                        cols="40"
81
                                        maxlength="1000"
82
                                        placeholder="Enter a note explaining the surplus or deficit..."
83
                                    ></textarea>
84
                                    [% IF reconciliation_note_required %]
85
                                        <span class="required">Required</span>
86
                                    [% END %]
87
                                    <div class="hint">Maximum 1000 characters</div>
88
                                [% END %]
89
                            </li>
90
                        </ol>
91
                    </fieldset>
92
                </div>
93
                <div class="modal-footer">
94
                    <input type="hidden" name="registerid" class="register-id-field" value="[% IF cashup_in_progress %][% register.id | html %][% END %]" />
95
                    <input type="hidden" name="op" value="cud-cashup" />
96
                    [% IF redirect_to %]
97
                        <input type="hidden" name="redirect_to" value="[% redirect_to | html %]" />
98
                    [% END %]
99
                    <button type="submit" class="btn btn-primary"> [% IF cashup_in_progress %]Complete cashup[% ELSE %]Confirm cashup[% END %] </button>
100
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
101
                </div>
102
            </div>
103
        </div>
104
    </form>
105
</div>
106
<!-- /#[% modal_id | html %] -->
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/trigger_cashup.inc (+50 lines)
Line 0 Link Here
1
[% USE raw %]
2
<!-- Trigger cashup modal -->
3
<!-- Parameters:
4
     - modal_id: ID for the modal (required)
5
     - register_description: Register description to pre-populate (optional, for register.tt)
6
     - register_id: Register ID to pre-populate (optional, for register.tt)
7
     - form_action: Form action URL (optional, defaults to current page)
8
     - redirect_to: Where to redirect after completion (optional: 'register' or 'registers')
9
-->
10
<div class="modal" id="[% modal_id | html %]" tabindex="-1" role="dialog" aria-labelledby="[% modal_id | html %]Label">
11
    <form method="post" class="validated trigger-cashup-form" [% IF form_action %]action="[% form_action | html %]"[% END %]>
12
        [% INCLUDE 'csrf-token.inc' %]
13
        <div class="modal-dialog">
14
            <div class="modal-content">
15
                <div class="modal-header">
16
                    <h1 class="modal-title" id="[% modal_id | html %]Label">
17
                        Cashup for <em><span class="register-description">[% IF register_description %][% register_description | html %][% END %]</span></em>
18
                    </h1>
19
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
20
                </div>
21
                <div class="modal-body">
22
                    <p><strong>Choose how to proceed with the cashup:</strong></p>
23
                    <p><strong>Start cashup</strong></p>
24
                    <ul class="start-cashup-instructions">
25
                        <!-- JavaScript will populate this based on positive/negative amount -->
26
                    </ul>
27
                    <p><strong>Quick cashup</strong></p>
28
                    <ul class="quick-cashup-instructions">
29
                        <!-- JavaScript will populate this based on positive/negative amount -->
30
                    </ul>
31
                    <p class="float-reminder-text">
32
                        <!-- JavaScript will populate this based on positive/negative amount -->
33
                    </p>
34
                </div>
35
                <div class="modal-footer">
36
                    <input type="hidden" name="registerid" class="register-id-field" value="[% IF register_id %][% register_id | html %][% END %]" />
37
                    <input type="hidden" name="op" value="cud-cashup_start" />
38
                    <input type="hidden" name="amount" value="" />
39
                    [% IF redirect_to %]
40
                        <input type="hidden" name="redirect_to" value="[% redirect_to | html %]" />
41
                    [% END %]
42
                    <button type="submit" class="btn btn-primary">Start cashup</button>
43
                    <button type="button" class="btn btn-success quick-cashup-btn">Quick cashup</button>
44
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
45
                </div>
46
            </div>
47
        </div>
48
    </form>
49
</div>
50
<!-- /#[% modal_id | html %] -->
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/pos/register.tt (-105 / +73 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_cashup_no_transactions ) %]
60
            <div id="error_message" class="alert alert-info"> Cannot start cashup - there are no transactions in this register since the last cashup. </div>
61
        [% END %]
62
63
        [% IF ( error_no_cashup_start ) %]
64
            <div id="error_message" class="alert alert-warning"> No cashup session has been started. Please start a cashup before attempting to complete it. </div>
65
        [% END %]
66
67
        [% IF ( error_cashup_already_completed ) %]
68
            <div id="error_message" class="alert alert-warning"> This cashup session has already been completed. </div>
69
        [% END %]
70
71
        [% IF ( error_cashup_start ) %]
72
            <div id="error_message" class="alert alert-warning"> Failed to start cashup. Please try again. </div>
73
        [% END %]
74
75
        [% IF ( error_cashup_missing_param ) %]
76
            <div id="error_message" class="alert alert-warning"> Missing required parameter for cashup: [% error_message | html %] </div>
77
        [% END %]
78
79
        [% IF ( error_cashup_amount_invalid ) %]
80
            <div id="error_message" class="alert alert-warning"> The cashup amount must be a valid number. </div>
81
        [% END %]
82
83
        [% IF ( error_reconciliation_note_required ) %]
84
            <div id="error_message" class="alert alert-warning"> Reconciliation note is required when cashup amount differs from expected amount. </div>
85
        [% END %]
86
87
        [% IF ( error_cashup_complete ) %]
88
            <div id="error_message" class="alert alert-warning">
89
                Failed to complete cashup. Please try again.
90
                [% IF error_details %]
91
                    <br /><strong>Error details:</strong> [% error_details | html %]
92
                [% END %]
93
            </div>
94
        [% END %]
95
55
        [% IF ( error_refund_permission ) %]
96
        [% IF ( error_refund_permission ) %]
56
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform refund actions. </div>
97
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform refund actions. </div>
57
        [% END %]
98
        [% END %]
58
99
100
        [% IF cashup_in_progress %]
101
            <div class="alert alert-warning">
102
                <i class="fa-solid fa-info-circle"></i>
103
                [% SET progress_timestamp = cashup_in_progress.timestamp | $KohaDates(with_hours => 1) %]
104
                [% tx("Cashup in progress - started {timestamp}. You can continue to make transactions while counting cash.", { timestamp = progress_timestamp }) | html %]
105
                (<a data-bs-toggle="modal" data-cashup="[% cashup_in_progress.id | html %]" data-register="[% register.description | html %]" data-in-progress="true" href="#cashupSummaryModal" class="button"
106
                    >[% t("Preview cashup summary") | html %]</a
107
                >)
108
            </div>
109
        [% END %]
110
111
        [% SET total_bankable = accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 %]
112
        [% SET total_transactions = accountlines.total() * -1 %]
59
        [% IF ( CAN_user_cash_management_cashup ) %]
113
        [% IF ( CAN_user_cash_management_cashup ) %]
60
            <div id="toolbar" class="btn-toolbar">
114
            <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>
115
                [% IF cashup_in_progress %]
116
                    <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>
117
                [% ELSE %]
118
                    <button type="button" class="btn btn-default" data-bs-toggle="modal" data-bs-target="#triggerCashupModal" [% IF total_transactions == 0 %]disabled title="No transactions"[% END %]>
119
                        <i class="fa fa-money-bill-alt"></i> Record cashup
120
                    </button>
121
                [% END %]
62
            </div>
122
            </div>
63
        [% END %]
123
        [% END %]
64
124
Lines 81-87 Link Here
81
            <li>Float: [% register.starting_float | $Price %]</li>
141
            <li>Float: [% register.starting_float | $Price %]</li>
82
            <li>Total income (cash): [% accountlines.credits_total * -1 | $Price %] ([% accountlines.credits_total(payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %])</li>
142
            <li>Total income (cash): [% accountlines.credits_total * -1 | $Price %] ([% accountlines.credits_total(payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %])</li>
83
            <li>Total outgoing (cash): [% accountlines.debits_total * -1 | $Price %] ([% accountlines.debits_total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %])</li>
143
            <li>Total outgoing (cash): [% accountlines.debits_total * -1 | $Price %] ([% accountlines.debits_total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %])</li>
84
            <li>Total bankable: [% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %]</li>
144
            <li>Total bankable: [% total_bankable | $Price %]</li>
85
        </ul>
145
        </ul>
86
146
87
        [% IF register.last_cashup %]
147
        [% IF register.last_cashup %]
Lines 368-427 Link Here
368
    [% END %]
428
    [% END %]
369
[% END %]
429
[% END %]
370
430
371
<!-- Confirm cashup modal -->
372
<div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel">
373
    <form id="cashup_form" method="post" enctype="multipart/form-data" class="validated">
374
        [% INCLUDE 'csrf-token.inc' %]
375
        <div class="modal-dialog">
376
            <div class="modal-content">
377
                <div class="modal-header">
378
                    <h1 class="modal-title" id="confirmCashupLabel">Confirm cashup of <em>[% register.description | html %]</em></h1>
379
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
380
                </div>
381
                <div class="modal-body">
382
                    <fieldset class="rows">
383
                        <ol>
384
                            <li>
385
                                <span class="label">Expected amount to remove:</span>
386
                                <span id="expected_amount" class="expected-amount">[% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %]</span>
387
                            </li>
388
                            <li>
389
                                <span class="label">Float to remain:</span>
390
                                <span>[% register.starting_float | $Price %]</span>
391
                            </li>
392
                            <li>
393
                                <label class="required" for="amount">Actual amount removed from register:</label>
394
                                <input type="text" inputmode="decimal" pattern="^\d+(\.\d{2})?$" id="amount" name="amount" required="required" />
395
                                <span class="required">Required</span>
396
                            </li>
397
                            <li id="reconciliation_display" style="display: none;">
398
                                <span class="label">Reconciliation:</span>
399
                                <span id="reconciliation_text"></span>
400
                            </li>
401
                            <li id="reconciliation_note_field" style="display: none;">
402
                                <label for="reconciliation_note">Note (optional):</label>
403
                                <textarea id="reconciliation_note" name="reconciliation_note" rows="3" cols="40" maxlength="1000" placeholder="Enter a note explaining the surplus or deficit..."></textarea>
404
                                <div class="hint">Maximum 1000 characters</div>
405
                            </li>
406
                        </ol>
407
                    </fieldset>
408
                </div>
409
                <!-- /.modal-body -->
410
                <div class="modal-footer">
411
                    <input type="hidden" name="registerid" value="[% register.id | html %]" />
412
                    <input type="hidden" name="op" value="cud-cashup" />
413
                    <button type="submit" class="btn btn-primary" id="pos_cashup_confirm">Confirm cashup</button>
414
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
415
                </div>
416
                <!-- /.modal-footer -->
417
            </div>
418
            <!-- /.modal-content -->
419
        </div>
420
        <!-- /.modal-dialog -->
421
    </form>
422
</div>
423
<!-- /#confirmCashupModal -->
424
425
<!-- Issue refund modal -->
431
<!-- Issue refund modal -->
426
<div class="modal" id="issueRefundModal" tabindex="-1" role="dialog" aria-labelledby="issueRefundLabel">
432
<div class="modal" id="issueRefundModal" tabindex="-1" role="dialog" aria-labelledby="issueRefundLabel">
427
    <form id="refund_form" method="post" enctype="multipart/form-data" class="validated">
433
    <form id="refund_form" method="post" enctype="multipart/form-data" class="validated">
Lines 466-477 Link Here
466
<!-- /#issueRefundModal -->
472
<!-- /#issueRefundModal -->
467
473
468
[% INCLUDE 'modals/cashup_summary.inc' %]
474
[% INCLUDE 'modals/cashup_summary.inc' %]
475
[% INCLUDE 'modals/trigger_cashup.inc' modal_id='triggerCashupModal' register_description=register.description register_id=register.id %]
476
[% INCLUDE 'modals/confirm_cashup.inc' modal_id='confirmCashupModal' reconciliation_note_avs=reconciliation_note_avs reconciliation_note_required=reconciliation_note_required cashup_in_progress=cashup_in_progress %]
469
477
470
[% MACRO jsinclude BLOCK %]
478
[% MACRO jsinclude BLOCK %]
471
    [% INCLUDE 'datatables.inc' %]
479
    [% INCLUDE 'datatables.inc' %]
472
    [% INCLUDE 'format_price.inc' %]
480
    [% INCLUDE 'format_price.inc' %]
473
    [% INCLUDE 'js-date-format.inc' %]
481
    [% INCLUDE 'js-date-format.inc' %]
474
    [% Asset.js("js/cashup_modal.js") | $raw %]
482
    [% Asset.js("js/cashup_modal.js") | $raw %]
483
    [% Asset.js("js/modals/cashup_modals.js") | $raw %]
475
    [% Asset.js("js/modal_printer.js") | $raw %]
484
    [% Asset.js("js/modal_printer.js") | $raw %]
476
    [% INCLUDE 'calendar.inc' %]
485
    [% INCLUDE 'calendar.inc' %]
477
    <script>
486
    <script>
Lines 617-673 Link Here
617
            }
626
            }
618
        });
627
        });
619
628
620
        // Real-time reconciliation calculation for cashup modal
629
        // Initialize cashup modals
621
        $("#amount").on("input", function() {
630
        initTriggerCashupModal('#triggerCashupModal', {
622
            var actualAmount = parseFloat($(this).val()) || 0;
631
            bankableAmount: [% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | html %],
623
            var expectedText = $("#expected_amount").text().replace(/[£$,]/g, '');
632
            registerFloat: [% register.starting_float | html %]
624
            var expectedAmount = parseFloat(expectedText) || 0;
625
            var difference = actualAmount - expectedAmount;
626
627
            if ($(this).val() && !isNaN(actualAmount)) {
628
                var reconciliationText = "";
629
                var reconciliationClass = "";
630
                var hasDiscrepancy = false;
631
632
                if (difference > 0) {
633
                    reconciliationText = "Surplus: " + difference.format_price();
634
                    reconciliationClass = "success";
635
                    hasDiscrepancy = true;
636
                } else if (difference < 0) {
637
                    reconciliationText = "Deficit: " + Math.abs(difference).format_price();
638
                    reconciliationClass = "warning";
639
                    hasDiscrepancy = true;
640
                } else {
641
                    reconciliationText = "Balanced - no surplus or deficit";
642
                    reconciliationClass = "success";
643
                    hasDiscrepancy = false;
644
                }
645
646
                $("#reconciliation_text").text(reconciliationText)
647
                    .removeClass("success warning")
648
                    .addClass(reconciliationClass);
649
                $("#reconciliation_display").show();
650
651
                // Show/hide note field based on whether there's a discrepancy
652
                if (hasDiscrepancy) {
653
                    $("#reconciliation_note_field").show();
654
                } else {
655
                    $("#reconciliation_note_field").hide();
656
                    $("#reconciliation_note").val(''); // Clear note when balanced
657
                }
658
            } else {
659
                $("#reconciliation_display").hide();
660
                $("#reconciliation_note_field").hide();
661
            }
662
        });
633
        });
663
634
664
        // Reset modal when opened
635
        initConfirmCashupModal('#confirmCashupModal', {
665
        $("#confirmCashupModal").on("shown.bs.modal", function() {
636
            hasAuthorisedValues: [% reconciliation_note_avs ? 'true' : 'false' | html %],
666
            // Start with empty actual amount field (user must enter amount)
637
            noteRequired: [% reconciliation_note_required ? 'true' : 'false' | html %],
667
            $("#amount").val('').focus();
638
            isInProgress: [% cashup_in_progress ? 'true' : 'false' | html %]
668
            $("#reconciliation_display").hide();
669
            $("#reconciliation_note_field").hide();
670
            $("#reconciliation_note").val('');
671
        });
639
        });
672
    </script>
640
    </script>
673
[% END %]
641
[% END %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/pos/registers.tt (-42 / +265 lines)
Lines 45-53 Link Here
45
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform cashup actions. </div>
45
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform cashup actions. </div>
46
        [% END %]
46
        [% END %]
47
47
48
        [% IF CAN_user_cash_management_cashup %]
48
        [% IF ( error_cashup_start && cashup_errors ) %]
49
            <div id="toolbar" class="btn-toolbar">
49
            <div class="alert alert-warning">
50
                <button type="button" class="cashup_all btn btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupAllModal"><i class="fa-solid fa-money-bill-1"></i> Cashup all</button>
50
                <strong>Some registers failed to start cashup:</strong>
51
                <ul>
52
                    [% FOREACH error IN cashup_errors %]
53
                        <li>[% error | html %]</li>
54
                    [% END %]
55
                </ul>
56
            </div>
57
        [% END %]
58
59
        [% IF ( error_cashup_complete && cashup_errors ) %]
60
            <div class="alert alert-warning">
61
                <strong>Some registers failed to complete cashup:</strong>
62
                <ul>
63
                    [% FOREACH error IN cashup_errors %]
64
                        <li>[% error | html %]</li>
65
                    [% END %]
66
                </ul>
67
            </div>
68
        [% END %]
69
70
        [% IF ( cashup_start_success ) %]
71
            <div class="alert alert-success">
72
                <i class="fa fa-check"></i>
73
                [% IF cashup_start_success == 1 %]
74
                    Successfully started cashup for 1 register.
75
                [% ELSE %]
76
                    Successfully started cashup for [% cashup_start_success | html %] registers.
77
                [% END %]
78
                [% IF ( cashup_start_errors ) %]
79
                    [% IF cashup_start_errors == 1 %]
80
                        However, 1 register had errors.
81
                    [% ELSE %]
82
                        However, [% cashup_start_errors | html %] registers had errors.
83
                    [% END %]
84
                [% END %]
85
            </div>
86
        [% END %]
87
88
        [% IF ( cashup_complete_success ) %]
89
            <div class="alert alert-success">
90
                <i class="fa fa-check"></i>
91
                [% IF cashup_complete_success == 1 %]
92
                    Successfully completed cashup for 1 register.
93
                [% ELSE %]
94
                    Successfully completed cashup for [% cashup_complete_success | html %] registers.
95
                [% END %]
96
                [% IF ( cashup_complete_errors ) %]
97
                    [% IF cashup_complete_errors == 1 %]
98
                        However, 1 register had errors.
99
                    [% ELSE %]
100
                        However, [% cashup_complete_errors | html %] registers had errors.
101
                    [% END %]
102
                [% END %]
103
            </div>
104
        [% END %]
105
106
        [% IF ( cashup_start_errors && !cashup_start_success ) %]
107
            <div class="alert alert-warning">
108
                <i class="fa fa-exclamation-triangle"></i>
109
                [% IF cashup_start_errors == 1 %]
110
                    Failed to start cashup for 1 register.
111
                [% ELSE %]
112
                    Failed to start cashup for [% cashup_start_errors | html %] registers.
113
                [% END %]
114
            </div>
115
        [% END %]
116
117
        [% IF ( cashup_complete_errors && !cashup_complete_success ) %]
118
            <div class="alert alert-warning">
119
                <i class="fa fa-exclamation-triangle"></i>
120
                [% IF cashup_complete_errors == 1 %]
121
                    Failed to complete cashup for 1 register.
122
                [% ELSE %]
123
                    Failed to complete cashup for [% cashup_complete_errors | html %] registers.
124
                [% END %]
51
            </div>
125
            </div>
52
        [% END %]
126
        [% END %]
53
127
Lines 64-69 Link Here
64
            <table id="registers" class="table_registers">
138
            <table id="registers" class="table_registers">
65
                <thead>
139
                <thead>
66
                    <tr>
140
                    <tr>
141
                        [% IF CAN_user_cash_management_cashup %]
142
                            <th class="no-export"><input type="checkbox" id="select_all_registers" title="Select all available registers" /></th>
143
                        [% END %]
67
                        <th>Register name</th>
144
                        <th>Register name</th>
68
                        <th>Register description</th>
145
                        <th>Register description</th>
69
                        <th>Last cashup</th>
146
                        <th>Last cashup</th>
Lines 79-85 Link Here
79
                <tbody>
156
                <tbody>
80
                    [% SET bankable = 0, ctotal = 0, dtotal = 0, cctotal = 0, cdtotal = 0 %]
157
                    [% SET bankable = 0, ctotal = 0, dtotal = 0, cctotal = 0, cdtotal = 0 %]
81
                    [% FOREACH register IN registers %]
158
                    [% FOREACH register IN registers %]
159
                        [% SET rbankable = ( register.outstanding_accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 ) %]
160
                        [% SET rtotal = ( register.outstanding_accountlines.total() * -1 ) %]
82
                        <tr>
161
                        <tr>
162
                            [% IF CAN_user_cash_management_cashup %]
163
                                <td>
164
                                    [% IF register.cashup_in_progress %]
165
                                        <input type="checkbox" class="register_checkbox" value="[% register.id | html %]" disabled title="Cashup in progress" />
166
                                    [% ELSIF rtotal == 0 %]
167
                                        <input type="checkbox" class="register_checkbox" value="[% register.id | html %]" disabled title="No transactions" />
168
                                    [% ELSE %]
169
                                        <input type="checkbox" class="register_checkbox" value="[% register.id | html %]" />
170
                                    [% END %]
171
                                </td>
172
                            [% END %]
83
                            <td><a href="/cgi-bin/koha/pos/register.pl?registerid=[% register.id | uri %]">[% register.name | html %]</a></td>
173
                            <td><a href="/cgi-bin/koha/pos/register.pl?registerid=[% register.id | uri %]">[% register.name | html %]</a></td>
84
                            <td>[% register.description | html %]</td>
174
                            <td>[% register.description | html %]</td>
85
                            <td>
175
                            <td>
Lines 92-98 Link Here
92
                            </td>
182
                            </td>
93
                            <td>[% register.starting_float | $Price %]</td>
183
                            <td>[% register.starting_float | $Price %]</td>
94
                            <td>
184
                            <td>
95
                                [% SET rbankable = ( register.outstanding_accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 ) %]
96
                                [% SET bankable = bankable + rbankable %]
185
                                [% SET bankable = bankable + rbankable %]
97
                                [% rbankable | $Price %]
186
                                [% rbankable | $Price %]
98
                            </td>
187
                            </td>
Lines 112-128 Link Here
112
                            </td>
201
                            </td>
113
                            [% IF CAN_user_cash_management_cashup %]
202
                            [% IF CAN_user_cash_management_cashup %]
114
                                <td>
203
                                <td>
115
                                    <button
204
                                    [% IF register.cashup_in_progress %]
116
                                        type="button"
205
                                        <button
117
                                        class="cashup_individual btn btn-xs btn-default"
206
                                            type="button"
118
                                        data-bs-toggle="modal"
207
                                            class="btn btn-xs btn-primary pos_complete_cashup"
119
                                        data-bs-target="#confirmCashupModal"
208
                                            data-bs-toggle="modal"
120
                                        data-register="[% register.description | html %]"
209
                                            data-bs-target="#confirmCashupModal"
121
                                        data-bankable="[% rbankable | $Price %]"
210
                                            data-register="[% register.description | html %]"
122
                                        data-float="[% register.starting_float | $Price %]"
211
                                            data-bankable="[% rbankable | $Price %]"
123
                                        data-registerid="[% register.id | html %]"
212
                                            data-expected="[% register.cashup_in_progress.amount | $Price %]"
124
                                        ><i class="fa-solid fa-money-bill-1"></i> Record cashup</button
213
                                            data-float="[% register.starting_float | $Price %]"
125
                                    >
214
                                            data-registerid="[% register.id | html %]"
215
                                            ><i class="fa-solid fa-check"></i> Complete cashup</button
216
                                        >
217
                                    [% ELSE %]
218
                                        <button
219
                                            type="button"
220
                                            class="cashup_individual btn btn-xs btn-default"
221
                                            data-bs-toggle="modal"
222
                                            data-bs-target="#triggerCashupModalRegister"
223
                                            data-register="[% register.description | html %]"
224
                                            data-bankable="[% rbankable | $Price %]"
225
                                            data-float="[% register.starting_float | $Price %]"
226
                                            data-registerid="[% register.id | html %]"
227
                                            [% IF rtotal == 0 %]disabled title="No transactions"[% END %]
228
                                            ><i class="fa-solid fa-money-bill-1"></i> Record cashup</button
229
                                        >
230
                                    [% END %]
126
                                </td>
231
                                </td>
127
                            [% END %]
232
                            [% END %]
128
                        </tr>
233
                        </tr>
Lines 130-142 Link Here
130
                </tbody>
235
                </tbody>
131
                <tfoot>
236
                <tfoot>
132
                    <tr>
237
                    <tr>
133
                        <td colspan="4" align="right">Totals:</td>
238
                        [% IF CAN_user_cash_management_cashup %]
239
                            <td colspan="5" align="right">Totals:</td>
240
                        [% ELSE %]
241
                            <td colspan="4" align="right">Totals:</td>
242
                        [% END %]
134
                        <td>[% bankable | $Price %]</td>
243
                        <td>[% bankable | $Price %]</td>
135
                        <td>[% ctotal | $Price %] ([% cctotal | $Price %])</td>
244
                        <td>[% ctotal | $Price %] ([% cctotal | $Price %])</td>
136
                        <td>[% dtotal | $Price %] ([% cdtotal | $Price %])</td>
245
                        <td>[% dtotal | $Price %] ([% cdtotal | $Price %])</td>
137
                        [% IF CAN_user_cash_management_cashup %]
246
                        [% IF CAN_user_cash_management_cashup %]
138
                            <td>
247
                            <td>
139
                                <button type="button" class="cashup_all btn btn-xs btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupAllModal"><i class="fa-solid fa-money-bill-1"></i> Cashup all</button>
248
                                <button type="button" id="cashup_selected_btn" class="btn btn-xs btn-default" data-bs-toggle="modal" data-bs-target="#confirmCashupSelectedModal" disabled
249
                                    ><i class="fa-solid fa-money-bill-1"></i> Cashup selected</button
250
                                >
140
                            </td>
251
                            </td>
141
                        [% END %]
252
                        [% END %]
142
                    </tr>
253
                    </tr>
Lines 147-180 Link Here
147
    [% END %]
258
    [% END %]
148
[% END %]
259
[% END %]
149
260
150
<!-- Confirm cashup modal -->
261
<!-- Confirm cashup selected modal -->
151
<div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel">
262
<div class="modal" id="confirmCashupSelectedModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupSelectedLabel">
152
    <form id="cashup_individual_form" method="post" enctype="multipart/form-data">
263
    <form method="post" class="validated">
153
        [% INCLUDE 'csrf-token.inc' %]
264
        [% INCLUDE 'csrf-token.inc' %]
154
        <div class="modal-dialog">
265
        <div class="modal-dialog">
155
            <div class="modal-content">
266
            <div class="modal-content">
156
                <div class="modal-header">
267
                <div class="modal-header">
157
                    <h1 class="modal-title" id="confirmCashupLabel"
268
                    <h1 class="modal-title" id="confirmCashupSelectedLabel">Cashup selected registers</h1>
158
                        >Confirm cashup of <em><span id="registerc"></span></em
159
                    ></h1>
160
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
269
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
161
                </div>
270
                </div>
162
                <div class="modal-body"> Please confirm that you have removed <span id="cashc"></span> from the cash register and left a float of <span id="floatc"></span>. </div>
271
                <div class="modal-body">
163
                <!-- /.modal-body -->
272
                    <p
273
                        ><strong>Choose how to proceed with the cashup for <span id="selected_count">0</span> selected register(s):</strong></p
274
                    >
275
276
                    <div class="row">
277
                        <div class="col-md-6">
278
                            <div class="card">
279
                                <div class="card-body">
280
                                    <h5 class="card-title"><i class="fa-solid fa-play"></i> Start cashup for selected</h5>
281
                                    <p class="card-text">Begin two-phase cashup for all selected registers. Cash can be removed for counting while registers continue operating.</p>
282
                                    <ul class="small">
283
                                        <li>Remove cash from each register for counting</li>
284
                                        <li>Registers continue operating during counting</li>
285
                                        <li>Complete each register individually later</li>
286
                                    </ul>
287
                                </div>
288
                            </div>
289
                        </div>
290
                        <div class="col-md-6">
291
                            <div class="card">
292
                                <div class="card-body">
293
                                    <h5 class="card-title"><i class="fa-solid fa-lightning"></i> Quick cashup for selected</h5>
294
                                    <p class="card-text">Complete cashup immediately for all selected registers using expected amounts (no reconciliation needed).</p>
295
                                    <ul class="small">
296
                                        <li>Uses expected amounts for each register</li>
297
                                        <li>No individual reconciliation</li>
298
                                        <li>Completes all selected registers immediately</li>
299
                                    </ul>
300
                                </div>
301
                            </div>
302
                        </div>
303
                    </div>
304
305
                    <div class="mt-3">
306
                        <h6>Selected registers:</h6>
307
                        <ul id="selected_registers_list"></ul>
308
                    </div>
309
                </div>
164
                <div class="modal-footer">
310
                <div class="modal-footer">
165
                    <input type="hidden" name="registerid" id="cashup_registerid" value="" />
311
                    <input type="hidden" name="registerid" id="selected_registers_field" value="" />
166
                    <input type="hidden" name="op" value="cud-cashup" />
312
                    <input type="hidden" name="op" id="selected_operation" value="" />
167
                    <button type="submit" class="btn btn-primary" id="cashup_confirm">Confirm</button>
313
                    <button type="button" class="btn btn-primary" id="start_selected_btn">Start cashup for selected</button>
314
                    <button type="button" class="btn btn-success" id="quick_selected_btn">Quick cashup for selected</button>
168
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
315
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
169
                </div>
316
                </div>
170
                <!-- /.modal-footer -->
171
            </div>
317
            </div>
172
            <!-- /.modal-content -->
173
        </div>
318
        </div>
174
        <!-- /.modal-dialog -->
175
    </form>
319
    </form>
176
</div>
320
</div>
177
<!-- /#confirmCashupModal -->
321
<!-- /#confirmCashupSelectedModal -->
178
322
179
<!-- Confirm cashupall modal -->
323
<!-- Confirm cashupall modal -->
180
<div class="modal" id="confirmCashupAllModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupAllLabel">
324
<div class="modal" id="confirmCashupAllModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupAllLabel">
Lines 209-220 Link Here
209
<!-- /#confirmCashupAllModal -->
353
<!-- /#confirmCashupAllModal -->
210
354
211
[% INCLUDE 'modals/cashup_summary.inc' %]
355
[% INCLUDE 'modals/cashup_summary.inc' %]
356
[% INCLUDE 'modals/trigger_cashup.inc' modal_id='triggerCashupModalRegister' form_action='/cgi-bin/koha/pos/register.pl' redirect_to='registers' %]
357
[% INCLUDE 'modals/confirm_cashup.inc' modal_id='confirmCashupModal' reconciliation_note_avs=reconciliation_note_avs reconciliation_note_required=reconciliation_note_required form_action='/cgi-bin/koha/pos/register.pl' redirect_to='registers' %]
212
358
213
[% MACRO jsinclude BLOCK %]
359
[% MACRO jsinclude BLOCK %]
214
    [% INCLUDE 'datatables.inc' %]
360
    [% INCLUDE 'datatables.inc' %]
215
    [% INCLUDE 'format_price.inc' %]
361
    [% INCLUDE 'format_price.inc' %]
216
    [% INCLUDE 'js-date-format.inc' %]
362
    [% INCLUDE 'js-date-format.inc' %]
217
    [% Asset.js("js/cashup_modal.js") | $raw %]
363
    [% Asset.js("js/cashup_modal.js") | $raw %]
364
    [% Asset.js("js/modals/cashup_modals.js") | $raw %]
218
    [% Asset.js("js/modal_printer.js") | $raw %]
365
    [% Asset.js("js/modal_printer.js") | $raw %]
219
    <script>
366
    <script>
220
        $(document).ready(function () {
367
        $(document).ready(function () {
Lines 225-244 Link Here
225
            $("#outgoing").text('[% dtotal | $Price %] ([% cdtotal | $Price %])');
372
            $("#outgoing").text('[% dtotal | $Price %] ([% cdtotal | $Price %])');
226
373
227
            var registers_table = $("#registers").kohaTable({
374
            var registers_table = $("#registers").kohaTable({
375
                columnDefs: [{ targets: [ -1, 0 ], orderable: false }],
228
                searching: false,
376
                searching: false,
229
                paginationType: "full",
377
                paginationType: "full",
230
            });
378
            });
231
379
232
            $("#confirmCashupModal").on("shown.bs.modal", function(e){
380
            // Initialize cashup modals
233
               var button = $(e.relatedTarget);
381
            initTriggerCashupModal('#triggerCashupModalRegister');
234
               var register = button.data('register');
382
235
               $("#registerc").text(register);
383
            initConfirmCashupModal('#confirmCashupModal', {
236
               var bankable = button.data('bankable');
384
                hasAuthorisedValues: [% reconciliation_note_avs ? 'true' : 'false' | html %],
237
               $("#cashc").text(bankable);
385
                noteRequired: [% reconciliation_note_required ? 'true' : 'false' | html %]
238
               var rfloat = button.data('float');
386
            });
239
               $('#floatc').text(rfloat);
387
240
               var rid = button.data('registerid');
388
            // Select all registers functionality
241
               $('#cashup_registerid').val(rid);
389
            $("#select_all_registers").on("change", function() {
390
                var isChecked = $(this).is(":checked");
391
                $(".register_checkbox:not(:disabled)").prop("checked", isChecked);
392
                updateCashupSelectedButton();
393
            });
394
395
            // Individual checkbox change handler
396
            $(".register_checkbox").on("change", function() {
397
                updateCashupSelectedButton();
398
399
                // Update select all checkbox state
400
                var totalCheckboxes = $(".register_checkbox:not(:disabled)").length;
401
                var checkedCheckboxes = $(".register_checkbox:not(:disabled):checked").length;
402
403
                if (checkedCheckboxes === 0) {
404
                    $("#select_all_registers").prop("indeterminate", false).prop("checked", false);
405
                } else if (checkedCheckboxes === totalCheckboxes) {
406
                    $("#select_all_registers").prop("indeterminate", false).prop("checked", true);
407
                } else {
408
                    $("#select_all_registers").prop("indeterminate", true);
409
                }
410
            });
411
412
            // Update cashup selected button state
413
            function updateCashupSelectedButton() {
414
                var selectedCount = $(".register_checkbox:checked").length;
415
                var button = $("#cashup_selected_btn");
416
417
                if (selectedCount > 0) {
418
                    button.prop("disabled", false).removeClass("btn-default").addClass("btn-primary");
419
                } else {
420
                    button.prop("disabled", true).removeClass("btn-primary").addClass("btn-default");
421
                }
422
            }
423
424
            // Handle cashup selected modal
425
            $("#confirmCashupSelectedModal").on("shown.bs.modal", function(e) {
426
                var selectedCheckboxes = $(".register_checkbox:checked");
427
                var selectedCount = selectedCheckboxes.length;
428
                var selectedIds = [];
429
                var selectedNames = [];
430
431
                selectedCheckboxes.each(function() {
432
                    var registerRow = $(this).closest("tr");
433
                    var registerId = $(this).val();
434
                    var registerName = registerRow.find("td:nth-child(2) a").text(); // Second column (after checkbox)
435
436
                    selectedIds.push(registerId);
437
                    selectedNames.push(registerName);
438
                });
439
440
                $("#selected_count").text(selectedCount);
441
                $("#selected_registers_field").val(selectedIds.join(","));
442
443
                // Populate register list
444
                var listHtml = "";
445
                selectedNames.forEach(function(name) {
446
                    listHtml += "<li>" + name + "</li>";
447
                });
448
                $("#selected_registers_list").html(listHtml);
449
            });
450
451
            // Handle start cashup for selected
452
            $("#start_selected_btn").on("click", function(e) {
453
                e.preventDefault();
454
                var form = $(this).closest("form");
455
                form.find("#selected_operation").val("cud-cashup_start");
456
                form.submit();
457
            });
458
459
            // Handle quick cashup for selected
460
            $("#quick_selected_btn").on("click", function(e) {
461
                e.preventDefault();
462
                var form = $(this).closest("form");
463
                form.find("#selected_operation").val("cud-cashup");
464
                form.submit();
242
            });
465
            });
243
466
244
            // Check for cashup hash in URL
467
            // Check for cashup hash in URL
(-)a/koha-tmpl/intranet-tmpl/prog/js/modals/cashup_modals.js (+277 lines)
Line 0 Link Here
1
/**
2
 * Cashup Modal JavaScript Module
3
 * Shared initialization functions for cashup modals across POS register pages
4
 */
5
6
/**
7
 * Initialize trigger cashup modal behavior
8
 * @param {string} modalSelector - jQuery selector for the modal (e.g., '#triggerCashupModal')
9
 * @param {object} options - Configuration options
10
 * @param {number} options.registerFloat - Starting float amount (for register.tt)
11
 * @param {number} options.bankableAmount - Bankable amount (for register.tt)
12
 */
13
function initTriggerCashupModal(modalSelector, options) {
14
    options = options || {};
15
16
    $(modalSelector).on("shown.bs.modal", function (e) {
17
        var button = $(e.relatedTarget);
18
        var modal = $(this);
19
20
        // Get data from button (for registers.tt) or options (for register.tt)
21
        var register = button.data("register");
22
        var bankable = button.data("bankable");
23
        var rfloat = button.data("float");
24
        var rid = button.data("registerid");
25
26
        // For register.tt, use options if provided
27
        if (options.bankableAmount !== undefined) {
28
            bankable = options.bankableAmount;
29
        }
30
        if (options.registerFloat !== undefined) {
31
            rfloat = options.registerFloat;
32
        }
33
34
        // Populate register description if available
35
        if (register) {
36
            modal.find(".register-description").text(register);
37
        }
38
39
        // Set register ID if available
40
        if (rid) {
41
            modal.find(".register-id-field").val(rid);
42
        }
43
44
        // Guard against undefined/null bankable value
45
        if (bankable === undefined || bankable === null) {
46
            console.error("Bankable amount is undefined");
47
            return;
48
        }
49
50
        // Parse bankable amount (remove currency formatting, keep minus sign)
51
        var bankableAmount = String(bankable).replace(/[^0-9.-]/g, "");
52
        var numericAmount = parseFloat(bankableAmount);
53
        var isNegative = numericAmount < 0;
54
55
        // Format amounts for display
56
        var absAmountFormatted = Math.abs(numericAmount).format_price();
57
        var floatFormatted = rfloat;
58
        if (typeof rfloat === "number") {
59
            floatFormatted = rfloat.format_price();
60
        }
61
62
        // Update Start cashup instructions
63
        var startInstructions;
64
        if (isNegative) {
65
            startInstructions =
66
                "<li>" +
67
                __("Count cash in the register") +
68
                "</li>" +
69
                "<li>" +
70
                __("The register can continue operating during counting") +
71
                "</li>" +
72
                "<li>" +
73
                __("Complete the cashup by adding cash to restore the float") +
74
                "</li>";
75
        } else {
76
            startInstructions =
77
                "<li>" +
78
                __("Remove cash from the register for counting") +
79
                "</li>" +
80
                "<li>" +
81
                __("The register can continue operating during counting") +
82
                "</li>" +
83
                "<li>" +
84
                __("Complete the cashup once counted") +
85
                "</li>";
86
        }
87
        modal.find(".start-cashup-instructions").html(startInstructions);
88
89
        // Update Quick cashup instructions
90
        var quickInstructions;
91
        if (isNegative) {
92
            quickInstructions =
93
                "<li>" +
94
                __("Top up the register with %s to restore the float").format(
95
                    absAmountFormatted
96
                ) +
97
                "</li>";
98
        } else {
99
            quickInstructions =
100
                "<li>" +
101
                __(
102
                    "Confirm you have removed %s cash from the register to bank immediately"
103
                ).format(absAmountFormatted) +
104
                "</li>";
105
        }
106
        modal.find(".quick-cashup-instructions").html(quickInstructions);
107
108
        // Update float reminder
109
        var floatReminder;
110
        if (isNegative) {
111
            floatReminder = __(
112
                "This will bring the register back to the expected float of <strong>%s</strong>"
113
            ).format(floatFormatted);
114
        } else {
115
            floatReminder = __(
116
                "Remember to leave the float amount of <strong>%s</strong> in the register."
117
            ).format(floatFormatted);
118
        }
119
        modal.find(".float-reminder-text").html(floatReminder);
120
121
        // Store bankable amount for quick cashup (with sign)
122
        modal.data("bankable-amount", bankableAmount);
123
    });
124
125
    // Handle Quick cashup button click
126
    $(modalSelector + " .quick-cashup-btn").on("click", function (e) {
127
        e.preventDefault();
128
        var form = $(this).closest("form");
129
        var modal = $(this).closest(".modal");
130
        var bankableAmount = modal.data("bankable-amount");
131
132
        // Change operation to cud-cashup (quick cashup)
133
        form.find('input[name="op"]').val("cud-cashup");
134
135
        // Set the amount to the expected bankable amount
136
        form.find('input[name="amount"]').val(bankableAmount);
137
138
        // Submit the form
139
        form.submit();
140
    });
141
}
142
143
/**
144
 * Initialize confirm cashup modal behavior with reconciliation calculation
145
 * @param {string} modalSelector - jQuery selector for the modal (e.g., '#confirmCashupModal')
146
 * @param {object} options - Configuration options
147
 * @param {boolean} options.noteRequired - Whether reconciliation note is required when there's a discrepancy
148
 * @param {boolean} options.hasAuthorisedValues - Whether authorized values are configured for notes
149
 * @param {boolean} options.isInProgress - Whether this is completing an in-progress cashup (for register.tt)
150
 */
151
function initConfirmCashupModal(modalSelector, options) {
152
    options = options || {};
153
    var noteRequired = options.noteRequired || false;
154
    var hasAuthorisedValues = options.hasAuthorisedValues || false;
155
    var isInProgress = options.isInProgress || false;
156
157
    // Real-time reconciliation calculation
158
    $(modalSelector + " .cashup-amount-input").on("input", function () {
159
        var modal = $(this).closest(".modal");
160
        var actualAmount = parseFloat($(this).val()) || 0;
161
        var expectedText = modal
162
            .find(".expected-amount")
163
            .text()
164
            .replace(/[£$,]/g, "");
165
        var expectedAmount = parseFloat(expectedText) || 0;
166
        var difference = actualAmount - expectedAmount;
167
168
        if ($(this).val() && !isNaN(actualAmount)) {
169
            var reconciliationText = "";
170
            var reconciliationClass = "";
171
            var hasDiscrepancy = false;
172
173
            if (difference > 0) {
174
                reconciliationText = "Surplus: " + difference.format_price();
175
                reconciliationClass = "success";
176
                hasDiscrepancy = true;
177
            } else if (difference < 0) {
178
                reconciliationText =
179
                    "Deficit: " + Math.abs(difference).format_price();
180
                reconciliationClass = "warning";
181
                hasDiscrepancy = true;
182
            } else {
183
                reconciliationText = "Balanced - no surplus or deficit";
184
                reconciliationClass = "success";
185
                hasDiscrepancy = false;
186
            }
187
188
            modal
189
                .find(".reconciliation-text")
190
                .text(reconciliationText)
191
                .removeClass("success warning")
192
                .addClass(reconciliationClass);
193
            modal.find(".reconciliation-display").show();
194
195
            // Show/hide note field based on whether there's a discrepancy
196
            if (hasDiscrepancy) {
197
                modal.find(".reconciliation-note-field").show();
198
199
                // Update required attribute and label based on system preference
200
                if (noteRequired) {
201
                    modal
202
                        .find(".reconciliation-note-input")
203
                        .attr("required", "required");
204
                    modal
205
                        .find(".reconciliation-note-label")
206
                        .addClass("required");
207
                } else {
208
                    modal
209
                        .find(".reconciliation-note-input")
210
                        .removeAttr("required");
211
                    modal
212
                        .find(".reconciliation-note-label")
213
                        .removeClass("required");
214
                }
215
            } else {
216
                modal.find(".reconciliation-note-field").hide();
217
                modal.find(".reconciliation-note-input").val(""); // Clear note when balanced
218
                modal.find(".reconciliation-note-input").removeAttr("required");
219
                modal
220
                    .find(".reconciliation-note-label")
221
                    .removeClass("required");
222
            }
223
        } else {
224
            modal.find(".reconciliation-display").hide();
225
            modal.find(".reconciliation-note-field").hide();
226
        }
227
    });
228
229
    // Reset/populate modal when opened
230
    $(modalSelector).on("shown.bs.modal", function (e) {
231
        var button = $(e.relatedTarget);
232
        var modal = $(this);
233
234
        // For registers.tt: populate from button data
235
        if (button.length && button.data("register")) {
236
            var register = button.data("register");
237
            modal.find(".register-name").text(register);
238
239
            var expected = button.data("expected");
240
            modal.find(".expected-amount").text(expected);
241
242
            var rid = button.data("registerid");
243
            modal.find(".register-id-field").val(rid);
244
245
            // Parse expected amount to check if negative
246
            // Convert to string first in case jQuery's .data() parsed it as a number
247
            var expectedAmount = String(expected || "").replace(
248
                /[^0-9.-]/g,
249
                ""
250
            );
251
            var isNegative = parseFloat(expectedAmount) < 0;
252
253
            // Update labels based on sign
254
            if (isNegative) {
255
                modal
256
                    .find(".expected-amount-label")
257
                    .text(__("Expected amount to add:"));
258
                modal
259
                    .find(".actual-amount-label")
260
                    .text(__("Actual amount added to register:"));
261
            } else {
262
                modal
263
                    .find(".expected-amount-label")
264
                    .text(__("Expected cashup amount:"));
265
                modal
266
                    .find(".actual-amount-label")
267
                    .text(__("Actual cashup amount counted:"));
268
            }
269
        }
270
271
        // Reset fields
272
        modal.find(".cashup-amount-input").val("").focus();
273
        modal.find(".reconciliation-display").hide();
274
        modal.find(".reconciliation-note-field").hide();
275
        modal.find(".reconciliation-note-input").val("");
276
    });
277
}
(-)a/pos/register.pl (-16 / +94 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
70
    # Get authorized values for reconciliation notes if configured
71
    my $note_av_category = C4::Context->preference('CashupReconciliationNoteAuthorisedValue');
72
    my $reconciliation_note_avs;
73
    if ($note_av_category) {
74
        require Koha::AuthorisedValues;
75
        $reconciliation_note_avs = Koha::AuthorisedValues->search(
76
            { category => $note_av_category },
77
            { order_by => { '-asc' => 'lib' } }
78
        );
79
    }
80
68
    $template->param(
81
    $template->param(
69
        register     => $cash_register,
82
        register                     => $cash_register,
70
        accountlines => $accountlines
83
        accountlines                 => $accountlines,
84
        cashup_in_progress           => $cashup_in_progress,
85
        reconciliation_note_avs      => $reconciliation_note_avs,
86
        reconciliation_note_required => C4::Context->preference('CashupReconciliationNoteRequired'),
71
    );
87
    );
72
88
73
    my $transactions_range_from = $input->param('trange_f');
89
    my $transactions_range_from = $input->param('trange_f');
Lines 102-113 if ( !$registers->count ) { Link Here
102
    $template->param( trange_t => $end, );
118
    $template->param( trange_t => $end, );
103
119
104
    my $op = $input->param('op') // '';
120
    my $op = $input->param('op') // '';
105
    if ( $op eq 'cud-cashup' ) {
121
    if ( $op eq 'cud-cashup_start' ) {
122
        if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
123
            eval {
124
                $cash_register->start_cashup(
125
                    {
126
                        manager_id => $logged_in_user->id,
127
                    }
128
                );
129
            };
130
            if ($@) {
131
                if ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
132
                    $template->param( error_cashup_in_progress => 1 );
133
                } elsif ( $@->isa('Koha::Exceptions::Object::BadValue') ) {
134
                    $template->param( error_cashup_no_transactions => 1 );
135
                } else {
136
                    $template->param( error_cashup_start => 1 );
137
                }
138
            } else {
139
140
                # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
141
                my $redirect_to = $input->param('redirect_to') || 'register';
142
143
                if ( $redirect_to eq 'registers' ) {
144
                    print $input->redirect("/cgi-bin/koha/pos/registers.pl?cashup_start_success=1");
145
                } else {
146
                    print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid );
147
                }
148
                exit;
149
            }
150
        } else {
151
            $template->param( error_cashup_permission => 1 );
152
        }
153
    } elsif ( $op eq 'cud-cashup' ) {
106
        if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
154
        if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
107
            my $amount              = $input->param('amount');
155
            my $amount              = $input->param('amount');
108
            my $reconciliation_note = $input->param('reconciliation_note');
156
            my $reconciliation_note = $input->param('reconciliation_note');
109
157
110
            if ( defined $amount && $amount =~ /^\d+(?:\.\d{1,2})?$/ ) {
158
            if ( defined $amount && $amount =~ /^-?\d+(?:\.\d{1,2})?$/ ) {
111
159
112
                # Sanitize and limit note length
160
                # Sanitize and limit note length
113
                if ( defined $reconciliation_note ) {
161
                if ( defined $reconciliation_note ) {
Lines 116-134 if ( !$registers->count ) { Link Here
116
                    $reconciliation_note = undef if $reconciliation_note eq '';
164
                    $reconciliation_note = undef if $reconciliation_note eq '';
117
                }
165
                }
118
166
119
                my $cashup = $cash_register->add_cashup(
167
                my $cashup;
120
                    {
168
                eval {
121
                        manager_id          => $logged_in_user->id,
169
                    $cashup = $cash_register->add_cashup(
122
                        amount              => $amount,
170
                        {
123
                        reconciliation_note => $reconciliation_note
171
                            manager_id          => $logged_in_user->id,
172
                            amount              => $amount,
173
                            reconciliation_note => $reconciliation_note
174
                        }
175
                    );
176
                };
177
                if ($@) {
178
                    if ( $@->isa('Koha::Exceptions::Object::BadValue') ) {
179
                        $template->param( error_no_cashup_start => 1 );
180
                    } elsif ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
181
                        $template->param( error_cashup_already_completed => 1 );
182
                    } elsif ( $@->isa('Koha::Exceptions::MissingParameter') ) {
183
184
                        # Check if this is a reconciliation note error specifically
185
                        if ( $@->error =~ /Reconciliation note is required/ ) {
186
                            $template->param( error_reconciliation_note_required => 1 );
187
                        } else {
188
                            $template->param( error_cashup_missing_param => 1, error_message => $@ );
189
                        }
190
                    } elsif ( $@->isa('Koha::Exceptions::Account::AmountNotPositive') ) {
191
                        $template->param( error_cashup_amount_invalid => 1 );
192
                    } else {
193
194
                        # Log the actual exception for debugging
195
                        $template->param( error_cashup_complete => 1, error_details => "$@" );
124
                    }
196
                    }
125
                );
197
                } else {
126
198
127
                # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
199
                    # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
128
                print $input->redirect(
200
                    my $redirect_to = $input->param('redirect_to') || 'register';
129
                    "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid . "#cashup-" . $cashup->id );
130
                exit;
131
201
202
                    if ( $redirect_to eq 'registers' ) {
203
                        print $input->redirect("/cgi-bin/koha/pos/registers.pl?cashup_complete_success=1");
204
                    } else {
205
                        print $input->redirect(
206
                            "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid . "#cashup-" . $cashup->id );
207
                    }
208
                    exit;
209
                }
132
            } else {
210
            } else {
133
                $template->param( error_cashup_amount => 1 );
211
                $template->param( error_cashup_amount => 1 );
134
            }
212
            }
(-)a/pos/registers.pl (-20 / +171 lines)
Lines 42-47 my $logged_in_user = Koha::Patrons->find($loggedinuser) or die "Not logged in"; Link Here
42
my $library = Koha::Libraries->find( C4::Context->userenv->{'branch'} );
42
my $library = Koha::Libraries->find( C4::Context->userenv->{'branch'} );
43
$template->param( library => $library );
43
$template->param( library => $library );
44
44
45
# Get authorized values for reconciliation notes if configured
46
my $note_av_category = C4::Context->preference('CashupReconciliationNoteAuthorisedValue');
47
my $reconciliation_note_avs;
48
if ($note_av_category) {
49
    require Koha::AuthorisedValues;
50
    $reconciliation_note_avs = Koha::AuthorisedValues->search(
51
        { category => $note_av_category },
52
        { order_by => { '-asc' => 'lib' } }
53
    );
54
}
55
45
my $registers = Koha::Cash::Registers->search(
56
my $registers = Koha::Cash::Registers->search(
46
    { branch   => $library->id, archived => 0 },
57
    { branch   => $library->id, archived => 0 },
47
    { order_by => { '-asc' => 'name' } }
58
    { order_by => { '-asc' => 'name' } }
Lines 50-86 my $registers = Koha::Cash::Registers->search( Link Here
50
if ( !$registers->count ) {
61
if ( !$registers->count ) {
51
    $template->param( error_registers => 1 );
62
    $template->param( error_registers => 1 );
52
} else {
63
} else {
53
    $template->param( registers => $registers );
64
    $template->param(
65
        registers                    => $registers,
66
        reconciliation_note_avs      => $reconciliation_note_avs,
67
        reconciliation_note_required => C4::Context->preference('CashupReconciliationNoteRequired'),
68
    );
69
}
70
71
# Handle success/error messages from redirects
72
my $cashup_start_success    = $input->param('cashup_start_success');
73
my $cashup_start_errors     = $input->param('cashup_start_errors');
74
my $cashup_complete_success = $input->param('cashup_complete_success');
75
my $cashup_complete_errors  = $input->param('cashup_complete_errors');
76
77
if ($cashup_start_success) {
78
    $template->param( cashup_start_success => $cashup_start_success );
79
}
80
if ($cashup_start_errors) {
81
    $template->param( cashup_start_errors => $cashup_start_errors );
82
}
83
if ($cashup_complete_success) {
84
    $template->param( cashup_complete_success => $cashup_complete_success );
85
}
86
if ($cashup_complete_errors) {
87
    $template->param( cashup_complete_errors => $cashup_complete_errors );
54
}
88
}
55
89
56
my $op = $input->param('op') // '';
90
my $op = $input->param('op') // '';
57
if ( $op eq 'cud-cashup' ) {
91
if ( $op eq 'cud-cashup_start' ) {
58
    if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
92
    if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
59
        my $registerid   = $input->param('registerid');
93
        my $registerid_param = $input->param('registerid');
60
        my $redirect_url = "/cgi-bin/koha/pos/registers.pl";
94
        my @register_ids     = split( ',', $registerid_param );
61
        if ($registerid) {
95
        my @errors           = ();
62
            my $register = Koha::Cash::Registers->find( { id => $registerid } );
96
        my $success_count    = 0;
63
            my $cashup   = $register->add_cashup(
97
64
                {
98
        foreach my $register_id (@register_ids) {
65
                    manager_id => $logged_in_user->id,
99
            $register_id =~ s/^\s+|\s+$//g;    # Trim whitespace
66
                    amount     => $register->outstanding_accountlines->total
100
            next unless $register_id;
67
                }
101
68
            );
102
            my $register = Koha::Cash::Registers->find( { id => $register_id } );
69
            $redirect_url .= "#cashup-" . $cashup->id;
103
            next unless $register;
70
        } else {
104
71
            for my $register ( $registers->as_list ) {
105
            eval {
72
                $register->add_cashup(
106
                $register->start_cashup(
73
                    {
107
                    {
74
                        manager_id => $logged_in_user->id,
108
                        manager_id => $logged_in_user->id,
75
                        amount     => $register->outstanding_accountlines->total
76
                    }
109
                    }
77
                );
110
                );
111
                $success_count++;
112
            };
113
            if ($@) {
114
                if ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
115
                    push @errors, "Register " . $register->name . ": Cashup already in progress";
116
                } elsif ( $@->isa('Koha::Exceptions::Object::BadValue') ) {
117
                    push @errors, "Register " . $register->name . ": No cash transactions to cashup";
118
                } else {
119
                    push @errors, "Register " . $register->name . ": Failed to start cashup";
120
                }
121
            }
122
        }
123
124
        if ( @errors && $success_count == 0 ) {
125
126
            # All failed - stay on page to show errors
127
            $template->param(
128
                error_cashup_start => 1,
129
                cashup_errors      => \@errors
130
            );
131
        } else {
132
133
            # Some or all succeeded - redirect with coded parameters
134
            my $redirect_url = "/cgi-bin/koha/pos/registers.pl";
135
            my @params;
136
137
            if ( $success_count > 0 ) {
138
                push @params, "cashup_start_success=" . $success_count;
139
            }
140
            if (@errors) {
141
                push @params, "cashup_start_errors=" . scalar(@errors);
142
            }
143
144
            if (@params) {
145
                $redirect_url .= "?" . join( "&", @params );
146
            }
147
148
            print $input->redirect($redirect_url);
149
            exit;
150
        }
151
    } else {
152
        $template->param( error_cashup_permission => 1 );
153
    }
154
} elsif ( $op eq 'cud-cashup' ) {
155
    if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
156
        my $registerid_param = $input->param('registerid');
157
        my @register_ids     = split( ',', $registerid_param );
158
        my @errors           = ();
159
        my $success_count    = 0;
160
161
        foreach my $register_id (@register_ids) {
162
            $register_id =~ s/^\s+|\s+$//g;    # Trim whitespace
163
            next unless $register_id;
164
165
            my $register = Koha::Cash::Registers->find( { id => $register_id } );
166
            next unless $register;
167
168
            eval {
169
                # Get the amount from the request parameter
170
                # For quick cashup, this will be the expected amount set by JavaScript
171
                # For two-stage cashup completion, this will be the user-entered actual amount
172
                my $amount = $input->param('amount');
173
174
                # If no amount provided, calculate expected amount (backwards compatibility)
175
                unless ( defined $amount && $amount ne '' ) {
176
                    $amount =
177
                        $register->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) * -1;
178
                }
179
180
                # Get optional reconciliation note
181
                my $reconciliation_note = $input->param('reconciliation_note');
182
183
                # Complete the cashup
184
                my %cashup_params = (
185
                    manager_id => $logged_in_user->id,
186
                    amount     => $amount,
187
                );
188
189
                # Add reconciliation note if provided
190
                if ( defined $reconciliation_note && $reconciliation_note ne '' ) {
191
                    $cashup_params{reconciliation_note} = $reconciliation_note;
192
                }
193
194
                $register->add_cashup( \%cashup_params );
195
                $success_count++;
196
            };
197
            if ($@) {
198
                if ( $@->isa('Koha::Exceptions::Object::BadValue') ) {
199
                    push @errors, "Register " . $register->name . ": No cashup session to complete";
200
                } elsif ( $@->isa('Koha::Exceptions::Object::DuplicateID') ) {
201
                    push @errors, "Register " . $register->name . ": Cashup already completed";
202
                } else {
203
                    push @errors, "Register " . $register->name . ": Failed to complete cashup";
204
                }
78
            }
205
            }
79
        }
206
        }
80
207
81
        # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
208
        if ( @errors && $success_count == 0 ) {
82
        print $input->redirect($redirect_url);
209
83
        exit;
210
            # All failed - stay on page to show errors
211
            $template->param(
212
                error_cashup_complete => 1,
213
                cashup_errors         => \@errors
214
            );
215
        } else {
216
217
            # Some or all succeeded - redirect with coded parameters
218
            my $redirect_url = "/cgi-bin/koha/pos/registers.pl";
219
            my @params;
220
221
            if ( $success_count > 0 ) {
222
                push @params, "cashup_complete_success=" . $success_count;
223
            }
224
            if (@errors) {
225
                push @params, "cashup_complete_errors=" . scalar(@errors);
226
            }
227
228
            if (@params) {
229
                $redirect_url .= "?" . join( "&", @params );
230
            }
231
232
            print $input->redirect($redirect_url);
233
            exit;
234
        }
84
    } else {
235
    } else {
85
        $template->param( error_cashup_permission => 1 );
236
        $template->param( error_cashup_permission => 1 );
86
    }
237
    }
(-)a/t/db_dependent/Koha/Cash/Register.t (-65 / +1033 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 => 11;
24
24
25
use Test::Exception;
25
use Test::Exception;
26
26
Lines 29-34 use Koha::Account; Link Here
29
use Koha::Account::CreditTypes;
29
use Koha::Account::CreditTypes;
30
use Koha::Account::DebitTypes;
30
use Koha::Account::DebitTypes;
31
31
32
use t::lib::Mocks;
32
use t::lib::TestBuilder;
33
use t::lib::TestBuilder;
33
34
34
my $builder = t::lib::TestBuilder->new;
35
my $builder = t::lib::TestBuilder->new;
Lines 173-178 subtest 'cashup' => sub { Link Here
173
174
174
    $schema->storage->txn_begin;
175
    $schema->storage->txn_begin;
175
176
177
    # Ensure reconciliation notes are not required for these tests
178
    t::lib::Mocks::mock_preference( 'CashupReconciliationNoteRequired', 0 );
179
176
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
180
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
177
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
181
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
178
182
Lines 260-266 subtest 'cashup' => sub { Link Here
260
    subtest 'outstanding_accountlines' => sub {
264
    subtest 'outstanding_accountlines' => sub {
261
        plan tests => 6;
265
        plan tests => 6;
262
266
263
        my $accountlines = $register->outstanding_accountlines;
267
        $schema->storage->txn_begin;
268
269
        my $test_register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
270
        my $accountlines  = $test_register->outstanding_accountlines;
264
        is(
271
        is(
265
            ref($accountlines), 'Koha::Account::Lines',
272
            ref($accountlines), 'Koha::Account::Lines',
266
            'Koha::Cash::Register->outstanding_accountlines should always return a Koha::Account::Lines set'
273
            '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'
277
            'Koha::Cash::Register->outstanding_accountlines should always return the correct number of accountlines'
271
        );
278
        );
272
279
280
        my $test_patron = $builder->build_object( { class => 'Koha::Patrons' } );
281
273
        my $accountline1 = $builder->build_object(
282
        my $accountline1 = $builder->build_object(
274
            {
283
            {
275
                class => 'Koha::Account::Lines',
284
                class => 'Koha::Account::Lines',
276
                value => { register_id => $register->id, date => \'NOW() - INTERVAL 5 MINUTE' },
285
                value => {
286
                    register_id  => $test_register->id,
287
                    amount       => -2.50,
288
                    date         => \'SYSDATE() - INTERVAL 5 MINUTE',
289
                    payment_type => 'CASH'
290
                },
277
            }
291
            }
278
        );
292
        );
279
        my $accountline2 = $builder->build_object(
293
        my $accountline2 = $builder->build_object(
280
            {
294
            {
281
                class => 'Koha::Account::Lines',
295
                class => 'Koha::Account::Lines',
282
                value => { register_id => $register->id, date => \'NOW() - INTERVAL 5 MINUTE' },
296
                value => {
297
                    register_id  => $test_register->id,
298
                    amount       => -1.50,
299
                    date         => \'SYSDATE() - INTERVAL 5 MINUTE',
300
                    payment_type => 'CASH'
301
                },
283
            }
302
            }
284
        );
303
        );
285
304
286
        $accountlines = $register->outstanding_accountlines;
305
        $accountlines = $test_register->outstanding_accountlines;
287
        is( $accountlines->count, 2, 'No cashup, all accountlines returned' );
306
        is( $accountlines->count, 2, 'No cashup, all accountlines returned' );
288
307
289
        my $cashup3 = $register->add_cashup( { manager_id => $patron->id, amount => '2.50' } );
308
        # Calculate expected amount for this cashup
309
        my $expected_amount =
310
            ( $test_register->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) ) * -1;
311
        my $cashup3 = $test_register->add_cashup( { manager_id => $test_patron->id, amount => $expected_amount } );
290
312
291
        $accountlines = $register->outstanding_accountlines;
313
        $accountlines = $test_register->outstanding_accountlines;
292
        is( $accountlines->count, 0, 'Cashup added, no accountlines returned' );
314
        is( $accountlines->count, 0, 'Cashup added, no accountlines returned' );
293
315
294
        my $accountline3 = $builder->build_object(
316
        my $accountline3 = $builder->build_object(
295
            {
317
            {
296
                class => 'Koha::Account::Lines',
318
                class => 'Koha::Account::Lines',
297
                value => { register_id => $register->id },
319
                value => {
320
                    register_id  => $test_register->id,
321
                    amount       => 1.50,
322
                    date         => \'SYSDATE() + INTERVAL 5 MINUTE',
323
                    payment_type => 'CASH'
324
                },
298
            }
325
            }
299
        );
326
        );
300
327
301
        # Fake the cashup timestamp to make sure it's before the accountline we just added,
328
        $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(
329
        is(
307
            $accountlines->count, 1,
330
            $accountlines->count, 1,
308
            'Accountline added, one accountline returned'
331
            'Accountline added, one accountline returned'
Lines 311-365 subtest 'cashup' => sub { Link Here
311
            $accountlines->next->id,
334
            $accountlines->next->id,
312
            $accountline3->id, 'Correct accountline returned'
335
            $accountline3->id, 'Correct accountline returned'
313
        );
336
        );
337
338
        $schema->storage->txn_rollback;
314
    };
339
    };
315
340
316
    $schema->storage->txn_rollback;
341
    $schema->storage->txn_rollback;
317
};
342
};
318
343
319
subtest 'cashup_reconciliation' => sub {
344
subtest 'cashup_reconciliation' => sub {
320
    plan tests => 5;
345
    plan tests => 6;
321
346
322
    $schema->storage->txn_begin;
347
    $schema->storage->txn_begin;
323
348
324
    # Ensure required account types for reconciliation exist (they should already exist from mandatory data)
349
    # Ensure reconciliation notes are not required for these tests
325
    use Koha::Account::CreditTypes;
350
    t::lib::Mocks::mock_preference( 'CashupReconciliationNoteRequired', 0 );
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
351
364
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
352
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
365
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
353
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
Lines 371-379 subtest 'cashup_reconciliation' => sub { Link Here
371
            value => {
359
            value => {
372
                register_id      => $register->id,
360
                register_id      => $register->id,
373
                borrowernumber   => $patron->id,
361
                borrowernumber   => $patron->id,
374
                amount           => -10.00,          # Credit (payment)
362
                amount           => -10.00,                             # Credit (payment)
375
                credit_type_code => 'PAYMENT',
363
                credit_type_code => 'PAYMENT',
376
                debit_type_code  => undef,
364
                debit_type_code  => undef,
365
                payment_type     => 'CASH',
366
                date             => \'SYSDATE() - INTERVAL 1 MINUTE',
367
                timestamp        => \'SYSDATE() - INTERVAL 1 MINUTE',
377
            }
368
            }
378
        }
369
        }
379
    );
370
    );
Lines 383-402 subtest 'cashup_reconciliation' => sub { Link Here
383
            value => {
374
            value => {
384
                register_id      => $register->id,
375
                register_id      => $register->id,
385
                borrowernumber   => $patron->id,
376
                borrowernumber   => $patron->id,
386
                amount           => -5.00,           # Credit (payment)
377
                amount           => -5.00,                              # Credit (payment)
387
                credit_type_code => 'PAYMENT',
378
                credit_type_code => 'PAYMENT',
388
                debit_type_code  => undef,
379
                debit_type_code  => undef,
380
                payment_type     => 'CASH',
381
                date             => \'SYSDATE() - INTERVAL 1 MINUTE',
382
                timestamp        => \'SYSDATE() - INTERVAL 1 MINUTE',
389
            }
383
            }
390
        }
384
        }
391
    );
385
    );
392
386
393
    my $expected_amount = $register->outstanding_accountlines->total;    # Should be -15.00
387
    my $expected_amount =
388
        $register->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } );    # Should be -15.00
389
    is( $expected_amount, -15.00, "Expected cash amount is calculated correctly" );
394
390
395
    subtest 'balanced_cashup' => sub {
391
    subtest 'balanced_cashup' => sub {
396
        plan tests => 3;
392
        plan tests => 3;
397
393
394
        $schema->storage->txn_begin;
395
398
        # Test exact match - no surplus/deficit accountlines should be created
396
        # Test exact match - no surplus/deficit accountlines should be created
399
        my $amount = abs($expected_amount);                              # 15.00 actual matches 15.00 expected
397
        my $amount = abs($expected_amount);    # 15.00 actual matches 15.00 expected
400
398
401
        my $cashup = $register->add_cashup(
399
        my $cashup = $register->add_cashup(
402
            {
400
            {
Lines 420-429 subtest 'cashup_reconciliation' => sub { Link Here
420
        );
418
        );
421
419
422
        is( $reconciliation_lines->count, 0, 'No reconciliation accountlines created for balanced cashup' );
420
        is( $reconciliation_lines->count, 0, 'No reconciliation accountlines created for balanced cashup' );
421
422
        $schema->storage->txn_rollback;
423
    };
423
    };
424
424
425
    subtest 'surplus_cashup' => sub {
425
    subtest 'surplus_cashup' => sub {
426
        plan tests => 7;
426
        plan tests => 10;
427
427
428
        $schema->storage->txn_begin;
428
        $schema->storage->txn_begin;
429
429
Lines 437-449 subtest 'cashup_reconciliation' => sub { Link Here
437
                    amount           => -20.00,           # Credit (payment)
437
                    amount           => -20.00,           # Credit (payment)
438
                    credit_type_code => 'PAYMENT',
438
                    credit_type_code => 'PAYMENT',
439
                    debit_type_code  => undef,
439
                    debit_type_code  => undef,
440
                    payment_type     => 'CASH',
440
                }
441
                }
441
            }
442
            }
442
        );
443
        );
443
444
444
        my $expected = abs( $register2->outstanding_accountlines->total );    # 20.00
445
        my $expected =
445
        my $actual   = 25.00;                                                 # 5.00 surplus
446
            abs( $register2->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) );    # 20.00
446
        my $surplus  = $actual - $expected;
447
        my $actual  = 25.00;                 # 5.00 surplus
448
        my $surplus = $actual - $expected;
447
449
448
        my $cashup = $register2->add_cashup(
450
        my $cashup = $register2->add_cashup(
449
            {
451
            {
Lines 488-493 subtest 'cashup_reconciliation' => sub { Link Here
488
                    amount           => -10.00,
490
                    amount           => -10.00,
489
                    credit_type_code => 'PAYMENT',
491
                    credit_type_code => 'PAYMENT',
490
                    debit_type_code  => undef,
492
                    debit_type_code  => undef,
493
                    payment_type     => 'CASH',
491
                }
494
                }
492
            }
495
            }
493
        );
496
        );
Lines 520-526 subtest 'cashup_reconciliation' => sub { Link Here
520
    };
523
    };
521
524
522
    subtest 'deficit_cashup' => sub {
525
    subtest 'deficit_cashup' => sub {
523
        plan tests => 7;
526
        plan tests => 10;
524
527
525
        $schema->storage->txn_begin;
528
        $schema->storage->txn_begin;
526
529
Lines 534-546 subtest 'cashup_reconciliation' => sub { Link Here
534
                    amount           => -30.00,           # Credit (payment)
537
                    amount           => -30.00,           # Credit (payment)
535
                    credit_type_code => 'PAYMENT',
538
                    credit_type_code => 'PAYMENT',
536
                    debit_type_code  => undef,
539
                    debit_type_code  => undef,
540
                    payment_type     => 'CASH',
537
                }
541
                }
538
            }
542
            }
539
        );
543
        );
540
544
541
        my $expected = abs( $register3->outstanding_accountlines->total );    # 30.00
545
        my $expected =
542
        my $actual   = 25.00;                                                 # 5.00 deficit
546
            abs( $register3->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) );    # 30.00
543
        my $deficit  = $expected - $actual;
547
        my $actual  = 25.00;                 # 5.00 deficit
548
        my $deficit = $expected - $actual;
544
549
545
        my $cashup = $register3->add_cashup(
550
        my $cashup = $register3->add_cashup(
546
            {
551
            {
Lines 585-590 subtest 'cashup_reconciliation' => sub { Link Here
585
                    amount           => -20.00,
590
                    amount           => -20.00,
586
                    credit_type_code => 'PAYMENT',
591
                    credit_type_code => 'PAYMENT',
587
                    debit_type_code  => undef,
592
                    debit_type_code  => undef,
593
                    payment_type     => 'CASH',
588
                }
594
                }
589
            }
595
            }
590
        );
596
        );
Lines 631-636 subtest 'cashup_reconciliation' => sub { Link Here
631
                    amount           => -10.00,
637
                    amount           => -10.00,
632
                    credit_type_code => 'PAYMENT',
638
                    credit_type_code => 'PAYMENT',
633
                    debit_type_code  => undef,
639
                    debit_type_code  => undef,
640
                    payment_type     => 'CASH',
634
                }
641
                }
635
            }
642
            }
636
        );
643
        );
Lines 683-688 subtest 'cashup_reconciliation' => sub { Link Here
683
                    amount           => -10.00,
690
                    amount           => -10.00,
684
                    credit_type_code => 'PAYMENT',
691
                    credit_type_code => 'PAYMENT',
685
                    debit_type_code  => undef,
692
                    debit_type_code  => undef,
693
                    payment_type     => 'CASH',
686
                }
694
                }
687
            }
695
            }
688
        );
696
        );
Lines 722-727 subtest 'cashup_reconciliation' => sub { Link Here
722
                    amount           => -10.00,
730
                    amount           => -10.00,
723
                    credit_type_code => 'PAYMENT',
731
                    credit_type_code => 'PAYMENT',
724
                    debit_type_code  => undef,
732
                    debit_type_code  => undef,
733
                    payment_type     => 'CASH',
725
                }
734
                }
726
            }
735
            }
727
        );
736
        );
Lines 751-753 subtest 'cashup_reconciliation' => sub { Link Here
751
760
752
    $schema->storage->txn_rollback;
761
    $schema->storage->txn_rollback;
753
};
762
};
763
764
subtest 'two_phase_cashup_workflow' => sub {
765
    plan tests => 15;
766
767
    $schema->storage->txn_begin;
768
769
    # Create test data
770
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
771
    my $library  = $builder->build_object( { class => 'Koha::Libraries' } );
772
    my $register = $builder->build_object(
773
        {
774
            class => 'Koha::Cash::Registers',
775
            value => {
776
                branch         => $library->branchcode,
777
                starting_float => 0,
778
            }
779
        }
780
    );
781
782
    # Add some test transactions
783
    my $account_line1 = $builder->build_object(
784
        {
785
            class => 'Koha::Account::Lines',
786
            value => {
787
                amount           => 10.00,
788
                date             => \'SYSDATE() - INTERVAL 1 MINUTE',
789
                register_id      => undef,
790
                debit_type_code  => 'OVERDUE',
791
                credit_type_code => undef,
792
                payment_type     => undef,
793
            }
794
        }
795
    );
796
797
    my $account_line2 = $builder->build_object(
798
        {
799
            class => 'Koha::Account::Lines',
800
            value => {
801
                amount           => -5.00,
802
                date             => \'SYSDATE() - INTERVAL 1 MINUTE',
803
                register_id      => $register->id,
804
                debit_type_code  => undef,
805
                credit_type_code => 'PAYMENT',
806
                payment_type     => 'CASH'
807
            }
808
        }
809
    );
810
811
    # Test 1: start_cashup creates CASHUP_START action
812
    my $cashup_start = $register->start_cashup( { manager_id => $manager->id } );
813
814
    is(
815
        ref $cashup_start, 'Koha::Cash::Register::Cashup',
816
        'start_cashup returns Cash::Register::Cashup object'
817
    );
818
819
    my $start_action = Koha::Cash::Register::Actions->search(
820
        {
821
            register_id => $register->id,
822
            code        => 'CASHUP_START'
823
        }
824
    )->next;
825
826
    ok( $start_action, 'CASHUP_START action created in database' );
827
    is( $start_action->manager_id, $manager->id, 'CASHUP_START has correct manager_id' );
828
829
    # Test 2: cashup_in_progress detects active cashup
830
    my $in_progress = $register->cashup_in_progress;
831
    ok( $in_progress, 'cashup_in_progress detects active cashup' );
832
    is( $in_progress->id, $start_action->id, 'cashup_in_progress returns correct CASHUP_START action' );
833
834
    # Test 3: Cannot start another cashup while one is in progress
835
    throws_ok {
836
        $register->start_cashup( { manager_id => $manager->id } );
837
    }
838
    'Koha::Exceptions::Object::DuplicateID',
839
        'Cannot start second cashup while one is in progress';
840
841
    # Test 4: outstanding_accountlines behavior during active cashup
842
    my $outstanding = $register->outstanding_accountlines;
843
    is( $outstanding->count, 0, 'outstanding_accountlines returns 0 during active cashup' );
844
845
    # Test 5: Add transaction after cashup start (should appear in outstanding)
846
    my $account_line3 = $builder->build_object(
847
        {
848
            class => 'Koha::Account::Lines',
849
            value => {
850
                amount           => -8.00,
851
                date             => \'SYSDATE() + INTERVAL 1 MINUTE',
852
                register_id      => $register->id,
853
                debit_type_code  => undef,
854
                credit_type_code => 'PAYMENT',
855
                payment_type     => 'CASH',
856
            }
857
        }
858
    );
859
860
    # This new transaction should appear in outstanding (it's after CASHUP_START)
861
    $outstanding = $register->outstanding_accountlines;
862
    is( $outstanding->count, 1, 'New transaction after CASHUP_START appears in outstanding' );
863
864
    # Test 6: outstanding_accountlines correctly handles session boundaries
865
    my $session_accountlines = $register->outstanding_accountlines;
866
    my $session_total        = $session_accountlines->total;
867
    is(
868
        $session_total, -8.00,
869
        'outstanding_accountlines correctly calculates session totals with CASHUP_START cutoff'
870
    );
871
872
    # Test 7: Complete cashup with exact amount (no reconciliation)
873
    my $expected_cashup_amount = 5.00;                    # CASH PAYMENT prior to CASHUP_START
874
    my $cashup_complete        = $register->add_cashup(
875
        {
876
            manager_id => $manager->id,
877
            amount     => $expected_cashup_amount
878
        }
879
    );
880
881
    is(
882
        ref $cashup_complete, 'Koha::Cash::Register::Cashup',
883
        'add_cashup returns Cashup object'
884
    );
885
886
    # Check no reconciliation lines were created
887
    my $surplus_lines = $cashup_complete->accountlines->search(
888
        {
889
            register_id      => $register->id,
890
            credit_type_code => 'CASHUP_SURPLUS'
891
        }
892
    );
893
    my $deficit_lines = $cashup_complete->accountlines->search(
894
        {
895
            register_id     => $register->id,
896
            debit_type_code => 'CASHUP_DEFICIT'
897
        }
898
    );
899
900
    is( $surplus_lines->count, 0, 'No surplus lines created for exact cashup' );
901
    is( $deficit_lines->count, 0, 'No deficit lines created for exact cashup' );
902
903
    # Test 8: cashup_in_progress returns undef after completion
904
    $in_progress = $register->cashup_in_progress;
905
    is( $in_progress, undef, 'cashup_in_progress returns undef after completion' );
906
907
    # Test 9: outstanding_accountlines now includes new transaction
908
    $outstanding = $register->outstanding_accountlines;
909
    is( $outstanding->count,  1,    'outstanding_accountlines includes transaction after completion' );
910
    is( $outstanding->total, -8.00, 'outstanding_accountlines total is correct after completion' );
911
912
    $schema->storage->txn_rollback;
913
};
914
915
subtest 'cashup_in_progress' => sub {
916
    plan tests => 6;
917
918
    $schema->storage->txn_begin;
919
920
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
921
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
922
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
923
924
    # Test 1: No cashups ever performed
925
    subtest 'no_cashups_ever' => sub {
926
        plan tests => 1;
927
928
        my $in_progress = $register->cashup_in_progress;
929
        is( $in_progress, undef, 'cashup_in_progress returns undef when no cashups have ever been performed' );
930
    };
931
932
    # Test 2: Only quick cashups performed
933
    subtest 'only_quick_cashups' => sub {
934
        plan tests => 2;
935
936
        # Add cash for first quick cashup
937
        $builder->build_object(
938
            {
939
                class => 'Koha::Account::Lines',
940
                value => {
941
                    register_id      => $register->id,
942
                    borrowernumber   => $patron->id,
943
                    amount           => -10.00,
944
                    credit_type_code => 'PAYMENT',
945
                    payment_type     => 'CASH',
946
                }
947
            }
948
        );
949
950
        # Add a quick cashup
951
        my $quick_cashup = $register->add_cashup( { manager_id => $manager->id, amount => '10.00' } );
952
        $quick_cashup->timestamp( \'NOW() - INTERVAL 30 MINUTE' )->store();
953
954
        my $in_progress = $register->cashup_in_progress;
955
        is( $in_progress, undef, 'cashup_in_progress returns undef after quick cashup completion' );
956
957
        # Add cash for second quick cashup
958
        $builder->build_object(
959
            {
960
                class => 'Koha::Account::Lines',
961
                value => {
962
                    register_id      => $register->id,
963
                    borrowernumber   => $patron->id,
964
                    amount           => -5.00,
965
                    credit_type_code => 'PAYMENT',
966
                    payment_type     => 'CASH',
967
                }
968
            }
969
        );
970
971
        # Add another quick cashup
972
        my $quick_cashup2 = $register->add_cashup( { manager_id => $manager->id, amount => '5.00' } );
973
974
        $in_progress = $register->cashup_in_progress;
975
        is( $in_progress, undef, 'cashup_in_progress returns undef after multiple quick cashups' );
976
    };
977
978
    # Test 3: Multiple CASHUP_START actions
979
    subtest 'multiple_start_actions' => sub {
980
        plan tests => 2;
981
982
        my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
983
        my $patron    = $builder->build_object( { class => 'Koha::Patrons' } );
984
985
        # Add cash transactions before starting cashup
986
        $builder->build_object(
987
            {
988
                class => 'Koha::Account::Lines',
989
                value => {
990
                    register_id      => $register2->id,
991
                    borrowernumber   => $patron->id,
992
                    amount           => -5.00,
993
                    credit_type_code => 'PAYMENT',
994
                    payment_type     => 'CASH',
995
                }
996
            }
997
        );
998
999
        # Create multiple CASHUP_START actions
1000
        my $start1 = $register2->start_cashup( { manager_id => $manager->id } );
1001
        $start1->timestamp( \'NOW() - INTERVAL 60 MINUTE' )->store();
1002
1003
        # Complete the first one
1004
        my $complete1 = $register2->add_cashup( { manager_id => $manager->id, amount => '1.00' } );
1005
        $complete1->timestamp( \'NOW() - INTERVAL 50 MINUTE' )->store();
1006
1007
        # Add more cash for second cashup
1008
        $builder->build_object(
1009
            {
1010
                class => 'Koha::Account::Lines',
1011
                value => {
1012
                    register_id      => $register2->id,
1013
                    borrowernumber   => $patron->id,
1014
                    amount           => -3.00,
1015
                    credit_type_code => 'PAYMENT',
1016
                    payment_type     => 'CASH',
1017
                }
1018
            }
1019
        );
1020
1021
        # Start another one
1022
        my $start2 = $register2->start_cashup( { manager_id => $manager->id } );
1023
1024
        my $in_progress = $register2->cashup_in_progress;
1025
        is( ref($in_progress), 'Koha::Cash::Register::Action', 'Returns most recent CASHUP_START when multiple exist' );
1026
        is( $in_progress->id,  $start2->id, 'Returns the correct (most recent) CASHUP_START action' );
1027
    };
1028
1029
    # Test 4: Mixed quick and two-phase workflows
1030
    subtest 'mixed_workflows' => sub {
1031
        plan tests => 3;
1032
1033
        my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1034
        my $patron    = $builder->build_object( { class => 'Koha::Patrons' } );
1035
1036
        # Add cash for first quick cashup
1037
        $builder->build_object(
1038
            {
1039
                class => 'Koha::Account::Lines',
1040
                value => {
1041
                    register_id      => $register3->id,
1042
                    borrowernumber   => $patron->id,
1043
                    amount           => -5.00,
1044
                    credit_type_code => 'PAYMENT',
1045
                    payment_type     => 'CASH',
1046
                }
1047
            }
1048
        );
1049
1050
        # Quick cashup first
1051
        my $quick = $register3->add_cashup( { manager_id => $manager->id, amount => '5.00' } );
1052
        $quick->timestamp( \'NOW() - INTERVAL 40 MINUTE' )->store();
1053
1054
        # Add cash for two-phase cashup
1055
        $builder->build_object(
1056
            {
1057
                class => 'Koha::Account::Lines',
1058
                value => {
1059
                    register_id      => $register3->id,
1060
                    borrowernumber   => $patron->id,
1061
                    amount           => -3.00,
1062
                    credit_type_code => 'PAYMENT',
1063
                    payment_type     => 'CASH',
1064
                }
1065
            }
1066
        );
1067
1068
        # Start two-phase
1069
        my $start = $register3->start_cashup( { manager_id => $manager->id } );
1070
        $start->timestamp( \'NOW() - INTERVAL 30 MINUTE' )->store();
1071
1072
        my $in_progress = $register3->cashup_in_progress;
1073
        is( ref($in_progress), 'Koha::Cash::Register::Action', 'Detects two-phase in progress after quick cashup' );
1074
        is( $in_progress->id,  $start->id,                     'Returns correct CASHUP_START after mixed workflow' );
1075
1076
        # Complete two-phase
1077
        my $complete = $register3->add_cashup( { manager_id => $manager->id, amount => '3.00' } );
1078
1079
        $in_progress = $register3->cashup_in_progress;
1080
        is( $in_progress, undef, 'Returns undef after completing two-phase in mixed workflow' );
1081
    };
1082
1083
    # Test 5: Timestamp edge cases
1084
    subtest 'timestamp_edge_cases' => sub {
1085
        plan tests => 2;
1086
1087
        my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1088
        my $patron    = $builder->build_object( { class => 'Koha::Patrons' } );
1089
1090
        # Add cash for cashup
1091
        $builder->build_object(
1092
            {
1093
                class => 'Koha::Account::Lines',
1094
                value => {
1095
                    register_id      => $register4->id,
1096
                    borrowernumber   => $patron->id,
1097
                    amount           => -2.00,
1098
                    credit_type_code => 'PAYMENT',
1099
                    payment_type     => 'CASH',
1100
                }
1101
            }
1102
        );
1103
1104
        # Create CASHUP_START
1105
        my $start      = $register4->start_cashup( { manager_id => $manager->id } );
1106
        my $start_time = $start->timestamp;
1107
1108
        # Create CASHUP with exactly the same timestamp (edge case)
1109
        my $complete = $register4->add_cashup( { manager_id => $manager->id, amount => '1.00' } );
1110
        $complete->timestamp($start_time)->store();
1111
1112
        my $in_progress = $register4->cashup_in_progress;
1113
        is( $in_progress, undef, 'Handles same timestamp edge case correctly' );
1114
1115
        # Test with CASHUP timestamp slightly before CASHUP_START (edge case)
1116
        my $register5 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1117
1118
        # Add cash for register5
1119
        $builder->build_object(
1120
            {
1121
                class => 'Koha::Account::Lines',
1122
                value => {
1123
                    register_id      => $register5->id,
1124
                    borrowernumber   => $patron->id,
1125
                    amount           => -2.00,
1126
                    credit_type_code => 'PAYMENT',
1127
                    payment_type     => 'CASH',
1128
                }
1129
            }
1130
        );
1131
1132
        my $start2 = $register5->start_cashup( { manager_id => $manager->id } );
1133
1134
        my $complete2 = $register5->add_cashup( { manager_id => $manager->id, amount => '1.00' } );
1135
        $complete2->timestamp( \'NOW() - INTERVAL 1 MINUTE' )->store();
1136
1137
        $in_progress = $register5->cashup_in_progress;
1138
        is(
1139
            ref($in_progress), 'Koha::Cash::Register::Action',
1140
            'Correctly identifies active cashup when completion is backdated'
1141
        );
1142
    };
1143
1144
    # Test 6: Performance with many cashups
1145
    subtest 'performance_with_many_cashups' => sub {
1146
        plan tests => 1;
1147
1148
        my $register6 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1149
        my $patron    = $builder->build_object( { class => 'Koha::Patrons' } );
1150
1151
        # Add cash for the many quick cashups
1152
        for my $i ( 1 .. 10 ) {
1153
            $builder->build_object(
1154
                {
1155
                    class => 'Koha::Account::Lines',
1156
                    value => {
1157
                        register_id      => $register6->id,
1158
                        borrowernumber   => $patron->id,
1159
                        amount           => -1.00,
1160
                        credit_type_code => 'PAYMENT',
1161
                        payment_type     => 'CASH',
1162
                    }
1163
                }
1164
            );
1165
        }
1166
1167
        # Create many quick cashups
1168
        for my $i ( 1 .. 10 ) {
1169
            my $cashup    = $register6->add_cashup( { manager_id => $manager->id, amount => '1.00' } );
1170
            my $timestamp = "NOW() - INTERVAL $i MINUTE";
1171
            $cashup->timestamp( \$timestamp )->store();
1172
        }
1173
1174
        # Add cash for two-phase cashup
1175
        $builder->build_object(
1176
            {
1177
                class => 'Koha::Account::Lines',
1178
                value => {
1179
                    register_id      => $register6->id,
1180
                    borrowernumber   => $patron->id,
1181
                    amount           => -2.00,
1182
                    credit_type_code => 'PAYMENT',
1183
                    payment_type     => 'CASH',
1184
                }
1185
            }
1186
        );
1187
1188
        # Start a two-phase cashup
1189
        my $start = $register6->start_cashup( { manager_id => $manager->id } );
1190
1191
        my $in_progress = $register6->cashup_in_progress;
1192
        is( ref($in_progress), 'Koha::Cash::Register::Action', 'Performs correctly with many previous cashups' );
1193
    };
1194
1195
    $schema->storage->txn_rollback;
1196
};
1197
1198
subtest 'start_cashup_parameter_validation' => sub {
1199
    plan tests => 5;
1200
1201
    $schema->storage->txn_begin;
1202
1203
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1204
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
1205
1206
    # Test 1: Valid parameters
1207
    subtest 'valid_parameters' => sub {
1208
        plan tests => 3;
1209
1210
        my $register1 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1211
        my $patron    = $builder->build_object( { class => 'Koha::Patrons' } );
1212
1213
        # Add cash transaction before starting cashup
1214
        $builder->build_object(
1215
            {
1216
                class => 'Koha::Account::Lines',
1217
                value => {
1218
                    register_id      => $register1->id,
1219
                    borrowernumber   => $patron->id,
1220
                    amount           => -5.00,
1221
                    credit_type_code => 'PAYMENT',
1222
                    payment_type     => 'CASH',
1223
                }
1224
            }
1225
        );
1226
1227
        my $cashup_start = $register1->start_cashup( { manager_id => $manager->id } );
1228
1229
        is( ref($cashup_start),        'Koha::Cash::Register::Cashup', 'start_cashup returns correct object type' );
1230
        is( $cashup_start->manager_id, $manager->id,                   'manager_id set correctly' );
1231
        is( $cashup_start->code,       'CASHUP_START',                 'code set correctly to CASHUP_START' );
1232
    };
1233
1234
    # Test 2: Missing manager_id
1235
    subtest 'missing_manager_id' => sub {
1236
        plan tests => 1;
1237
1238
        my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1239
1240
        eval { $register2->start_cashup( {} ); };
1241
        ok( $@, 'start_cashup fails when manager_id is missing' );
1242
    };
1243
1244
    # Test 3: Invalid manager_id
1245
    subtest 'invalid_manager_id' => sub {
1246
        plan tests => 1;
1247
1248
        my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1249
        my $patron    = $builder->build_object( { class => 'Koha::Patrons' } );
1250
1251
        # Add cash transaction before starting cashup
1252
        $builder->build_object(
1253
            {
1254
                class => 'Koha::Account::Lines',
1255
                value => {
1256
                    register_id      => $register3->id,
1257
                    borrowernumber   => $patron->id,
1258
                    amount           => -5.00,
1259
                    credit_type_code => 'PAYMENT',
1260
                    payment_type     => 'CASH',
1261
                }
1262
            }
1263
        );
1264
1265
        throws_ok {
1266
            $register3->start_cashup( { manager_id => 99999999 } );
1267
        }
1268
        'Koha::Exceptions::Object::FKConstraint', 'start_cashup throws FK constraint exception with invalid manager_id';
1269
    };
1270
1271
    # Test 4: Duplicate start_cashup prevention
1272
    subtest 'duplicate_prevention' => sub {
1273
        plan tests => 2;
1274
1275
        my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1276
        my $patron    = $builder->build_object( { class => 'Koha::Patrons' } );
1277
1278
        # Add cash transaction before starting cashup
1279
        $builder->build_object(
1280
            {
1281
                class => 'Koha::Account::Lines',
1282
                value => {
1283
                    register_id      => $register4->id,
1284
                    borrowernumber   => $patron->id,
1285
                    amount           => -5.00,
1286
                    credit_type_code => 'PAYMENT',
1287
                    payment_type     => 'CASH',
1288
                }
1289
            }
1290
        );
1291
1292
        # First start should succeed
1293
        my $first_start = $register4->start_cashup( { manager_id => $manager->id } );
1294
        ok( $first_start, 'First start_cashup succeeds' );
1295
1296
        # Second start should fail
1297
        throws_ok {
1298
            $register4->start_cashup( { manager_id => $manager->id } );
1299
        }
1300
        'Koha::Exceptions::Object::DuplicateID',
1301
            'Second start_cashup throws DuplicateID exception';
1302
    };
1303
1304
    # Test 5: Database transaction integrity
1305
    subtest 'transaction_integrity' => sub {
1306
        plan tests => 3;
1307
1308
        my $register5 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1309
1310
        # Add some transactions to establish expected amount
1311
        my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
1312
        my $account = $patron->account;
1313
1314
        my $fine = $account->add_debit(
1315
            {
1316
                amount    => '15.00',
1317
                type      => 'OVERDUE',
1318
                interface => 'cron'
1319
            }
1320
        );
1321
1322
        my $payment = $account->pay(
1323
            {
1324
                cash_register => $register5->id,
1325
                amount        => '15.00',
1326
                credit_type   => 'PAYMENT',
1327
                payment_type  => 'CASH',
1328
                lines         => [$fine]
1329
            }
1330
        );
1331
1332
        my $initial_action_count = $register5->_result->search_related('cash_register_actions')->count;
1333
1334
        my $start = $register5->start_cashup( { manager_id => $manager->id } );
1335
1336
        # Verify action was created
1337
        my $final_action_count = $register5->_result->search_related('cash_register_actions')->count;
1338
        is( $final_action_count, $initial_action_count + 1, 'CASHUP_START action created in database' );
1339
1340
        # Verify expected amount calculation (can be positive or negative, but not zero)
1341
        ok( $start->amount != 0, 'Expected amount calculated correctly' );
1342
1343
        # Verify timestamp is set
1344
        ok( defined $start->timestamp, 'Timestamp is set on CASHUP_START action' );
1345
    };
1346
1347
    $schema->storage->txn_rollback;
1348
};
1349
1350
subtest 'add_cashup' => sub {
1351
    plan tests => 5;
1352
1353
    $schema->storage->txn_begin;
1354
1355
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1356
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
1357
1358
    # Test 1: Valid parameters
1359
    subtest 'valid_parameters' => sub {
1360
        plan tests => 3;
1361
1362
        my $register1 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1363
1364
        my $cashup = $register1->add_cashup( { manager_id => $manager->id, amount => '10.00' } );
1365
1366
        is( ref($cashup),        'Koha::Cash::Register::Cashup', 'add_cashup returns correct object type' );
1367
        is( $cashup->manager_id, $manager->id,                   'manager_id set correctly' );
1368
        is( $cashup->amount + 0, 10,                             'amount set correctly' );
1369
    };
1370
1371
    # Test 2: Missing required parameters
1372
    subtest 'missing_parameters' => sub {
1373
        plan tests => 3;
1374
1375
        my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1376
1377
        # Missing manager_id
1378
        eval { $register2->add_cashup( { amount => '10.00' } ); };
1379
        ok( $@, 'add_cashup fails when manager_id is missing' );
1380
1381
        # Missing amount
1382
        eval { $register2->add_cashup( { manager_id => $manager->id } ); };
1383
        ok( $@, 'add_cashup fails when amount is missing' );
1384
1385
        # Missing both
1386
        eval { $register2->add_cashup( {} ); };
1387
        ok( $@, 'add_cashup fails when both parameters are missing' );
1388
    };
1389
1390
    # Test 3: Invalid amount parameter
1391
    subtest 'invalid_amount' => sub {
1392
        plan tests => 6;
1393
1394
        my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1395
1396
        # Zero amount is now valid (for non-cash transaction scenarios)
1397
        my $zero_cashup;
1398
        lives_ok {
1399
            $zero_cashup = $register3->add_cashup( { manager_id => $manager->id, amount => '0.00' } );
1400
        }
1401
        'Zero amount is accepted for non-cash transaction scenarios';
1402
        is( $zero_cashup->amount + 0, 0, 'Zero amount stored correctly' );
1403
1404
        # Negative amount is now valid (for float deficits)
1405
        my $negative_cashup;
1406
        lives_ok {
1407
            $negative_cashup = $register3->add_cashup( { manager_id => $manager->id, amount => '-5.00' } );
1408
        }
1409
        'Negative amount is accepted for float deficit scenarios';
1410
        is( $negative_cashup->amount + 0, -5, 'Negative amount stored correctly' );
1411
1412
        # Non-numeric amount
1413
        throws_ok {
1414
            $register3->add_cashup( { manager_id => $manager->id, amount => 'invalid' } );
1415
        }
1416
        'Koha::Exceptions::Account::AmountNotPositive',
1417
            'Non-numeric amount throws AmountNotPositive exception';
1418
1419
        # Empty string amount
1420
        throws_ok {
1421
            $register3->add_cashup( { manager_id => $manager->id, amount => '' } );
1422
        }
1423
        'Koha::Exceptions::Account::AmountNotPositive',
1424
            'Empty string amount throws AmountNotPositive exception';
1425
    };
1426
1427
    # Test 4: Reconciliation note handling
1428
    subtest 'reconciliation_note_handling' => sub {
1429
        plan tests => 4;
1430
1431
        my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1432
        my $patron    = $builder->build_object( { class => 'Koha::Patrons' } );
1433
        my $account   = $patron->account;
1434
1435
        # Create transaction to enable surplus creation
1436
        my $fine = $account->add_debit(
1437
            {
1438
                amount    => '10.00',
1439
                type      => 'OVERDUE',
1440
                interface => 'cron'
1441
            }
1442
        );
1443
1444
        my $payment = $account->pay(
1445
            {
1446
                cash_register => $register4->id,
1447
                amount        => '10.00',
1448
                credit_type   => 'PAYMENT',
1449
                payment_type  => 'CASH',
1450
                lines         => [$fine]
1451
            }
1452
        );
1453
1454
        # Test normal note
1455
        my $cashup1 = $register4->add_cashup(
1456
            {
1457
                manager_id          => $manager->id,
1458
                amount              => '15.00',                        # Creates surplus
1459
                reconciliation_note => 'Found extra money in drawer'
1460
            }
1461
        );
1462
1463
        my $surplus1 = Koha::Account::Lines->search(
1464
            {
1465
                register_id      => $register4->id,
1466
                credit_type_code => 'CASHUP_SURPLUS'
1467
            }
1468
        )->next;
1469
        is( $surplus1->note, 'Found extra money in drawer', 'Normal reconciliation note stored correctly' );
1470
1471
        # Test very long note (should be truncated)
1472
        my $register5 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1473
        my $long_note = 'x' x 1500;    # Longer than 1000 character limit
1474
1475
        my $fine2 = $account->add_debit(
1476
            {
1477
                amount    => '10.00',
1478
                type      => 'OVERDUE',
1479
                interface => 'cron'
1480
            }
1481
        );
1482
1483
        my $payment2 = $account->pay(
1484
            {
1485
                cash_register => $register5->id,
1486
                amount        => '10.00',
1487
                credit_type   => 'PAYMENT',
1488
                payment_type  => 'CASH',
1489
                lines         => [$fine2]
1490
            }
1491
        );
1492
1493
        my $cashup2 = $register5->add_cashup(
1494
            {
1495
                manager_id          => $manager->id,
1496
                amount              => '15.00',
1497
                reconciliation_note => $long_note
1498
            }
1499
        );
1500
1501
        my $surplus2 = Koha::Account::Lines->search(
1502
            {
1503
                register_id      => $register5->id,
1504
                credit_type_code => 'CASHUP_SURPLUS'
1505
            }
1506
        )->next;
1507
        is( length( $surplus2->note ), 1000, 'Long reconciliation note truncated to 1000 characters' );
1508
1509
        # Test whitespace-only note (should be undef)
1510
        my $register6 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1511
1512
        my $fine3 = $account->add_debit(
1513
            {
1514
                amount    => '10.00',
1515
                type      => 'OVERDUE',
1516
                interface => 'cron'
1517
            }
1518
        );
1519
1520
        my $payment3 = $account->pay(
1521
            {
1522
                cash_register => $register6->id,
1523
                amount        => '10.00',
1524
                credit_type   => 'PAYMENT',
1525
                payment_type  => 'CASH',
1526
                lines         => [$fine3]
1527
            }
1528
        );
1529
1530
        my $cashup3 = $register6->add_cashup(
1531
            {
1532
                manager_id          => $manager->id,
1533
                amount              => '15.00',
1534
                reconciliation_note => '   '           # Whitespace only
1535
            }
1536
        );
1537
1538
        my $surplus3 = Koha::Account::Lines->search(
1539
            {
1540
                register_id      => $register6->id,
1541
                credit_type_code => 'CASHUP_SURPLUS'
1542
            }
1543
        )->next;
1544
        is( $surplus3->note, undef, 'Whitespace-only reconciliation note stored as undef' );
1545
1546
        # Test empty string note (should be undef)
1547
        my $register7 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1548
1549
        my $fine4 = $account->add_debit(
1550
            {
1551
                amount    => '10.00',
1552
                type      => 'OVERDUE',
1553
                interface => 'cron'
1554
            }
1555
        );
1556
1557
        my $payment4 = $account->pay(
1558
            {
1559
                cash_register => $register7->id,
1560
                amount        => '10.00',
1561
                credit_type   => 'PAYMENT',
1562
                payment_type  => 'CASH',
1563
                lines         => [$fine4]
1564
            }
1565
        );
1566
1567
        my $cashup4 = $register7->add_cashup(
1568
            {
1569
                manager_id          => $manager->id,
1570
                amount              => '15.00',
1571
                reconciliation_note => ''              # Empty string
1572
            }
1573
        );
1574
1575
        my $surplus4 = Koha::Account::Lines->search(
1576
            {
1577
                register_id      => $register7->id,
1578
                credit_type_code => 'CASHUP_SURPLUS'
1579
            }
1580
        )->next;
1581
        is( $surplus4->note, undef, 'Empty string reconciliation note stored as undef' );
1582
    };
1583
1584
    # Test 5: Invalid manager_id
1585
    subtest 'invalid_manager_id' => sub {
1586
        plan tests => 1;
1587
1588
        my $register9 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1589
1590
        throws_ok {
1591
            $register9->add_cashup( { manager_id => 99999999, amount => '10.00' } );
1592
        }
1593
        'Koha::Exceptions::Object::FKConstraint', 'add_cashup throws FK constraint exception with invalid manager_id';
1594
    };
1595
1596
    $schema->storage->txn_rollback;
1597
};
1598
1599
subtest 'required_reconciliation_note' => sub {
1600
    plan tests => 4;
1601
1602
    $schema->storage->txn_begin;
1603
1604
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1605
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
1606
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
1607
    my $account  = $patron->account;
1608
1609
    # Create a cash transaction
1610
    my $fine = $account->add_debit(
1611
        {
1612
            amount    => '10.00',
1613
            type      => 'OVERDUE',
1614
            interface => 'cron'
1615
        }
1616
    );
1617
1618
    my $payment = $account->pay(
1619
        {
1620
            cash_register => $register->id,
1621
            amount        => '10.00',
1622
            credit_type   => 'PAYMENT',
1623
            payment_type  => 'CASH',
1624
            lines         => [$fine]
1625
        }
1626
    );
1627
1628
    # Enable the required note preference
1629
    t::lib::Mocks::mock_preference( 'CashupReconciliationNoteRequired', 1 );
1630
1631
    # Test 1: Missing note with discrepancy throws exception
1632
    throws_ok {
1633
        $register->add_cashup(
1634
            {
1635
                manager_id => $manager->id,
1636
                amount     => '15.00'         # Creates discrepancy
1637
            }
1638
        );
1639
    }
1640
    'Koha::Exceptions::MissingParameter',
1641
        'Missing reconciliation note with discrepancy throws MissingParameter exception when preference enabled';
1642
1643
    # Test 2: Note provided with discrepancy succeeds
1644
    my $cashup1;
1645
    lives_ok {
1646
        $cashup1 = $register->add_cashup(
1647
            {
1648
                manager_id          => $manager->id,
1649
                amount              => '15.00',
1650
                reconciliation_note => 'Found extra money'
1651
            }
1652
        );
1653
    }
1654
    'Cashup with note and discrepancy succeeds when preference enabled';
1655
1656
    # Test 3: No note with no discrepancy succeeds (note only required for discrepancies)
1657
    my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1658
    my $fine2     = $account->add_debit(
1659
        {
1660
            amount    => '20.00',
1661
            type      => 'OVERDUE',
1662
            interface => 'cron'
1663
        }
1664
    );
1665
1666
    my $payment2 = $account->pay(
1667
        {
1668
            cash_register => $register2->id,
1669
            amount        => '20.00',
1670
            credit_type   => 'PAYMENT',
1671
            payment_type  => 'CASH',
1672
            lines         => [$fine2]
1673
        }
1674
    );
1675
1676
    my $cashup2;
1677
    lives_ok {
1678
        $cashup2 = $register2->add_cashup(
1679
            {
1680
                manager_id => $manager->id,
1681
                amount     => '20.00'         # Exact amount, no discrepancy
1682
            }
1683
        );
1684
    }
1685
    'Cashup without note succeeds when there is no discrepancy';
1686
1687
    # Test 4: Preference disabled allows missing note even with discrepancy
1688
    t::lib::Mocks::mock_preference( 'CashupReconciliationNoteRequired', 0 );
1689
1690
    my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
1691
    my $fine3     = $account->add_debit(
1692
        {
1693
            amount    => '10.00',
1694
            type      => 'OVERDUE',
1695
            interface => 'cron'
1696
        }
1697
    );
1698
1699
    my $payment3 = $account->pay(
1700
        {
1701
            cash_register => $register3->id,
1702
            amount        => '10.00',
1703
            credit_type   => 'PAYMENT',
1704
            payment_type  => 'CASH',
1705
            lines         => [$fine3]
1706
        }
1707
    );
1708
1709
    my $cashup3;
1710
    lives_ok {
1711
        $cashup3 = $register3->add_cashup(
1712
            {
1713
                manager_id => $manager->id,
1714
                amount     => '15.00'         # Creates discrepancy
1715
            }
1716
        );
1717
    }
1718
    'Missing note with discrepancy succeeds when preference disabled';
1719
1720
    $schema->storage->txn_rollback;
1721
};
(-)a/t/db_dependent/Koha/Cash/Register/Cashup.t (-2 / +314 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
        # Add initial cash transaction before starting cashup
415
        my $initial_fine = $account2->add_debit(
416
            {
417
                amount    => '3.00',
418
                type      => 'OVERDUE',
419
                interface => 'cron'
420
            }
421
        );
422
423
        my $initial_payment = $account2->pay(
424
            {
425
                cash_register => $register2->id,
426
                amount        => '3.00',
427
                credit_type   => 'PAYMENT',
428
                payment_type  => 'CASH',
429
                lines         => [$initial_fine]
430
            }
431
        );
432
433
        # Start cashup first
434
        my $cashup_start = $register2->start_cashup( { manager_id => $manager->id } );
435
436
        # Add transaction after start
437
        my $fine = $account2->add_debit(
438
            {
439
                amount    => '5.00',
440
                type      => 'OVERDUE',
441
                interface => 'cron'
442
            }
443
        );
444
445
        my $payment = $account2->pay(
446
            {
447
                cash_register => $register2->id,
448
                amount        => '5.00',
449
                credit_type   => 'PAYMENT',
450
                payment_type  => 'CASH',
451
                lines         => [$fine]
452
            }
453
        );
454
455
        # Complete cashup
456
        my $cashup_complete = $register2->add_cashup( { manager_id => $manager->id, amount => '5.00' } );
457
458
        # Check accountlines
459
        my $accountlines = $cashup_complete->accountlines;
460
        is( ref($accountlines), 'Koha::Account::Lines', 'Two-phase accountlines returns correct type' );
461
        ok( $accountlines->count >= 0, 'Two-phase accountlines returns valid count' );
462
463
        # Check filtering capability
464
        my $filtered = $accountlines->search( { payment_type => 'CASH' } );
465
        ok( defined $filtered, 'Accountlines can be filtered' );
466
    };
467
468
    # Test 3: Reconciliation inclusion
469
    subtest 'reconciliation_inclusion' => sub {
470
        plan tests => 2;
471
472
        my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
473
        my $account3  = $patron->account;
474
475
        # Create transaction
476
        my $fine = $account3->add_debit(
477
            {
478
                amount    => '20.00',
479
                type      => 'OVERDUE',
480
                interface => 'cron'
481
            }
482
        );
483
484
        my $payment = $account3->pay(
485
            {
486
                cash_register => $register3->id,
487
                amount        => '20.00',
488
                credit_type   => 'PAYMENT',
489
                payment_type  => 'CASH',
490
                lines         => [$fine]
491
            }
492
        );
493
494
        # Cashup with surplus to create reconciliation line
495
        my $cashup = $register3->add_cashup(
496
            {
497
                manager_id => $manager->id,
498
                amount     => '25.00'         # Creates surplus
499
            }
500
        );
501
502
        my $accountlines = $cashup->accountlines;
503
        ok( $accountlines->count >= 1, 'Accountlines includes transactions when surplus created' );
504
505
        # Verify surplus line exists
506
        my $surplus_lines = $accountlines->search( { credit_type_code => 'CASHUP_SURPLUS' } );
507
        is( $surplus_lines->count, 1, 'Surplus reconciliation line is included' );
508
    };
509
510
    $schema->storage->txn_rollback;
511
};
512
513
subtest 'summary_session_boundaries' => sub {
514
    plan tests => 4;
515
516
    $schema->storage->txn_begin;
517
518
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
519
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
520
    my $manager  = $builder->build_object( { class => 'Koha::Patrons' } );
521
522
    # Test 1: Basic summary functionality
523
    subtest 'basic_summary_functionality' => sub {
524
        plan tests => 3;
525
526
        my $account = $patron->account;
527
528
        # Create a simple transaction and cashup
529
        my $fine = $account->add_debit(
530
            {
531
                amount    => '10.00',
532
                type      => 'OVERDUE',
533
                interface => 'cron'
534
            }
535
        );
536
537
        my $payment = $account->pay(
538
            {
539
                cash_register => $register->id,
540
                amount        => '10.00',
541
                credit_type   => 'PAYMENT',
542
                payment_type  => 'CASH',
543
                lines         => [$fine]
544
            }
545
        );
546
547
        my $cashup  = $register->add_cashup( { manager_id => $manager->id, amount => '10.00' } );
548
        my $summary = $cashup->summary;
549
550
        # Basic summary structure validation
551
        ok( defined $summary->{from_date} || !defined $summary->{from_date}, 'Summary has from_date field' );
552
        ok( defined $summary->{to_date},                                     'Summary has to_date field' );
553
        ok( defined $summary->{total},                                       'Summary has total field' );
554
    };
555
556
    # Test 2: Two-phase workflow basic functionality
557
    subtest 'two_phase_basic_functionality' => sub {
558
        plan tests => 4;
559
560
        my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
561
        my $account   = $patron->account;
562
563
        # Add initial cash transaction before starting cashup
564
        my $initial_fine = $account->add_debit(
565
            {
566
                amount    => '5.00',
567
                type      => 'OVERDUE',
568
                interface => 'cron'
569
            }
570
        );
571
572
        my $initial_payment = $account->pay(
573
            {
574
                cash_register => $register2->id,
575
                amount        => '5.00',
576
                credit_type   => 'PAYMENT',
577
                payment_type  => 'CASH',
578
                lines         => [$initial_fine]
579
            }
580
        );
581
582
        # Start two-phase cashup
583
        my $cashup_start = $register2->start_cashup( { manager_id => $manager->id } );
584
        ok( defined $cashup_start, 'Two-phase cashup can be started' );
585
586
        # Create transaction during session
587
        my $fine = $account->add_debit(
588
            {
589
                amount    => '15.00',
590
                type      => 'OVERDUE',
591
                interface => 'cron'
592
            }
593
        );
594
595
        my $payment = $account->pay(
596
            {
597
                cash_register => $register2->id,
598
                amount        => '15.00',
599
                credit_type   => 'PAYMENT',
600
                payment_type  => 'CASH',
601
                lines         => [$fine]
602
            }
603
        );
604
605
        # Complete two-phase cashup
606
        my $cashup_complete = $register2->add_cashup( { manager_id => $manager->id, amount => '15.00' } );
607
        ok( defined $cashup_complete, 'Two-phase cashup can be completed' );
608
609
        my $summary = $cashup_complete->summary;
610
        ok( defined $summary,          'Two-phase completed cashup has summary' );
611
        ok( defined $summary->{total}, 'Two-phase summary has total' );
612
    };
613
614
    # Test 3: Reconciliation functionality
615
    subtest 'reconciliation_functionality' => sub {
616
        plan tests => 2;
617
618
        my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
619
        my $account   = $patron->account;
620
621
        # Create transaction with surplus
622
        my $fine = $account->add_debit(
623
            {
624
                amount    => '20.00',
625
                type      => 'OVERDUE',
626
                interface => 'cron'
627
            }
628
        );
629
630
        my $payment = $account->pay(
631
            {
632
                cash_register => $register3->id,
633
                amount        => '20.00',
634
                credit_type   => 'PAYMENT',
635
                payment_type  => 'CASH',
636
                lines         => [$fine]
637
            }
638
        );
639
640
        # Cashup with surplus
641
        my $cashup = $register3->add_cashup(
642
            {
643
                manager_id => $manager->id,
644
                amount     => '25.00'         # Creates 5.00 surplus
645
            }
646
        );
647
648
        my $summary      = $cashup->summary;
649
        my $accountlines = $cashup->accountlines;
650
651
        ok( defined $summary, 'Cashup with reconciliation has summary' );
652
653
        # Check surplus reconciliation exists
654
        my $surplus_lines = $accountlines->search( { credit_type_code => 'CASHUP_SURPLUS' } );
655
        is( $surplus_lines->count, 1, 'Surplus reconciliation line is created and included' );
656
    };
657
658
    # Test 4: Edge cases
659
    subtest 'edge_cases' => sub {
660
        plan tests => 2;
661
662
        my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
663
664
        # Empty cashup
665
        my $empty_cashup = $register4->add_cashup( { manager_id => $manager->id, amount => '1.00' } );
666
        my $summary      = $empty_cashup->summary;
667
668
        ok( defined $summary,          'Empty cashup has summary' );
669
        ok( defined $summary->{total}, 'Empty cashup summary has total' );
670
    };
671
672
    $schema->storage->txn_rollback;
673
};
674
362
1;
675
1;
363
- 

Return to bug 40445