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

(-)a/Koha/Cash/Register.pm (-9 / +113 lines)
Lines 17-22 package Koha::Cash::Register; Link Here
17
17
18
use Modern::Perl;
18
use Modern::Perl;
19
19
20
use Koha::Account;
20
use Koha::Account::Lines;
21
use Koha::Account::Lines;
21
use Koha::Account::Offsets;
22
use Koha::Account::Offsets;
22
use Koha::Cash::Register::Actions;
23
use Koha::Cash::Register::Actions;
Lines 125-130 sub outstanding_accountlines { Link Here
125
        $since->count
126
        $since->count
126
        ? { 'date' => { '>' => $since->get_column('timestamp')->as_query } }
127
        ? { 'date' => { '>' => $since->get_column('timestamp')->as_query } }
127
        : {};
128
        : {};
129
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
    ];
128
    my $merged_conditions =
145
    my $merged_conditions =
129
        $conditions
146
        $conditions
130
        ? { %{$conditions}, %{$local_conditions} }
147
        ? { %{$conditions}, %{$local_conditions} }
Lines 208-234 sub drop_default { Link Here
208
225
209
    my $cashup = $cash_register->add_cashup(
226
    my $cashup = $cash_register->add_cashup(
210
        {
227
        {
211
            manager_id => $logged_in_user->id,
228
            manager_id            => $logged_in_user->id,
212
            amount     => $cash_register->outstanding_accountlines->total
229
            amount                => $amount_removed_from_register,
230
            [ reconciliation_note => $reconciliation_note ]
213
        }
231
        }
214
    );
232
    );
215
233
216
Add a new cashup action to the till, returns the added action.
234
Add a new cashup action to the till, returns the added action.
235
If amount differs from expected amount, creates surplus/deficit accountlines.
217
236
218
=cut
237
=cut
219
238
220
sub add_cashup {
239
sub add_cashup {
221
    my ( $self, $params ) = @_;
240
    my ( $self, $params ) = @_;
222
241
223
    my $rs = $self->_result->add_to_cash_register_actions(
242
    my $manager_id          = $params->{manager_id};
224
        {
243
    my $amount              = $params->{amount};
225
            code       => 'CASHUP',
244
    my $reconciliation_note = $params->{reconciliation_note};
226
            manager_id => $params->{manager_id},
245
227
            amount     => $params->{amount}
246
    # Sanitize reconciliation note - treat empty/whitespace-only as undef
247
    if ( defined $reconciliation_note ) {
248
        $reconciliation_note = substr( $reconciliation_note, 0, 1000 );    # Limit length
249
        $reconciliation_note =~ s/^\s+|\s+$//g;                            # Trim whitespace
250
        $reconciliation_note = undef if $reconciliation_note eq '';        # Empty after trim = undef
251
    }
252
253
    # Calculate expected amount from outstanding accountlines
254
    my $expected_amount = $self->outstanding_accountlines->total;
255
256
    # For backward compatibility, if no actual amount is specified, use expected amount
257
    $amount //= abs($expected_amount);
258
259
    # Calculate difference (actual - expected)
260
    my $difference = $amount - abs($expected_amount);
261
262
    # Use database transaction to ensure consistency
263
    my $schema = $self->_result->result_source->schema;
264
    my $cashup;
265
266
    $schema->txn_do(
267
        sub {
268
            # Create the cashup action with actual amount
269
            my $rs = $self->_result->add_to_cash_register_actions(
270
                {
271
                    code       => 'CASHUP',
272
                    manager_id => $manager_id,
273
                    amount     => $amount
274
                }
275
            )->discard_changes;
276
277
            $cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs);
278
279
            # Create reconciliation accountline if there's a difference
280
            if ( $difference != 0 ) {
281
282
                if ( $difference > 0 ) {
283
284
                    # Surplus: more cash found than expected (credits are negative amounts)
285
                    my $surplus = Koha::Account::Line->new(
286
                        {
287
                            date             => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
288
                            amount           => -abs($difference),                             # Credits are negative
289
                            description      => 'Cash register surplus found during cashup',
290
                            credit_type_code => 'CASHUP_SURPLUS',
291
                            manager_id       => $manager_id,
292
                            interface        => 'intranet',
293
                            register_id      => $self->id,
294
                            note             => $reconciliation_note
295
                        }
296
                    )->store();
297
298
                    # Record the account offset
299
                    my $account_offset = Koha::Account::Offset->new(
300
                        {
301
                            credit_id => $surplus->id,
302
                            type      => 'CREATE',
303
                            amount    => -abs($difference)    # Offsets match the line amount
304
                        }
305
                    )->store();
306
307
                } else {
308
309
                    # Deficit: less cash found than expected
310
                    my $deficit = Koha::Account::Line->new(
311
                        {
312
                            date            => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
313
                            amount          => abs($difference),
314
                            description     => 'Cash register deficit found during cashup',
315
                            debit_type_code => 'CASHUP_DEFICIT',
316
                            manager_id      => $manager_id,
317
                            interface       => 'intranet',
318
                            register_id     => $self->id,
319
                            note            => $reconciliation_note
320
                        }
321
                    )->store();
322
                    my $account_offset = Koha::Account::Offset->new(
323
                        {
324
                            debit_id => $deficit->id,
325
                            type     => 'CREATE',
326
                            amount   => abs($difference)    # Debits have positive offsets
327
                        }
328
                    )->store();
329
330
                }
331
            }
228
        }
332
        }
229
    )->discard_changes;
333
    );
230
334
231
    return Koha::Cash::Register::Cashup->_new_from_dbic($rs);
335
    return $cashup;
232
}
336
}
233
337
234
=head3 to_api_mapping
338
=head3 to_api_mapping
(-)a/Koha/Cash/Register/Cashup.pm (-3 / +24 lines)
Lines 80-89 sub summary { Link Here
80
        : { 'date' => { '<'        => $self->timestamp } };
80
        : { 'date' => { '<'        => $self->timestamp } };
81
81
82
    my $payout_transactions = $self->register->accountlines->search(
82
    my $payout_transactions = $self->register->accountlines->search(
83
        { %{$conditions}, credit_type_code => undef },
83
        {
84
            %{$conditions},
85
            credit_type_code => undef,
86
            debit_type_code  => { '!=' => 'CASHUP_DEFICIT' }
87
        },
84
    );
88
    );
85
    my $income_transactions = $self->register->accountlines->search(
89
    my $income_transactions = $self->register->accountlines->search(
86
        { %{$conditions}, debit_type_code => undef },
90
        {
91
            %{$conditions},
92
            debit_type_code  => undef,
93
            credit_type_code => { '!=' => 'CASHUP_SURPLUS' }
94
        },
87
    );
95
    );
88
96
89
    my $income_summary = Koha::Account::Offsets->search(
97
    my $income_summary = Koha::Account::Offsets->search(
Lines 173-178 sub summary { Link Here
173
        push @total_grouped, { payment_type => $type->lib, total => $typed_total };
181
        push @total_grouped, { payment_type => $type->lib, total => $typed_total };
174
    }
182
    }
175
183
184
    # Check for reconciliation lines separately (for footer display only)
185
    my $surplus_lines =
186
        $self->register->accountlines->search( { %{$conditions}, credit_type_code => 'CASHUP_SURPLUS' } );
187
    my $deficit_lines =
188
        $self->register->accountlines->search( { %{$conditions}, debit_type_code => 'CASHUP_DEFICIT' } );
189
190
    my $surplus_total = $surplus_lines->count ? $surplus_lines->total : undef;
191
    my $deficit_total = $deficit_lines->count ? $deficit_lines->total : undef;
192
176
    $summary = {
193
    $summary = {
177
        from_date      => $previous ? $previous->timestamp : undef,
194
        from_date      => $previous ? $previous->timestamp : undef,
178
        to_date        => $self->timestamp,
195
        to_date        => $self->timestamp,
Lines 181-187 sub summary { Link Here
181
        payout_grouped => \@payout,
198
        payout_grouped => \@payout,
182
        payout_total   => abs($payout_total),
199
        payout_total   => abs($payout_total),
183
        total          => $total * -1,
200
        total          => $total * -1,
184
        total_grouped  => \@total_grouped
201
        total_grouped  => \@total_grouped,
202
203
        # Reconciliation data for footer display
204
        surplus_total => $surplus_total ? $surplus_total * 1 : undef,
205
        deficit_total => $deficit_total ? $deficit_total * 1 : undef
185
    };
206
    };
186
207
187
    return $summary;
208
    return $summary;
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/cashup_summary.inc (-1 / +27 lines)
Lines 11-17 Link Here
11
                    <li>Cash register: <span id="register_description"></span></li>
11
                    <li>Cash register: <span id="register_description"></span></li>
12
                    <li>Period: <span id="from_date"></span> to <span id="to_date"></span></li>
12
                    <li>Period: <span id="from_date"></span> to <span id="to_date"></span></li>
13
                </ul>
13
                </ul>
14
                <table>
14
15
                <table class="table table-striped">
15
                    <thead>
16
                    <thead>
16
                        <tr>
17
                        <tr>
17
                            <th>Type</th>
18
                            <th>Type</th>
Lines 21-26 Link Here
21
                    <tbody> </tbody>
22
                    <tbody> </tbody>
22
                    <tfoot> </tfoot>
23
                    <tfoot> </tfoot>
23
                </table>
24
                </table>
25
26
                <style>
27
                    #cashupSummaryModal .reconciliation-separator hr {
28
                        margin: 0.5rem 0;
29
                        border-color: #dee2e6;
30
                    }
31
32
                    #cashupSummaryModal .reconciliation-info {
33
                        background-color: #f8f9fa;
34
                    }
35
36
                    #cashupSummaryModal .reconciliation-result.text-warning {
37
                        background-color: #fff3cd;
38
                        color: #856404;
39
                    }
40
41
                    #cashupSummaryModal .reconciliation-result.text-danger {
42
                        background-color: #f8d7da;
43
                        color: #721c24;
44
                    }
45
46
                    #cashupSummaryModal .total-row {
47
                        border-top: 2px solid #dee2e6;
48
                    }
49
                </style>
24
            </div>
50
            </div>
25
            <!-- /.modal-body -->
51
            <!-- /.modal-body -->
26
            <div class="modal-footer">
52
            <div class="modal-footer">
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/pos/register.tt (-3 / +85 lines)
Lines 48-53 Link Here
48
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform cashup actions. </div>
48
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform cashup actions. </div>
49
        [% END %]
49
        [% END %]
50
50
51
        [% IF ( error_cashup_amount ) %]
52
            <div id="error_message" class="alert alert-warning"> Invalid amount entered for cashup. Please enter a valid monetary amount. </div>
53
        [% END %]
54
51
        [% IF ( error_refund_permission ) %]
55
        [% IF ( error_refund_permission ) %]
52
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform refund actions. </div>
56
            <div id="error_message" class="alert alert-warning"> You do not have permission to perform refund actions. </div>
53
        [% END %]
57
        [% END %]
Lines 366-372 Link Here
366
370
367
<!-- Confirm cashup modal -->
371
<!-- Confirm cashup modal -->
368
<div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel">
372
<div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel">
369
    <form id="cashup_form" method="post" enctype="multipart/form-data">
373
    <form id="cashup_form" method="post" enctype="multipart/form-data" class="validated">
370
        [% INCLUDE 'csrf-token.inc' %]
374
        [% INCLUDE 'csrf-token.inc' %]
371
        <div class="modal-dialog">
375
        <div class="modal-dialog">
372
            <div class="modal-content">
376
            <div class="modal-content">
Lines 375-387 Link Here
375
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
379
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
376
                </div>
380
                </div>
377
                <div class="modal-body">
381
                <div class="modal-body">
378
                    Please confirm that you have removed [% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %] from the cash register and left a float of [% register.starting_float | $Price %].
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>
379
                </div>
408
                </div>
380
                <!-- /.modal-body -->
409
                <!-- /.modal-body -->
381
                <div class="modal-footer">
410
                <div class="modal-footer">
382
                    <input type="hidden" name="registerid" value="[% register.id | html %]" />
411
                    <input type="hidden" name="registerid" value="[% register.id | html %]" />
383
                    <input type="hidden" name="op" value="cud-cashup" />
412
                    <input type="hidden" name="op" value="cud-cashup" />
384
                    <button type="submit" class="btn btn-primary" id="pos_cashup_confirm">Confirm</button>
413
                    <button type="submit" class="btn btn-primary" id="pos_cashup_confirm">Confirm cashup</button>
385
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
414
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
386
                </div>
415
                </div>
387
                <!-- /.modal-footer -->
416
                <!-- /.modal-footer -->
Lines 573-578 Link Here
573
                }
602
                }
574
            ]
603
            ]
575
        }, null, 1);
604
        }, null, 1);
605
606
        // Real-time reconciliation calculation for cashup modal
607
        $("#amount").on("input", function() {
608
            var actualAmount = parseFloat($(this).val()) || 0;
609
            var expectedText = $("#expected_amount").text().replace(/[£$,]/g, '');
610
            var expectedAmount = parseFloat(expectedText) || 0;
611
            var difference = actualAmount - expectedAmount;
612
613
            if ($(this).val() && !isNaN(actualAmount)) {
614
                var reconciliationText = "";
615
                var reconciliationClass = "";
616
                var hasDiscrepancy = false;
617
618
                if (difference > 0) {
619
                    reconciliationText = "Surplus: " + difference.format_price();
620
                    reconciliationClass = "success";
621
                    hasDiscrepancy = true;
622
                } else if (difference < 0) {
623
                    reconciliationText = "Deficit: " + Math.abs(difference).format_price();
624
                    reconciliationClass = "warning";
625
                    hasDiscrepancy = true;
626
                } else {
627
                    reconciliationText = "Balanced - no surplus or deficit";
628
                    reconciliationClass = "success";
629
                    hasDiscrepancy = false;
630
                }
631
632
                $("#reconciliation_text").text(reconciliationText)
633
                    .removeClass("success warning")
634
                    .addClass(reconciliationClass);
635
                $("#reconciliation_display").show();
636
637
                // Show/hide note field based on whether there's a discrepancy
638
                if (hasDiscrepancy) {
639
                    $("#reconciliation_note_field").show();
640
                } else {
641
                    $("#reconciliation_note_field").hide();
642
                    $("#reconciliation_note").val(''); // Clear note when balanced
643
                }
644
            } else {
645
                $("#reconciliation_display").hide();
646
                $("#reconciliation_note_field").hide();
647
            }
648
        });
649
650
        // Reset modal when opened
651
        $("#confirmCashupModal").on("shown.bs.modal", function() {
652
            // Start with empty actual amount field (user must enter amount)
653
            $("#amount").val('').focus();
654
            $("#reconciliation_display").hide();
655
            $("#reconciliation_note_field").hide();
656
            $("#reconciliation_note").val('');
657
        });
576
    </script>
658
    </script>
577
[% END %]
659
[% END %]
578
660
(-)a/koha-tmpl/intranet-tmpl/prog/js/cashup_modal.js (-6 / +82 lines)
Lines 16-21 $(document).ready(function () { Link Here
16
                summary_modal.find("#from_date").text(from_date);
16
                summary_modal.find("#from_date").text(from_date);
17
                let to_date = $datetime(data.summary.to_date);
17
                let to_date = $datetime(data.summary.to_date);
18
                summary_modal.find("#to_date").text(to_date);
18
                summary_modal.find("#to_date").text(to_date);
19
20
                // Check for reconciliation (surplus or deficit) from dedicated fields
21
                var surplus = data.summary.surplus_total;
22
                var deficit = data.summary.deficit_total;
23
                var expectedAmount = data.summary.total;
24
                var actualAmount = data.amount;
25
19
                var tbody = summary_modal.find("tbody");
26
                var tbody = summary_modal.find("tbody");
20
                tbody.empty();
27
                tbody.empty();
21
                for (out of data.summary.payout_grouped) {
28
                for (out of data.summary.payout_grouped) {
Lines 61-82 $(document).ready(function () { Link Here
61
68
62
                var tfoot = summary_modal.find("tfoot");
69
                var tfoot = summary_modal.find("tfoot");
63
                tfoot.empty();
70
                tfoot.empty();
71
72
                // 1. Total (sum of all transactions)
64
                tfoot.append(
73
                tfoot.append(
65
                    "<tr><td>Total</td><td>" +
74
                    "<tr class='total-row'><td><strong>Total</strong></td><td><strong>" +
66
                        data.summary.total.format_price() +
75
                        data.summary.total.format_price() +
67
                        "</td></tr>"
76
                        "</strong></td></tr>"
77
                );
78
79
                // Add separator line
80
                tfoot.append(
81
                    "<tr class='reconciliation-separator'><td colspan='2'><hr></td></tr>"
68
                );
82
                );
83
84
                // 2. Cash collected (amount recorded as removed from register)
85
                var cashCollected = null;
69
                for (type of data.summary.total_grouped) {
86
                for (type of data.summary.total_grouped) {
70
                    if (type.total !== 0) {
87
                    if (
88
                        type.payment_type === "Cash" ||
89
                        type.payment_type === "CASH"
90
                    ) {
91
                        cashCollected = type.total;
92
                        break;
93
                    }
94
                }
95
                if (cashCollected !== null) {
96
                    tfoot.append(
97
                        "<tr><td><strong>Cash collected</strong></td><td><strong>" +
98
                            cashCollected.format_price() +
99
                            "</strong></td></tr>"
100
                    );
101
                }
102
103
                // 3. Other payment types collected (excluding CASH)
104
                for (type of data.summary.total_grouped) {
105
                    if (
106
                        type.total !== 0 &&
107
                        type.payment_type !== "Cash" &&
108
                        type.payment_type !== "CASH"
109
                    ) {
71
                        tfoot.append(
110
                        tfoot.append(
72
                            "<tr><td>" +
111
                            "<tr><td><strong>" +
73
                                escape_str(type.payment_type) +
112
                                escape_str(type.payment_type) +
74
                                "</td><td>" +
113
                                " collected" +
114
                                "</strong></td><td><strong>" +
75
                                type.total.format_price() +
115
                                type.total.format_price() +
76
                                "</td></tr>"
116
                                "</strong></td></tr>"
77
                        );
117
                        );
78
                    }
118
                    }
79
                }
119
                }
120
121
                // 4. Cashup surplus OR deficit (highlighted)
122
                if (surplus || deficit) {
123
                    // Add separator before reconciliation
124
                    tfoot.append(
125
                        "<tr class='reconciliation-separator'><td colspan='2'><hr></td></tr>"
126
                    );
127
128
                    var reconciliationClass,
129
                        reconciliationLabel,
130
                        reconciliationAmount;
131
132
                    if (surplus) {
133
                        reconciliationClass =
134
                            "reconciliation-result text-warning";
135
                        reconciliationLabel = "Cashup surplus";
136
                        reconciliationAmount =
137
                            "+" + Math.abs(surplus).format_price();
138
                    } else if (deficit) {
139
                        reconciliationClass =
140
                            "reconciliation-result text-danger";
141
                        reconciliationLabel = "Cashup deficit";
142
                        reconciliationAmount =
143
                            "-" + Math.abs(deficit).format_price();
144
                    }
145
146
                    tfoot.append(
147
                        "<tr class='" +
148
                            reconciliationClass +
149
                            "'><td><strong>" +
150
                            reconciliationLabel +
151
                            "</strong></td><td><strong>" +
152
                            reconciliationAmount +
153
                            "</strong></td></tr>"
154
                    );
155
                }
80
            },
156
            },
81
        });
157
        });
82
    });
158
    });
(-)a/pos/register.pl (-8 / +25 lines)
Lines 104-119 if ( !$registers->count ) { Link Here
104
    my $op = $input->param('op') // '';
104
    my $op = $input->param('op') // '';
105
    if ( $op eq 'cud-cashup' ) {
105
    if ( $op eq 'cud-cashup' ) {
106
        if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
106
        if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
107
            $cash_register->add_cashup(
107
            my $amount              = $input->param('amount');
108
                {
108
            my $reconciliation_note = $input->param('reconciliation_note');
109
                    manager_id => $logged_in_user->id,
109
110
                    amount     => $cash_register->outstanding_accountlines->total
110
            if ( defined $amount && $amount =~ /^\d+(?:\.\d{1,2})?$/ ) {
111
112
                # Sanitize and limit note length
113
                if ( defined $reconciliation_note ) {
114
                    $reconciliation_note = substr( $reconciliation_note, 0, 1000 );
115
                    $reconciliation_note =~ s/^\s+|\s+$//g;    # Trim whitespace
116
                    $reconciliation_note = undef if $reconciliation_note eq '';
111
                }
117
                }
112
            );
113
118
114
            # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
119
                $cash_register->add_cashup(
115
            print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid );
120
                    {
116
            exit;
121
                        manager_id          => $logged_in_user->id,
122
                        amount              => $amount,
123
                        reconciliation_note => $reconciliation_note
124
                    }
125
                );
126
127
                # Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
128
                print $input->redirect( "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid );
129
                exit;
130
131
            } else {
132
                $template->param( error_cashup_amount => 1 );
133
            }
117
        } else {
134
        } else {
118
            $template->param( error_cashup_permission => 1 );
135
            $template->param( error_cashup_permission => 1 );
119
        }
136
        }
(-)a/t/db_dependent/Koha/Cash/Register.t (-2 / +434 lines)
Lines 20-30 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 => 5;
23
use Test::More tests => 6;
24
24
25
use Test::Exception;
25
use Test::Exception;
26
26
27
use Koha::Database;
27
use Koha::Database;
28
use Koha::Account;
29
use Koha::Account::CreditTypes;
30
use Koha::Account::DebitTypes;
28
31
29
use t::lib::TestBuilder;
32
use t::lib::TestBuilder;
30
33
Lines 312-314 subtest 'cashup' => sub { Link Here
312
315
313
    $schema->storage->txn_rollback;
316
    $schema->storage->txn_rollback;
314
};
317
};
315
- 
318
319
subtest 'cashup_reconciliation' => sub {
320
    plan tests => 5;
321
322
    $schema->storage->txn_begin;
323
324
    # Ensure required account types for reconciliation exist (they should already exist from mandatory data)
325
    use Koha::Account::CreditTypes;
326
    use Koha::Account::DebitTypes;
327
328
    my $surplus_credit_type = Koha::Account::CreditTypes->find( { code => 'CASHUP_SURPLUS' } );
329
    if ( !$surplus_credit_type ) {
330
        $surplus_credit_type = $builder->build_object(
331
            {
332
                class => 'Koha::Account::CreditTypes',
333
                value => {
334
                    code                  => 'CASHUP_SURPLUS',
335
                    description           => 'Cash register surplus found during cashup',
336
                    can_be_added_manually => 0,
337
                    credit_number_enabled => 0,
338
                    is_system             => 1,
339
                    archived              => 0,
340
                }
341
            }
342
        );
343
    }
344
345
    my $deficit_debit_type = Koha::Account::DebitTypes->find( { code => 'CASHUP_DEFICIT' } );
346
    if ( !$deficit_debit_type ) {
347
        $deficit_debit_type = $builder->build_object(
348
            {
349
                class => 'Koha::Account::DebitTypes',
350
                value => {
351
                    code                => 'CASHUP_DEFICIT',
352
                    description         => 'Cash register deficit found during cashup',
353
                    can_be_invoiced     => 0,
354
                    can_be_sold         => 0,
355
                    default_amount      => undef,
356
                    is_system           => 1,
357
                    archived            => 0,
358
                    restricts_checkouts => 0,
359
                }
360
            }
361
        );
362
    }
363
364
    my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
365
    my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
366
367
    # Create some outstanding accountlines to establish expected amount
368
    my $accountline1 = $builder->build_object(
369
        {
370
            class => 'Koha::Account::Lines',
371
            value => {
372
                register_id      => $register->id,
373
                borrowernumber   => $patron->id,
374
                amount           => -10.00,          # Credit (payment)
375
                credit_type_code => 'PAYMENT',
376
                debit_type_code  => undef,
377
            }
378
        }
379
    );
380
    my $accountline2 = $builder->build_object(
381
        {
382
            class => 'Koha::Account::Lines',
383
            value => {
384
                register_id      => $register->id,
385
                borrowernumber   => $patron->id,
386
                amount           => -5.00,           # Credit (payment)
387
                credit_type_code => 'PAYMENT',
388
                debit_type_code  => undef,
389
            }
390
        }
391
    );
392
393
    my $expected_amount = $register->outstanding_accountlines->total;    # Should be -15.00
394
395
    subtest 'balanced_cashup' => sub {
396
        plan tests => 3;
397
398
        # Test exact match - no surplus/deficit accountlines should be created
399
        my $amount = abs($expected_amount);                              # 15.00 actual matches 15.00 expected
400
401
        my $cashup = $register->add_cashup(
402
            {
403
                manager_id => $patron->id,
404
                amount     => $amount
405
            }
406
        );
407
408
        ok( $cashup, 'Cashup created successfully' );
409
        is( sprintf( '%.0f', $cashup->amount ), sprintf( '%.0f', $amount ), 'Cashup amount matches actual amount' );
410
411
        # Check no surplus/deficit accountlines were created
412
        my $reconciliation_lines = Koha::Account::Lines->search(
413
            {
414
                register_id => $register->id,
415
                '-or'       => [
416
                    { credit_type_code => 'CASHUP_SURPLUS' },
417
                    { debit_type_code  => 'CASHUP_DEFICIT' }
418
                ]
419
            }
420
        );
421
422
        is( $reconciliation_lines->count, 0, 'No reconciliation accountlines created for balanced cashup' );
423
    };
424
425
    subtest 'surplus_cashup' => sub {
426
        plan tests => 7;
427
428
        $schema->storage->txn_begin;
429
430
        my $register2    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
431
        my $accountline3 = $builder->build_object(
432
            {
433
                class => 'Koha::Account::Lines',
434
                value => {
435
                    register_id      => $register2->id,
436
                    borrowernumber   => $patron->id,
437
                    amount           => -20.00,           # Credit (payment)
438
                    credit_type_code => 'PAYMENT',
439
                    debit_type_code  => undef,
440
                }
441
            }
442
        );
443
444
        my $expected = abs( $register2->outstanding_accountlines->total );    # 20.00
445
        my $actual   = 25.00;                                                 # 5.00 surplus
446
        my $surplus  = $actual - $expected;
447
448
        my $cashup = $register2->add_cashup(
449
            {
450
                manager_id => $patron->id,
451
                amount     => $actual
452
            }
453
        );
454
455
        ok( $cashup, 'Surplus cashup created successfully' );
456
        is( sprintf( '%.0f', $cashup->amount ), sprintf( '%.0f', $actual ), 'Cashup amount matches actual amount' );
457
458
        # Check surplus accountline was created
459
        my $surplus_lines = Koha::Account::Lines->search(
460
            {
461
                register_id      => $register2->id,
462
                credit_type_code => 'CASHUP_SURPLUS'
463
            }
464
        );
465
466
        is( $surplus_lines->count, 1, 'One surplus accountline created' );
467
468
        my $surplus_line = $surplus_lines->next;
469
        is(
470
            sprintf( '%.0f', $surplus_line->amount ), sprintf( '%.0f', -$surplus ),
471
            'Surplus amount is correct (negative for credit)'
472
        );
473
474
        # Note should be undef for surplus without user note
475
        is( $surplus_line->note, undef, 'No note for surplus without user reconciliation note' );
476
477
        # Test surplus with user note
478
        my $register_with_note    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
479
        my $accountline_with_note = $builder->build_object(
480
            {
481
                class => 'Koha::Account::Lines',
482
                value => {
483
                    register_id      => $register_with_note->id,
484
                    borrowernumber   => $patron->id,
485
                    amount           => -10.00,
486
                    credit_type_code => 'PAYMENT',
487
                    debit_type_code  => undef,
488
                }
489
            }
490
        );
491
492
        my $cashup_with_note = $register_with_note->add_cashup(
493
            {
494
                manager_id          => $patron->id,
495
                amount              => 15.00,                                           # 5.00 surplus
496
                reconciliation_note => 'Found extra \x{00A3}5 under the till drawer'    # £5 in UTF-8
497
            }
498
        );
499
500
        my $surplus_with_note = Koha::Account::Lines->search(
501
            {
502
                register_id      => $register_with_note->id,
503
                credit_type_code => 'CASHUP_SURPLUS'
504
            }
505
        )->next;
506
507
        like(
508
            $surplus_with_note->note, qr/Found extra .+5 under the till drawer/,
509
            'User note included in surplus accountline'
510
        );
511
        is(
512
            $surplus_with_note->note, 'Found extra \x{00A3}5 under the till drawer',
513
            'Only user note stored (no base reconciliation info)'
514
        );
515
516
        $schema->storage->txn_rollback;
517
    };
518
519
    subtest 'deficit_cashup' => sub {
520
        plan tests => 7;
521
522
        $schema->storage->txn_begin;
523
524
        my $register3    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
525
        my $accountline4 = $builder->build_object(
526
            {
527
                class => 'Koha::Account::Lines',
528
                value => {
529
                    register_id      => $register3->id,
530
                    borrowernumber   => $patron->id,
531
                    amount           => -30.00,           # Credit (payment)
532
                    credit_type_code => 'PAYMENT',
533
                    debit_type_code  => undef,
534
                }
535
            }
536
        );
537
538
        my $expected = abs( $register3->outstanding_accountlines->total );    # 30.00
539
        my $actual   = 25.00;                                                 # 5.00 deficit
540
        my $deficit  = $expected - $actual;
541
542
        my $cashup = $register3->add_cashup(
543
            {
544
                manager_id => $patron->id,
545
                amount     => $actual
546
            }
547
        );
548
549
        ok( $cashup, 'Deficit cashup created successfully' );
550
        is( sprintf( '%.0f', $cashup->amount ), sprintf( '%.0f', $actual ), 'Cashup amount matches actual amount' );
551
552
        # Check deficit accountline was created
553
        my $deficit_lines = Koha::Account::Lines->search(
554
            {
555
                register_id     => $register3->id,
556
                debit_type_code => 'CASHUP_DEFICIT'
557
            }
558
        );
559
560
        is( $deficit_lines->count, 1, 'One deficit accountline created' );
561
562
        my $deficit_line = $deficit_lines->next;
563
        is(
564
            sprintf( '%.0f', $deficit_line->amount ), sprintf( '%.0f', $deficit ),
565
            'Deficit amount is correct (positive for debit)'
566
        );
567
568
        # Note should be undef for deficit without user note
569
        is( $deficit_line->note, undef, 'No note for deficit without user reconciliation note' );
570
571
        # Test deficit with user note
572
        my $register_deficit_note    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
573
        my $accountline_deficit_note = $builder->build_object(
574
            {
575
                class => 'Koha::Account::Lines',
576
                value => {
577
                    register_id      => $register_deficit_note->id,
578
                    borrowernumber   => $patron->id,
579
                    amount           => -20.00,
580
                    credit_type_code => 'PAYMENT',
581
                    debit_type_code  => undef,
582
                }
583
            }
584
        );
585
586
        my $cashup_deficit_note = $register_deficit_note->add_cashup(
587
            {
588
                manager_id          => $patron->id,
589
                amount              => 15.00,                                                     # 5.00 deficit
590
                reconciliation_note => 'Till was short, possibly due to incorrect change given'
591
            }
592
        );
593
594
        my $deficit_with_note = Koha::Account::Lines->search(
595
            {
596
                register_id     => $register_deficit_note->id,
597
                debit_type_code => 'CASHUP_DEFICIT'
598
            }
599
        )->next;
600
601
        like(
602
            $deficit_with_note->note, qr/Till was short, possibly due to incorrect change given/,
603
            'User note included in deficit accountline'
604
        );
605
        is(
606
            $deficit_with_note->note, 'Till was short, possibly due to incorrect change given',
607
            'Only user note stored (no base reconciliation info)'
608
        );
609
610
        $schema->storage->txn_rollback;
611
    };
612
613
    subtest 'transaction_integrity' => sub {
614
        plan tests => 4;
615
616
        $schema->storage->txn_begin;
617
618
        my $register4    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
619
        my $accountline5 = $builder->build_object(
620
            {
621
                class => 'Koha::Account::Lines',
622
                value => {
623
                    register_id      => $register4->id,
624
                    borrowernumber   => $patron->id,
625
                    amount           => -10.00,
626
                    credit_type_code => 'PAYMENT',
627
                    debit_type_code  => undef,
628
                }
629
            }
630
        );
631
632
        my $initial_accountline_count = Koha::Account::Lines->search( { register_id => $register4->id } )->count;
633
634
        my $initial_action_count = $register4->cashups->count;
635
636
        # Test successful transaction
637
        my $cashup = $register4->add_cashup(
638
            {
639
                manager_id => $patron->id,
640
                amount     => 15.00          # Creates surplus
641
            }
642
        );
643
644
        # Check both cashup action and surplus accountline were created
645
        is( $register4->cashups->count, $initial_action_count + 1, 'Cashup action created' );
646
647
        my $final_accountline_count = Koha::Account::Lines->search( { register_id => $register4->id } )->count;
648
649
        is( $final_accountline_count, $initial_accountline_count + 1, 'Surplus accountline created' );
650
651
        # Verify the new accountline is the surplus
652
        my $surplus_line = Koha::Account::Lines->search(
653
            {
654
                register_id      => $register4->id,
655
                credit_type_code => 'CASHUP_SURPLUS'
656
            }
657
        )->next;
658
659
        ok( $surplus_line, 'Surplus accountline exists' );
660
        is( $surplus_line->register_id, $register4->id, 'Surplus linked to correct register' );
661
662
        $schema->storage->txn_rollback;
663
    };
664
665
    subtest 'note_handling' => sub {
666
        plan tests => 2;
667
668
        $schema->storage->txn_begin;
669
670
        my $register_note_test    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
671
        my $accountline_note_test = $builder->build_object(
672
            {
673
                class => 'Koha::Account::Lines',
674
                value => {
675
                    register_id      => $register_note_test->id,
676
                    borrowernumber   => $patron->id,
677
                    amount           => -10.00,
678
                    credit_type_code => 'PAYMENT',
679
                    debit_type_code  => undef,
680
                }
681
            }
682
        );
683
684
        # Test balanced cashup with note (should not create surplus/deficit)
685
        my $balanced_cashup = $register_note_test->add_cashup(
686
            {
687
                manager_id          => $patron->id,
688
                amount              => 10.00,                                              # Balanced
689
                reconciliation_note => 'This note should be ignored for balanced cashup'
690
            }
691
        );
692
693
        my $balanced_reconciliation_lines = Koha::Account::Lines->search(
694
            {
695
                register_id => $register_note_test->id,
696
                '-or'       => [
697
                    { credit_type_code => 'CASHUP_SURPLUS' },
698
                    { debit_type_code  => 'CASHUP_DEFICIT' }
699
                ]
700
            }
701
        );
702
703
        is(
704
            $balanced_reconciliation_lines->count, 0,
705
            'No reconciliation accountlines created for balanced cashup with note'
706
        );
707
708
        # Test empty/whitespace note handling
709
        my $register_empty_note    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
710
        my $accountline_empty_note = $builder->build_object(
711
            {
712
                class => 'Koha::Account::Lines',
713
                value => {
714
                    register_id      => $register_empty_note->id,
715
                    borrowernumber   => $patron->id,
716
                    amount           => -10.00,
717
                    credit_type_code => 'PAYMENT',
718
                    debit_type_code  => undef,
719
                }
720
            }
721
        );
722
723
        my $empty_note_cashup = $register_empty_note->add_cashup(
724
            {
725
                manager_id          => $patron->id,
726
                amount              => 12.00,         # 2.00 surplus
727
                reconciliation_note => '   '          # Whitespace only
728
            }
729
        );
730
731
        my $empty_note_surplus = Koha::Account::Lines->search(
732
            {
733
                register_id      => $register_empty_note->id,
734
                credit_type_code => 'CASHUP_SURPLUS'
735
            }
736
        )->next;
737
738
        is(
739
            $empty_note_surplus->note, undef,
740
            'No note stored when user note is empty/whitespace'
741
        );
742
743
        $schema->storage->txn_rollback;
744
    };
745
746
    $schema->storage->txn_rollback;
747
};

Return to bug 40445