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

(-)a/Koha/Cash/Register.pm (-9 / +119 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
                            amountoutstanding   => 0,
290
                            description         => 'Cash register surplus found during cashup',
291
                            credit_type_code    => 'CASHUP_SURPLUS',
292
                            payment_type        => 'CASH',
293
                            manager_id          => $manager_id,
294
                            interface           => 'intranet',
295
                            branchcode          => $self->branch,
296
                            register_id         => $self->id,
297
                            note                => $reconciliation_note
298
                        }
299
                    )->store();
300
301
                    # Record the account offset
302
                    my $account_offset = Koha::Account::Offset->new(
303
                        {
304
                            credit_id => $surplus->id,
305
                            type      => 'CREATE',
306
                            amount    => -abs($difference)    # Offsets match the line amount
307
                        }
308
                    )->store();
309
310
                } else {
311
312
                    # Deficit: less cash found than expected
313
                    my $deficit = Koha::Account::Line->new(
314
                        {
315
                            date                => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
316
                            amount              => abs($difference),
317
                            amountoutstanding   => 0,
318
                            description         => 'Cash register deficit found during cashup',
319
                            debit_type_code     => 'CASHUP_DEFICIT',
320
                            payment_type        => 'CASH',
321
                            manager_id          => $manager_id,
322
                            interface           => 'intranet',
323
                            branchcode          => $self->branch,
324
                            register_id         => $self->id,
325
                            note                => $reconciliation_note
326
                        }
327
                    )->store();
328
                    my $account_offset = Koha::Account::Offset->new(
329
                        {
330
                            debit_id => $deficit->id,
331
                            type     => 'CREATE',
332
                            amount   => abs($difference)    # Debits have positive offsets
333
                        }
334
                    )->store();
335
336
                }
337
            }
228
        }
338
        }
229
    )->discard_changes;
339
    );
230
340
231
    return Koha::Cash::Register::Cashup->_new_from_dbic($rs);
341
    return $cashup;
232
}
342
}
233
343
234
=head3 to_api_mapping
344
=head3 to_api_mapping
(-)a/Koha/Cash/Register/Cashup.pm (-3 / +33 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
193
    # Extract notes from reconciliation lines
194
    my ($surplus_record) = $surplus_lines->_resultset->search( {}, { rows => 1 } )->all;
195
    my $surplus_note = $surplus_record ? $surplus_record->note : undef;
196
197
    my ($deficit_record) = $deficit_lines->_resultset->search( {}, { rows => 1 } )->all;
198
    my $deficit_note = $deficit_record ? $deficit_record->note : undef;
199
176
    $summary = {
200
    $summary = {
177
        from_date      => $previous ? $previous->timestamp : undef,
201
        from_date      => $previous ? $previous->timestamp : undef,
178
        to_date        => $self->timestamp,
202
        to_date        => $self->timestamp,
Lines 181-187 sub summary { Link Here
181
        payout_grouped => \@payout,
205
        payout_grouped => \@payout,
182
        payout_total   => abs($payout_total),
206
        payout_total   => abs($payout_total),
183
        total          => $total * -1,
207
        total          => $total * -1,
184
        total_grouped  => \@total_grouped
208
        total_grouped  => \@total_grouped,
209
210
        # Reconciliation data for footer display
211
        surplus_total => $surplus_total ? $surplus_total * 1 : undef,
212
        deficit_total => $deficit_total ? $deficit_total * 1 : undef,
213
        surplus_note  => $surplus_note,
214
        deficit_note  => $deficit_note
185
    };
215
    };
186
216
187
    return $summary;
217
    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 587-592 Link Here
587
                }
616
                }
588
            }
617
            }
589
        });
618
        });
619
620
        // Real-time reconciliation calculation for cashup modal
621
        $("#amount").on("input", function() {
622
            var actualAmount = parseFloat($(this).val()) || 0;
623
            var expectedText = $("#expected_amount").text().replace(/[£$,]/g, '');
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
        });
663
664
        // Reset modal when opened
665
        $("#confirmCashupModal").on("shown.bs.modal", function() {
666
            // Start with empty actual amount field (user must enter amount)
667
            $("#amount").val('').focus();
668
            $("#reconciliation_display").hide();
669
            $("#reconciliation_note_field").hide();
670
            $("#reconciliation_note").val('');
671
        });
590
    </script>
672
    </script>
591
[% END %]
673
[% END %]
592
674
(-)a/koha-tmpl/intranet-tmpl/prog/js/cashup_modal.js (-6 / +98 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-79 $(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>"
68
                );
77
                );
78
79
                // Add separator line
80
                tfoot.append(
81
                    "<tr class='reconciliation-separator'><td colspan='2'><hr></td></tr>"
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>"
117
                        );
118
                    }
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
                        reconciliationNote;
132
133
                    if (surplus) {
134
                        reconciliationClass =
135
                            "reconciliation-result text-warning";
136
                        reconciliationLabel = "Cashup surplus";
137
                        reconciliationAmount =
138
                            "+" + Math.abs(surplus).format_price();
139
                        reconciliationNote = data.summary.surplus_note;
140
                    } else if (deficit) {
141
                        reconciliationClass =
142
                            "reconciliation-result text-danger";
143
                        reconciliationLabel = "Cashup deficit";
144
                        reconciliationAmount =
145
                            "-" + Math.abs(deficit).format_price();
146
                        reconciliationNote = data.summary.deficit_note;
147
                    }
148
149
                    tfoot.append(
150
                        "<tr class='" +
151
                            reconciliationClass +
152
                            "'><td><strong>" +
153
                            reconciliationLabel +
154
                            "</strong></td><td><strong>" +
155
                            reconciliationAmount +
156
                            "</strong></td></tr>"
157
                    );
158
159
                    // Add note if present
160
                    if (reconciliationNote) {
161
                        tfoot.append(
162
                            "<tr class='" +
163
                                reconciliationClass +
164
                                "'><td colspan='2'><em>" +
165
                                __("Note:") +
166
                                " " +
167
                                escape_str(reconciliationNote) +
168
                                "</em></td></tr>"
77
                        );
169
                        );
78
                    }
170
                    }
79
                }
171
                }
(-)a/pos/register.pl (-9 / +26 lines)
Lines 104-120 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
            my $cashup = $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
                my $cashup = $cash_register->add_cashup(
115
            print $input->redirect(
120
                    {
116
                "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid . "#cashup-" . $cashup->id );
121
                        manager_id          => $logged_in_user->id,
117
            exit;
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(
129
                    "/cgi-bin/koha/pos/register.pl?registerid=" . $registerid . "#cashup-" . $cashup->id );
130
                exit;
131
132
            } else {
133
                $template->param( error_cashup_amount => 1 );
134
            }
118
        } else {
135
        } else {
119
            $template->param( error_cashup_permission => 1 );
136
            $template->param( error_cashup_permission => 1 );
120
        }
137
        }
(-)a/t/db_dependent/Koha/Cash/Register.t (-2 / +440 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
        is( $surplus_line->branchcode,   $register2->branch,          'Surplus branchcode matches register branch' );
474
        is( $surplus_line->payment_type, 'CASH',                      'Surplus payment_type is set to CASH' );
475
        is( sprintf( '%.0f', $surplus_line->amountoutstanding ), '0', 'Surplus amountoutstanding is set to 0' );
476
477
        # Note should be undef for surplus without user note
478
        is( $surplus_line->note, undef, 'No note for surplus without user reconciliation note' );
479
480
        # Test surplus with user note
481
        my $register_with_note    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
482
        my $accountline_with_note = $builder->build_object(
483
            {
484
                class => 'Koha::Account::Lines',
485
                value => {
486
                    register_id      => $register_with_note->id,
487
                    borrowernumber   => $patron->id,
488
                    amount           => -10.00,
489
                    credit_type_code => 'PAYMENT',
490
                    debit_type_code  => undef,
491
                }
492
            }
493
        );
494
495
        my $cashup_with_note = $register_with_note->add_cashup(
496
            {
497
                manager_id          => $patron->id,
498
                amount              => 15.00,                                           # 5.00 surplus
499
                reconciliation_note => 'Found extra \x{00A3}5 under the till drawer'    # £5 in UTF-8
500
            }
501
        );
502
503
        my $surplus_with_note = Koha::Account::Lines->search(
504
            {
505
                register_id      => $register_with_note->id,
506
                credit_type_code => 'CASHUP_SURPLUS'
507
            }
508
        )->next;
509
510
        like(
511
            $surplus_with_note->note, qr/Found extra .+5 under the till drawer/,
512
            'User note included in surplus accountline'
513
        );
514
        is(
515
            $surplus_with_note->note, 'Found extra \x{00A3}5 under the till drawer',
516
            'Only user note stored (no base reconciliation info)'
517
        );
518
519
        $schema->storage->txn_rollback;
520
    };
521
522
    subtest 'deficit_cashup' => sub {
523
        plan tests => 7;
524
525
        $schema->storage->txn_begin;
526
527
        my $register3    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
528
        my $accountline4 = $builder->build_object(
529
            {
530
                class => 'Koha::Account::Lines',
531
                value => {
532
                    register_id      => $register3->id,
533
                    borrowernumber   => $patron->id,
534
                    amount           => -30.00,           # Credit (payment)
535
                    credit_type_code => 'PAYMENT',
536
                    debit_type_code  => undef,
537
                }
538
            }
539
        );
540
541
        my $expected = abs( $register3->outstanding_accountlines->total );    # 30.00
542
        my $actual   = 25.00;                                                 # 5.00 deficit
543
        my $deficit  = $expected - $actual;
544
545
        my $cashup = $register3->add_cashup(
546
            {
547
                manager_id => $patron->id,
548
                amount     => $actual
549
            }
550
        );
551
552
        ok( $cashup, 'Deficit cashup created successfully' );
553
        is( sprintf( '%.0f', $cashup->amount ), sprintf( '%.0f', $actual ), 'Cashup amount matches actual amount' );
554
555
        # Check deficit accountline was created
556
        my $deficit_lines = Koha::Account::Lines->search(
557
            {
558
                register_id     => $register3->id,
559
                debit_type_code => 'CASHUP_DEFICIT'
560
            }
561
        );
562
563
        is( $deficit_lines->count, 1, 'One deficit accountline created' );
564
565
        my $deficit_line = $deficit_lines->next;
566
        is(
567
            sprintf( '%.0f', $deficit_line->amount ), sprintf( '%.0f', $deficit ),
568
            'Deficit amount is correct (positive for debit)'
569
        );
570
        is( $deficit_line->branchcode,   $register3->branch,          'Deficit branchcode matches register branch' );
571
        is( $deficit_line->payment_type, 'CASH',                      'Deficit payment_type is set to CASH' );
572
        is( sprintf( '%.0f', $deficit_line->amountoutstanding ), '0', 'Deficit amountoutstanding is set to 0' );
573
574
        # Note should be undef for deficit without user note
575
        is( $deficit_line->note, undef, 'No note for deficit without user reconciliation note' );
576
577
        # Test deficit with user note
578
        my $register_deficit_note    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
579
        my $accountline_deficit_note = $builder->build_object(
580
            {
581
                class => 'Koha::Account::Lines',
582
                value => {
583
                    register_id      => $register_deficit_note->id,
584
                    borrowernumber   => $patron->id,
585
                    amount           => -20.00,
586
                    credit_type_code => 'PAYMENT',
587
                    debit_type_code  => undef,
588
                }
589
            }
590
        );
591
592
        my $cashup_deficit_note = $register_deficit_note->add_cashup(
593
            {
594
                manager_id          => $patron->id,
595
                amount              => 15.00,                                                     # 5.00 deficit
596
                reconciliation_note => 'Till was short, possibly due to incorrect change given'
597
            }
598
        );
599
600
        my $deficit_with_note = Koha::Account::Lines->search(
601
            {
602
                register_id     => $register_deficit_note->id,
603
                debit_type_code => 'CASHUP_DEFICIT'
604
            }
605
        )->next;
606
607
        like(
608
            $deficit_with_note->note, qr/Till was short, possibly due to incorrect change given/,
609
            'User note included in deficit accountline'
610
        );
611
        is(
612
            $deficit_with_note->note, 'Till was short, possibly due to incorrect change given',
613
            'Only user note stored (no base reconciliation info)'
614
        );
615
616
        $schema->storage->txn_rollback;
617
    };
618
619
    subtest 'transaction_integrity' => sub {
620
        plan tests => 4;
621
622
        $schema->storage->txn_begin;
623
624
        my $register4    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
625
        my $accountline5 = $builder->build_object(
626
            {
627
                class => 'Koha::Account::Lines',
628
                value => {
629
                    register_id      => $register4->id,
630
                    borrowernumber   => $patron->id,
631
                    amount           => -10.00,
632
                    credit_type_code => 'PAYMENT',
633
                    debit_type_code  => undef,
634
                }
635
            }
636
        );
637
638
        my $initial_accountline_count = Koha::Account::Lines->search( { register_id => $register4->id } )->count;
639
640
        my $initial_action_count = $register4->cashups->count;
641
642
        # Test successful transaction
643
        my $cashup = $register4->add_cashup(
644
            {
645
                manager_id => $patron->id,
646
                amount     => 15.00          # Creates surplus
647
            }
648
        );
649
650
        # Check both cashup action and surplus accountline were created
651
        is( $register4->cashups->count, $initial_action_count + 1, 'Cashup action created' );
652
653
        my $final_accountline_count = Koha::Account::Lines->search( { register_id => $register4->id } )->count;
654
655
        is( $final_accountline_count, $initial_accountline_count + 1, 'Surplus accountline created' );
656
657
        # Verify the new accountline is the surplus
658
        my $surplus_line = Koha::Account::Lines->search(
659
            {
660
                register_id      => $register4->id,
661
                credit_type_code => 'CASHUP_SURPLUS'
662
            }
663
        )->next;
664
665
        ok( $surplus_line, 'Surplus accountline exists' );
666
        is( $surplus_line->register_id, $register4->id, 'Surplus linked to correct register' );
667
668
        $schema->storage->txn_rollback;
669
    };
670
671
    subtest 'note_handling' => sub {
672
        plan tests => 2;
673
674
        $schema->storage->txn_begin;
675
676
        my $register_note_test    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
677
        my $accountline_note_test = $builder->build_object(
678
            {
679
                class => 'Koha::Account::Lines',
680
                value => {
681
                    register_id      => $register_note_test->id,
682
                    borrowernumber   => $patron->id,
683
                    amount           => -10.00,
684
                    credit_type_code => 'PAYMENT',
685
                    debit_type_code  => undef,
686
                }
687
            }
688
        );
689
690
        # Test balanced cashup with note (should not create surplus/deficit)
691
        my $balanced_cashup = $register_note_test->add_cashup(
692
            {
693
                manager_id          => $patron->id,
694
                amount              => 10.00,                                              # Balanced
695
                reconciliation_note => 'This note should be ignored for balanced cashup'
696
            }
697
        );
698
699
        my $balanced_reconciliation_lines = Koha::Account::Lines->search(
700
            {
701
                register_id => $register_note_test->id,
702
                '-or'       => [
703
                    { credit_type_code => 'CASHUP_SURPLUS' },
704
                    { debit_type_code  => 'CASHUP_DEFICIT' }
705
                ]
706
            }
707
        );
708
709
        is(
710
            $balanced_reconciliation_lines->count, 0,
711
            'No reconciliation accountlines created for balanced cashup with note'
712
        );
713
714
        # Test empty/whitespace note handling
715
        my $register_empty_note    = $builder->build_object( { class => 'Koha::Cash::Registers' } );
716
        my $accountline_empty_note = $builder->build_object(
717
            {
718
                class => 'Koha::Account::Lines',
719
                value => {
720
                    register_id      => $register_empty_note->id,
721
                    borrowernumber   => $patron->id,
722
                    amount           => -10.00,
723
                    credit_type_code => 'PAYMENT',
724
                    debit_type_code  => undef,
725
                }
726
            }
727
        );
728
729
        my $empty_note_cashup = $register_empty_note->add_cashup(
730
            {
731
                manager_id          => $patron->id,
732
                amount              => 12.00,         # 2.00 surplus
733
                reconciliation_note => '   '          # Whitespace only
734
            }
735
        );
736
737
        my $empty_note_surplus = Koha::Account::Lines->search(
738
            {
739
                register_id      => $register_empty_note->id,
740
                credit_type_code => 'CASHUP_SURPLUS'
741
            }
742
        )->next;
743
744
        is(
745
            $empty_note_surplus->note, undef,
746
            'No note stored when user note is empty/whitespace'
747
        );
748
749
        $schema->storage->txn_rollback;
750
    };
751
752
    $schema->storage->txn_rollback;
753
};

Return to bug 40445