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

(-)a/Koha/Cash/Register.pm (-9 / +97 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 208-234 sub drop_default { Link Here
208
209
209
    my $cashup = $cash_register->add_cashup(
210
    my $cashup = $cash_register->add_cashup(
210
        {
211
        {
211
            manager_id => $logged_in_user->id,
212
            manager_id            => $logged_in_user->id,
212
            amount     => $cash_register->outstanding_accountlines->total
213
            amount                => $amount_removed_from_register,
214
            [ reconciliation_note => $reconciliation_note ]
213
        }
215
        }
214
    );
216
    );
215
217
216
Add a new cashup action to the till, returns the added action.
218
Add a new cashup action to the till, returns the added action.
219
If amount differs from expected amount, creates surplus/deficit accountlines.
217
220
218
=cut
221
=cut
219
222
220
sub add_cashup {
223
sub add_cashup {
221
    my ( $self, $params ) = @_;
224
    my ( $self, $params ) = @_;
222
225
223
    my $rs = $self->_result->add_to_cash_register_actions(
226
    my $manager_id          = $params->{manager_id};
224
        {
227
    my $amount              = $params->{amount};
225
            code       => 'CASHUP',
228
    my $reconciliation_note = $params->{reconciliation_note};
226
            manager_id => $params->{manager_id},
229
227
            amount     => $params->{amount}
230
    # Sanitize reconciliation note - treat empty/whitespace-only as undef
231
    if ( defined $reconciliation_note ) {
232
        $reconciliation_note = substr( $reconciliation_note, 0, 1000 );    # Limit length
233
        $reconciliation_note =~ s/^\s+|\s+$//g;                            # Trim whitespace
234
        $reconciliation_note = undef if $reconciliation_note eq '';        # Empty after trim = undef
235
    }
236
237
    # Calculate expected amount from outstanding accountlines
238
    my $expected_amount = $self->outstanding_accountlines->total;
239
240
    # For backward compatibility, if no actual amount is specified, use expected amount
241
    $amount //= abs($expected_amount);
242
243
    # Calculate difference (actual - expected)
244
    my $difference = $amount - abs($expected_amount);
245
246
    # Use database transaction to ensure consistency
247
    my $schema = $self->_result->result_source->schema;
248
    my $cashup;
249
250
    $schema->txn_do(
251
        sub {
252
            # Create the cashup action with actual amount
253
            my $rs = $self->_result->add_to_cash_register_actions(
254
                {
255
                    code       => 'CASHUP',
256
                    manager_id => $manager_id,
257
                    amount     => $amount
258
                }
259
            )->discard_changes;
260
261
            $cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs);
262
263
            # Create reconciliation accountline if there's a difference
264
            if ( $difference != 0 ) {
265
266
                if ( $difference > 0 ) {
267
268
                    # Surplus: more cash found than expected (credits are negative amounts)
269
                    my $surplus = Koha::Account::Line->new(
270
                        {
271
                            date             => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
272
                            amount           => -abs($difference),                             # Credits are negative
273
                            description      => 'Cash register surplus found during cashup',
274
                            credit_type_code => 'CASHUP_SURPLUS',
275
                            manager_id       => $manager_id,
276
                            interface        => 'intranet',
277
                            register_id      => $self->id,
278
                            note             => $reconciliation_note
279
                        }
280
                    )->store();
281
282
                    # Record the account offset
283
                    my $account_offset = Koha::Account::Offset->new(
284
                        {
285
                            credit_id => $surplus->id,
286
                            type      => 'CREATE',
287
                            amount    => -abs($difference)    # Offsets match the line amount
288
                        }
289
                    )->store();
290
291
                } else {
292
293
                    # Deficit: less cash found than expected
294
                    my $deficit = Koha::Account::Line->new(
295
                        {
296
                            date            => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
297
                            amount          => abs($difference),
298
                            description     => 'Cash register deficit found during cashup',
299
                            debit_type_code => 'CASHUP_DEFICIT',
300
                            manager_id      => $manager_id,
301
                            interface       => 'intranet',
302
                            register_id     => $self->id,
303
                            note            => $reconciliation_note
304
                        }
305
                    )->store();
306
                    my $account_offset = Koha::Account::Offset->new(
307
                        {
308
                            debit_id => $deficit->id,
309
                            type     => 'CREATE',
310
                            amount   => abs($difference)    # Debits have positive offsets
311
                        }
312
                    )->store();
313
314
                }
315
            }
228
        }
316
        }
229
    )->discard_changes;
317
    );
230
318
231
    return Koha::Cash::Register::Cashup->_new_from_dbic($rs);
319
    return $cashup;
232
}
320
}
233
321
234
=head3 to_api_mapping
322
=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 (-9 / +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
        }
120
- 

Return to bug 40445