Bug 40445: Add cashup reconciliation functionality
Implements cashup reconciliation allowing staff to record actual cash amounts and track surplus/deficit discrepancies. Backend changes: - Enhanced add_cashup() to accept actual_amount and optional notes - Automatic CASHUP_SURPLUS/DEFICIT accountline creation for discrepancies - Transaction handling ensures atomicity of cashup and reconciliation - outstanding_accountlines() excludes reconciliation entries Frontend changes: - Interactive modal requiring actual amount entry - Real-time surplus/deficit calculation - Conditional note field for discrepancies (1000 char limit) - Enhanced summary modal displays reconciliation details - Full audit trail with timestamps and manager links Test plan: 1. Apply all patches and restart services 2. Run prove t/db_dependent/Koha/Cash/Register.t 3. Create test transactions on a register 4. Perform cashup with balanced amount - verify no reconciliation accountlines 5. Perform cashup with surplus - verify CASHUP_SURPLUS credit created 6. Perform cashup with deficit - verify CASHUP_DEFICIT debit created 7. Add notes to reconciliation - verify stored correctly 8. Start new cashup - verify previous reconciliation excluded from outstanding 9. View cashup summary - verify reconciliation displayed prominently Sponsored-by: OpenFifth <https://openfifth.co.uk/> Signed-off-by: Jackie Usher <jackie.usher@westsussex.gov.uk> Signed-off-by: Andrew Fuerste Henry <andrew@bywatersolutions.com> Signed-off-by: Pedro Amorim <pedro.amorim@openfifth.co.uk>
This commit is contained in:
parent
6a38111462
commit
6d5c2f463f
|
|
@ -17,7 +17,10 @@ package Koha::Cash::Register;
|
|||
|
||||
use Modern::Perl;
|
||||
|
||||
use Koha::Account;
|
||||
use Koha::Account::Line;
|
||||
use Koha::Account::Lines;
|
||||
use Koha::Account::Offset;
|
||||
use Koha::Account::Offsets;
|
||||
use Koha::Cash::Register::Actions;
|
||||
use Koha::Cash::Register::Cashups;
|
||||
|
|
@ -125,6 +128,22 @@ sub outstanding_accountlines {
|
|||
$since->count
|
||||
? { 'date' => { '>' => $since->get_column('timestamp')->as_query } }
|
||||
: {};
|
||||
|
||||
# Exclude reconciliation accountlines from outstanding accountlines
|
||||
$local_conditions->{'-and'} = [
|
||||
{
|
||||
'-or' => [
|
||||
{ 'credit_type_code' => { '!=' => 'CASHUP_SURPLUS' } },
|
||||
{ 'credit_type_code' => undef }
|
||||
]
|
||||
},
|
||||
{
|
||||
'-or' => [
|
||||
{ 'debit_type_code' => { '!=' => 'CASHUP_DEFICIT' } },
|
||||
{ 'debit_type_code' => undef }
|
||||
]
|
||||
}
|
||||
];
|
||||
my $merged_conditions =
|
||||
$conditions
|
||||
? { %{$conditions}, %{$local_conditions} }
|
||||
|
|
@ -208,27 +227,120 @@ sub drop_default {
|
|||
|
||||
my $cashup = $cash_register->add_cashup(
|
||||
{
|
||||
manager_id => $logged_in_user->id,
|
||||
amount => $cash_register->outstanding_accountlines->total
|
||||
manager_id => $logged_in_user->id,
|
||||
amount => $amount_removed_from_register,
|
||||
[ reconciliation_note => $reconciliation_note ]
|
||||
}
|
||||
);
|
||||
|
||||
Add a new cashup action to the till, returns the added action.
|
||||
If amount differs from expected amount, creates surplus/deficit accountlines.
|
||||
|
||||
=cut
|
||||
|
||||
sub add_cashup {
|
||||
my ( $self, $params ) = @_;
|
||||
|
||||
my $rs = $self->_result->add_to_cash_register_actions(
|
||||
{
|
||||
code => 'CASHUP',
|
||||
manager_id => $params->{manager_id},
|
||||
amount => $params->{amount}
|
||||
}
|
||||
)->discard_changes;
|
||||
my $manager_id = $params->{manager_id};
|
||||
my $amount = $params->{amount};
|
||||
my $reconciliation_note = $params->{reconciliation_note};
|
||||
|
||||
return Koha::Cash::Register::Cashup->_new_from_dbic($rs);
|
||||
# Sanitize reconciliation note - treat empty/whitespace-only as undef
|
||||
if ( defined $reconciliation_note ) {
|
||||
$reconciliation_note = substr( $reconciliation_note, 0, 1000 ); # Limit length
|
||||
$reconciliation_note =~ s/^\s+|\s+$//g; # Trim whitespace
|
||||
$reconciliation_note = undef if $reconciliation_note eq ''; # Empty after trim = undef
|
||||
}
|
||||
|
||||
# Calculate expected amount from outstanding accountlines
|
||||
my $expected_amount = $self->outstanding_accountlines->total;
|
||||
|
||||
# For backward compatibility, if no actual amount is specified, use expected amount
|
||||
$amount //= abs($expected_amount);
|
||||
|
||||
# Calculate difference (actual - expected)
|
||||
my $difference = $amount - abs($expected_amount);
|
||||
|
||||
# Use database transaction to ensure consistency
|
||||
my $schema = $self->_result->result_source->schema;
|
||||
my $cashup;
|
||||
|
||||
$schema->txn_do(
|
||||
sub {
|
||||
# Create the cashup action with actual amount
|
||||
my $rs = $self->_result->add_to_cash_register_actions(
|
||||
{
|
||||
code => 'CASHUP',
|
||||
manager_id => $manager_id,
|
||||
amount => $amount
|
||||
}
|
||||
)->discard_changes;
|
||||
|
||||
$cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs);
|
||||
|
||||
# Create reconciliation accountline if there's a difference
|
||||
if ( $difference != 0 ) {
|
||||
|
||||
if ( $difference > 0 ) {
|
||||
|
||||
# Surplus: more cash found than expected (credits are negative amounts)
|
||||
my $surplus = Koha::Account::Line->new(
|
||||
{
|
||||
date => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
|
||||
amount => -abs($difference), # Credits are negative
|
||||
amountoutstanding => 0,
|
||||
description => 'Cash register surplus found during cashup',
|
||||
credit_type_code => 'CASHUP_SURPLUS',
|
||||
payment_type => 'CASH',
|
||||
manager_id => $manager_id,
|
||||
interface => 'intranet',
|
||||
branchcode => $self->branch,
|
||||
register_id => $self->id,
|
||||
note => $reconciliation_note
|
||||
}
|
||||
)->store();
|
||||
|
||||
# Record the account offset
|
||||
my $account_offset = Koha::Account::Offset->new(
|
||||
{
|
||||
credit_id => $surplus->id,
|
||||
type => 'CREATE',
|
||||
amount => -abs($difference) # Offsets match the line amount
|
||||
}
|
||||
)->store();
|
||||
|
||||
} else {
|
||||
|
||||
# Deficit: less cash found than expected
|
||||
my $deficit = Koha::Account::Line->new(
|
||||
{
|
||||
date => \'DATE_SUB(NOW(), INTERVAL 1 SECOND)',
|
||||
amount => abs($difference),
|
||||
amountoutstanding => 0,
|
||||
description => 'Cash register deficit found during cashup',
|
||||
debit_type_code => 'CASHUP_DEFICIT',
|
||||
payment_type => 'CASH',
|
||||
manager_id => $manager_id,
|
||||
interface => 'intranet',
|
||||
branchcode => $self->branch,
|
||||
register_id => $self->id,
|
||||
note => $reconciliation_note
|
||||
}
|
||||
)->store();
|
||||
my $account_offset = Koha::Account::Offset->new(
|
||||
{
|
||||
debit_id => $deficit->id,
|
||||
type => 'CREATE',
|
||||
amount => abs($difference) # Debits have positive offsets
|
||||
}
|
||||
)->store();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return $cashup;
|
||||
}
|
||||
|
||||
=head3 to_api_mapping
|
||||
|
|
|
|||
|
|
@ -80,10 +80,18 @@ sub summary {
|
|||
: { 'date' => { '<' => $self->timestamp } };
|
||||
|
||||
my $payout_transactions = $self->register->accountlines->search(
|
||||
{ %{$conditions}, credit_type_code => undef },
|
||||
{
|
||||
%{$conditions},
|
||||
credit_type_code => undef,
|
||||
debit_type_code => { '!=' => 'CASHUP_DEFICIT' }
|
||||
},
|
||||
);
|
||||
my $income_transactions = $self->register->accountlines->search(
|
||||
{ %{$conditions}, debit_type_code => undef },
|
||||
{
|
||||
%{$conditions},
|
||||
debit_type_code => undef,
|
||||
credit_type_code => { '!=' => 'CASHUP_SURPLUS' }
|
||||
},
|
||||
);
|
||||
|
||||
my $income_summary = Koha::Account::Offsets->search(
|
||||
|
|
@ -173,6 +181,22 @@ sub summary {
|
|||
push @total_grouped, { payment_type => $type->lib, total => $typed_total };
|
||||
}
|
||||
|
||||
# Check for reconciliation lines separately (for footer display only)
|
||||
my $surplus_lines =
|
||||
$self->register->accountlines->search( { %{$conditions}, credit_type_code => 'CASHUP_SURPLUS' } );
|
||||
my $deficit_lines =
|
||||
$self->register->accountlines->search( { %{$conditions}, debit_type_code => 'CASHUP_DEFICIT' } );
|
||||
|
||||
my $surplus_total = $surplus_lines->count ? $surplus_lines->total : undef;
|
||||
my $deficit_total = $deficit_lines->count ? $deficit_lines->total : undef;
|
||||
|
||||
# Extract notes from reconciliation lines
|
||||
my ($surplus_record) = $surplus_lines->_resultset->search( {}, { rows => 1 } )->all;
|
||||
my $surplus_note = $surplus_record ? $surplus_record->note : undef;
|
||||
|
||||
my ($deficit_record) = $deficit_lines->_resultset->search( {}, { rows => 1 } )->all;
|
||||
my $deficit_note = $deficit_record ? $deficit_record->note : undef;
|
||||
|
||||
$summary = {
|
||||
from_date => $previous ? $previous->timestamp : undef,
|
||||
to_date => $self->timestamp,
|
||||
|
|
@ -181,7 +205,13 @@ sub summary {
|
|||
payout_grouped => \@payout,
|
||||
payout_total => abs($payout_total),
|
||||
total => $total * -1,
|
||||
total_grouped => \@total_grouped
|
||||
total_grouped => \@total_grouped,
|
||||
|
||||
# Reconciliation data for footer display
|
||||
surplus_total => $surplus_total ? $surplus_total * 1 : undef,
|
||||
deficit_total => $deficit_total ? $deficit_total * 1 : undef,
|
||||
surplus_note => $surplus_note,
|
||||
deficit_note => $deficit_note
|
||||
};
|
||||
|
||||
return $summary;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
<li>Cash register: <span id="register_description"></span></li>
|
||||
<li>Period: <span id="from_date"></span> to <span id="to_date"></span></li>
|
||||
</ul>
|
||||
<table>
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
|
|
@ -21,6 +22,31 @@
|
|||
<tbody> </tbody>
|
||||
<tfoot> </tfoot>
|
||||
</table>
|
||||
|
||||
<style>
|
||||
#cashupSummaryModal .reconciliation-separator hr {
|
||||
margin: 0.5rem 0;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
#cashupSummaryModal .reconciliation-info {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
#cashupSummaryModal .reconciliation-result.text-warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
#cashupSummaryModal .reconciliation-result.text-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
#cashupSummaryModal .total-row {
|
||||
border-top: 2px solid #dee2e6;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
<!-- /.modal-body -->
|
||||
<div class="modal-footer">
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@
|
|||
<div id="error_message" class="alert alert-warning"> You do not have permission to perform cashup actions. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_cashup_amount ) %]
|
||||
<div id="error_message" class="alert alert-warning"> Invalid amount entered for cashup. Please enter a valid monetary amount. </div>
|
||||
[% END %]
|
||||
|
||||
[% IF ( error_refund_permission ) %]
|
||||
<div id="error_message" class="alert alert-warning"> You do not have permission to perform refund actions. </div>
|
||||
[% END %]
|
||||
|
|
@ -370,7 +374,7 @@
|
|||
|
||||
<!-- Confirm cashup modal -->
|
||||
<div class="modal" id="confirmCashupModal" tabindex="-1" role="dialog" aria-labelledby="confirmCashupLabel">
|
||||
<form id="cashup_form" method="post" enctype="multipart/form-data">
|
||||
<form id="cashup_form" method="post" enctype="multipart/form-data" class="validated">
|
||||
[% INCLUDE 'csrf-token.inc' %]
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
|
|
@ -379,13 +383,38 @@
|
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
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 %].
|
||||
<fieldset class="rows">
|
||||
<ol>
|
||||
<li>
|
||||
<span class="label">Expected amount to remove:</span>
|
||||
<span id="expected_amount" class="expected-amount">[% accountlines.total( payment_type => [ 'CASH', 'SIP00' ]) * -1 | $Price %]</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">Float to remain:</span>
|
||||
<span>[% register.starting_float | $Price %]</span>
|
||||
</li>
|
||||
<li>
|
||||
<label class="required" for="amount">Actual amount removed from register:</label>
|
||||
<input type="text" inputmode="decimal" pattern="^\d+(\.\d{2})?$" id="amount" name="amount" required="required" />
|
||||
<span class="required">Required</span>
|
||||
</li>
|
||||
<li id="reconciliation_display" style="display: none;">
|
||||
<span class="label">Reconciliation:</span>
|
||||
<span id="reconciliation_text"></span>
|
||||
</li>
|
||||
<li id="reconciliation_note_field" style="display: none;">
|
||||
<label for="reconciliation_note">Note (optional):</label>
|
||||
<textarea id="reconciliation_note" name="reconciliation_note" rows="3" cols="40" maxlength="1000" placeholder="Enter a note explaining the surplus or deficit..."></textarea>
|
||||
<div class="hint">Maximum 1000 characters</div>
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
</div>
|
||||
<!-- /.modal-body -->
|
||||
<div class="modal-footer">
|
||||
<input type="hidden" name="registerid" value="[% register.id | html %]" />
|
||||
<input type="hidden" name="op" value="cud-cashup" />
|
||||
<button type="submit" class="btn btn-primary" id="pos_cashup_confirm">Confirm</button>
|
||||
<button type="submit" class="btn btn-primary" id="pos_cashup_confirm">Confirm cashup</button>
|
||||
<button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
<!-- /.modal-footer -->
|
||||
|
|
@ -610,6 +639,59 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time reconciliation calculation for cashup modal
|
||||
$("#amount").on("input", function() {
|
||||
var actualAmount = parseFloat($(this).val()) || 0;
|
||||
var expectedText = $("#expected_amount").text().replace(/[£$,]/g, '');
|
||||
var expectedAmount = parseFloat(expectedText) || 0;
|
||||
var difference = actualAmount - expectedAmount;
|
||||
|
||||
if ($(this).val() && !isNaN(actualAmount)) {
|
||||
var reconciliationText = "";
|
||||
var reconciliationClass = "";
|
||||
var hasDiscrepancy = false;
|
||||
|
||||
if (difference > 0) {
|
||||
reconciliationText = "Surplus: " + difference.format_price();
|
||||
reconciliationClass = "success";
|
||||
hasDiscrepancy = true;
|
||||
} else if (difference < 0) {
|
||||
reconciliationText = "Deficit: " + Math.abs(difference).format_price();
|
||||
reconciliationClass = "warning";
|
||||
hasDiscrepancy = true;
|
||||
} else {
|
||||
reconciliationText = "Balanced - no surplus or deficit";
|
||||
reconciliationClass = "success";
|
||||
hasDiscrepancy = false;
|
||||
}
|
||||
|
||||
$("#reconciliation_text").text(reconciliationText)
|
||||
.removeClass("success warning")
|
||||
.addClass(reconciliationClass);
|
||||
$("#reconciliation_display").show();
|
||||
|
||||
// Show/hide note field based on whether there's a discrepancy
|
||||
if (hasDiscrepancy) {
|
||||
$("#reconciliation_note_field").show();
|
||||
} else {
|
||||
$("#reconciliation_note_field").hide();
|
||||
$("#reconciliation_note").val(''); // Clear note when balanced
|
||||
}
|
||||
} else {
|
||||
$("#reconciliation_display").hide();
|
||||
$("#reconciliation_note_field").hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Reset modal when opened
|
||||
$("#confirmCashupModal").on("shown.bs.modal", function() {
|
||||
// Start with empty actual amount field (user must enter amount)
|
||||
$("#amount").val('').focus();
|
||||
$("#reconciliation_display").hide();
|
||||
$("#reconciliation_note_field").hide();
|
||||
$("#reconciliation_note").val('');
|
||||
});
|
||||
</script>
|
||||
[% END %]
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ $(document).ready(function () {
|
|||
summary_modal.find("#from_date").text(from_date);
|
||||
let to_date = $datetime(data.summary.to_date);
|
||||
summary_modal.find("#to_date").text(to_date);
|
||||
|
||||
// Check for reconciliation (surplus or deficit) from dedicated fields
|
||||
var surplus = data.summary.surplus_total;
|
||||
var deficit = data.summary.deficit_total;
|
||||
var expectedAmount = data.summary.total;
|
||||
var actualAmount = data.amount;
|
||||
|
||||
var tbody = summary_modal.find("tbody");
|
||||
tbody.empty();
|
||||
for (out of data.summary.payout_grouped) {
|
||||
|
|
@ -61,19 +68,104 @@ $(document).ready(function () {
|
|||
|
||||
var tfoot = summary_modal.find("tfoot");
|
||||
tfoot.empty();
|
||||
|
||||
// 1. Total (sum of all transactions)
|
||||
tfoot.append(
|
||||
"<tr><td>Total</td><td>" +
|
||||
"<tr class='total-row'><td><strong>Total</strong></td><td><strong>" +
|
||||
data.summary.total.format_price() +
|
||||
"</td></tr>"
|
||||
"</strong></td></tr>"
|
||||
);
|
||||
|
||||
// Add separator line
|
||||
tfoot.append(
|
||||
"<tr class='reconciliation-separator'><td colspan='2'><hr></td></tr>"
|
||||
);
|
||||
|
||||
// 2. Cash collected (amount recorded as removed from register)
|
||||
var cashCollected = null;
|
||||
for (type of data.summary.total_grouped) {
|
||||
if (type.total !== 0) {
|
||||
if (
|
||||
type.payment_type === "Cash" ||
|
||||
type.payment_type === "CASH"
|
||||
) {
|
||||
cashCollected = type.total;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cashCollected !== null) {
|
||||
tfoot.append(
|
||||
"<tr><td><strong>Cash collected</strong></td><td><strong>" +
|
||||
cashCollected.format_price() +
|
||||
"</strong></td></tr>"
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Other payment types collected (excluding CASH)
|
||||
for (type of data.summary.total_grouped) {
|
||||
if (
|
||||
type.total !== 0 &&
|
||||
type.payment_type !== "Cash" &&
|
||||
type.payment_type !== "CASH"
|
||||
) {
|
||||
tfoot.append(
|
||||
"<tr><td>" +
|
||||
"<tr><td><strong>" +
|
||||
escape_str(type.payment_type) +
|
||||
"</td><td>" +
|
||||
" collected" +
|
||||
"</strong></td><td><strong>" +
|
||||
type.total.format_price() +
|
||||
"</td></tr>"
|
||||
"</strong></td></tr>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Cashup surplus OR deficit (highlighted)
|
||||
if (surplus || deficit) {
|
||||
// Add separator before reconciliation
|
||||
tfoot.append(
|
||||
"<tr class='reconciliation-separator'><td colspan='2'><hr></td></tr>"
|
||||
);
|
||||
|
||||
var reconciliationClass,
|
||||
reconciliationLabel,
|
||||
reconciliationAmount,
|
||||
reconciliationNote;
|
||||
|
||||
if (surplus) {
|
||||
reconciliationClass =
|
||||
"reconciliation-result text-warning";
|
||||
reconciliationLabel = "Cashup surplus";
|
||||
reconciliationAmount =
|
||||
"+" + Math.abs(surplus).format_price();
|
||||
reconciliationNote = data.summary.surplus_note;
|
||||
} else if (deficit) {
|
||||
reconciliationClass =
|
||||
"reconciliation-result text-danger";
|
||||
reconciliationLabel = "Cashup deficit";
|
||||
reconciliationAmount =
|
||||
"-" + Math.abs(deficit).format_price();
|
||||
reconciliationNote = data.summary.deficit_note;
|
||||
}
|
||||
|
||||
tfoot.append(
|
||||
"<tr class='" +
|
||||
reconciliationClass +
|
||||
"'><td><strong>" +
|
||||
reconciliationLabel +
|
||||
"</strong></td><td><strong>" +
|
||||
reconciliationAmount +
|
||||
"</strong></td></tr>"
|
||||
);
|
||||
|
||||
// Add note if present
|
||||
if (reconciliationNote) {
|
||||
tfoot.append(
|
||||
"<tr class='" +
|
||||
reconciliationClass +
|
||||
"'><td colspan='2'><em>" +
|
||||
__("Note:") +
|
||||
" " +
|
||||
escape_str(reconciliationNote) +
|
||||
"</em></td></tr>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,17 +104,29 @@ if ( !$registers->count ) {
|
|||
my $op = $input->param('op') // '';
|
||||
if ( $op eq 'cud-cashup' ) {
|
||||
if ( $logged_in_user->has_permission( { cash_management => 'cashup' } ) ) {
|
||||
my $cashup = $cash_register->add_cashup(
|
||||
{
|
||||
manager_id => $logged_in_user->id,
|
||||
amount => $cash_register->outstanding_accountlines->total
|
||||
}
|
||||
);
|
||||
my $amount = $input->param('amount');
|
||||
|
||||
# Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
|
||||
print $input->redirect(
|
||||
"/cgi-bin/koha/pos/register.pl?registerid=" . $registerid . "#cashup-" . $cashup->id );
|
||||
exit;
|
||||
# add_cashup() sanitizes (trims, limits length, empty-to-undef) the note itself
|
||||
my $reconciliation_note = $input->param('reconciliation_note');
|
||||
|
||||
if ( defined $amount && $amount =~ /^\d+(?:\.\d{1,2})?$/ ) {
|
||||
|
||||
my $cashup = $cash_register->add_cashup(
|
||||
{
|
||||
manager_id => $logged_in_user->id,
|
||||
amount => $amount,
|
||||
reconciliation_note => $reconciliation_note
|
||||
}
|
||||
);
|
||||
|
||||
# Redirect to prevent duplicate submissions (POST/REDIRECT/GET pattern)
|
||||
print $input->redirect(
|
||||
"/cgi-bin/koha/pos/register.pl?registerid=" . $registerid . "#cashup-" . $cashup->id );
|
||||
exit;
|
||||
|
||||
} else {
|
||||
$template->param( error_cashup_amount => 1 );
|
||||
}
|
||||
} else {
|
||||
$template->param( error_cashup_permission => 1 );
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,14 @@
|
|||
use Modern::Perl;
|
||||
|
||||
use Test::NoWarnings;
|
||||
use Test::More tests => 5;
|
||||
use Test::More tests => 6;
|
||||
|
||||
use Test::Exception;
|
||||
|
||||
use Koha::Database;
|
||||
use Koha::Account;
|
||||
use Koha::Account::CreditTypes;
|
||||
use Koha::Account::DebitTypes;
|
||||
|
||||
use t::lib::TestBuilder;
|
||||
|
||||
|
|
@ -312,3 +315,439 @@ subtest 'cashup' => sub {
|
|||
|
||||
$schema->storage->txn_rollback;
|
||||
};
|
||||
|
||||
subtest 'cashup_reconciliation' => sub {
|
||||
plan tests => 5;
|
||||
|
||||
$schema->storage->txn_begin;
|
||||
|
||||
# Ensure required account types for reconciliation exist (they should already exist from mandatory data)
|
||||
use Koha::Account::CreditTypes;
|
||||
use Koha::Account::DebitTypes;
|
||||
|
||||
my $surplus_credit_type = Koha::Account::CreditTypes->find( { code => 'CASHUP_SURPLUS' } );
|
||||
if ( !$surplus_credit_type ) {
|
||||
$surplus_credit_type = $builder->build_object(
|
||||
{
|
||||
class => 'Koha::Account::CreditTypes',
|
||||
value => {
|
||||
code => 'CASHUP_SURPLUS',
|
||||
description => 'Cash register surplus found during cashup',
|
||||
can_be_added_manually => 0,
|
||||
credit_number_enabled => 0,
|
||||
is_system => 1,
|
||||
archived => 0,
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
my $deficit_debit_type = Koha::Account::DebitTypes->find( { code => 'CASHUP_DEFICIT' } );
|
||||
if ( !$deficit_debit_type ) {
|
||||
$deficit_debit_type = $builder->build_object(
|
||||
{
|
||||
class => 'Koha::Account::DebitTypes',
|
||||
value => {
|
||||
code => 'CASHUP_DEFICIT',
|
||||
description => 'Cash register deficit found during cashup',
|
||||
can_be_invoiced => 0,
|
||||
can_be_sold => 0,
|
||||
default_amount => undef,
|
||||
is_system => 1,
|
||||
archived => 0,
|
||||
restricts_checkouts => 0,
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
my $register = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
|
||||
|
||||
# Create some outstanding accountlines to establish expected amount
|
||||
my $accountline1 = $builder->build_object(
|
||||
{
|
||||
class => 'Koha::Account::Lines',
|
||||
value => {
|
||||
register_id => $register->id,
|
||||
borrowernumber => $patron->id,
|
||||
amount => -10.00, # Credit (payment)
|
||||
credit_type_code => 'PAYMENT',
|
||||
debit_type_code => undef,
|
||||
}
|
||||
}
|
||||
);
|
||||
my $accountline2 = $builder->build_object(
|
||||
{
|
||||
class => 'Koha::Account::Lines',
|
||||
value => {
|
||||
register_id => $register->id,
|
||||
borrowernumber => $patron->id,
|
||||
amount => -5.00, # Credit (payment)
|
||||
credit_type_code => 'PAYMENT',
|
||||
debit_type_code => undef,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
my $expected_amount = $register->outstanding_accountlines->total; # Should be -15.00
|
||||
|
||||
subtest 'balanced_cashup' => sub {
|
||||
plan tests => 3;
|
||||
|
||||
# Test exact match - no surplus/deficit accountlines should be created
|
||||
my $amount = abs($expected_amount); # 15.00 actual matches 15.00 expected
|
||||
|
||||
my $cashup = $register->add_cashup(
|
||||
{
|
||||
manager_id => $patron->id,
|
||||
amount => $amount
|
||||
}
|
||||
);
|
||||
|
||||
ok( $cashup, 'Cashup created successfully' );
|
||||
is( sprintf( '%.0f', $cashup->amount ), sprintf( '%.0f', $amount ), 'Cashup amount matches actual amount' );
|
||||
|
||||
# Check no surplus/deficit accountlines were created
|
||||
my $reconciliation_lines = Koha::Account::Lines->search(
|
||||
{
|
||||
register_id => $register->id,
|
||||
'-or' => [
|
||||
{ credit_type_code => 'CASHUP_SURPLUS' },
|
||||
{ debit_type_code => 'CASHUP_DEFICIT' }
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
is( $reconciliation_lines->count, 0, 'No reconciliation accountlines created for balanced cashup' );
|
||||
};
|
||||
|
||||
subtest 'surplus_cashup' => sub {
|
||||
plan tests => 7;
|
||||
|
||||
$schema->storage->txn_begin;
|
||||
|
||||
my $register2 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $accountline3 = $builder->build_object(
|
||||
{
|
||||
class => 'Koha::Account::Lines',
|
||||
value => {
|
||||
register_id => $register2->id,
|
||||
borrowernumber => $patron->id,
|
||||
amount => -20.00, # Credit (payment)
|
||||
credit_type_code => 'PAYMENT',
|
||||
debit_type_code => undef,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
my $expected = abs( $register2->outstanding_accountlines->total ); # 20.00
|
||||
my $actual = 25.00; # 5.00 surplus
|
||||
my $surplus = $actual - $expected;
|
||||
|
||||
my $cashup = $register2->add_cashup(
|
||||
{
|
||||
manager_id => $patron->id,
|
||||
amount => $actual
|
||||
}
|
||||
);
|
||||
|
||||
ok( $cashup, 'Surplus cashup created successfully' );
|
||||
is( sprintf( '%.0f', $cashup->amount ), sprintf( '%.0f', $actual ), 'Cashup amount matches actual amount' );
|
||||
|
||||
# Check surplus accountline was created
|
||||
my $surplus_lines = Koha::Account::Lines->search(
|
||||
{
|
||||
register_id => $register2->id,
|
||||
credit_type_code => 'CASHUP_SURPLUS'
|
||||
}
|
||||
);
|
||||
|
||||
is( $surplus_lines->count, 1, 'One surplus accountline created' );
|
||||
|
||||
my $surplus_line = $surplus_lines->next;
|
||||
is(
|
||||
sprintf( '%.0f', $surplus_line->amount ), sprintf( '%.0f', -$surplus ),
|
||||
'Surplus amount is correct (negative for credit)'
|
||||
);
|
||||
is( $surplus_line->branchcode, $register2->branch, 'Surplus branchcode matches register branch' );
|
||||
is( $surplus_line->payment_type, 'CASH', 'Surplus payment_type is set to CASH' );
|
||||
is( sprintf( '%.0f', $surplus_line->amountoutstanding ), '0', 'Surplus amountoutstanding is set to 0' );
|
||||
|
||||
# Note should be undef for surplus without user note
|
||||
is( $surplus_line->note, undef, 'No note for surplus without user reconciliation note' );
|
||||
|
||||
# Test surplus with user note
|
||||
my $register_with_note = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $accountline_with_note = $builder->build_object(
|
||||
{
|
||||
class => 'Koha::Account::Lines',
|
||||
value => {
|
||||
register_id => $register_with_note->id,
|
||||
borrowernumber => $patron->id,
|
||||
amount => -10.00,
|
||||
credit_type_code => 'PAYMENT',
|
||||
debit_type_code => undef,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
my $cashup_with_note = $register_with_note->add_cashup(
|
||||
{
|
||||
manager_id => $patron->id,
|
||||
amount => 15.00, # 5.00 surplus
|
||||
reconciliation_note => 'Found extra \x{00A3}5 under the till drawer' # £5 in UTF-8
|
||||
}
|
||||
);
|
||||
|
||||
my $surplus_with_note = Koha::Account::Lines->search(
|
||||
{
|
||||
register_id => $register_with_note->id,
|
||||
credit_type_code => 'CASHUP_SURPLUS'
|
||||
}
|
||||
)->next;
|
||||
|
||||
like(
|
||||
$surplus_with_note->note, qr/Found extra .+5 under the till drawer/,
|
||||
'User note included in surplus accountline'
|
||||
);
|
||||
is(
|
||||
$surplus_with_note->note, 'Found extra \x{00A3}5 under the till drawer',
|
||||
'Only user note stored (no base reconciliation info)'
|
||||
);
|
||||
|
||||
$schema->storage->txn_rollback;
|
||||
};
|
||||
|
||||
subtest 'deficit_cashup' => sub {
|
||||
plan tests => 7;
|
||||
|
||||
$schema->storage->txn_begin;
|
||||
|
||||
my $register3 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $accountline4 = $builder->build_object(
|
||||
{
|
||||
class => 'Koha::Account::Lines',
|
||||
value => {
|
||||
register_id => $register3->id,
|
||||
borrowernumber => $patron->id,
|
||||
amount => -30.00, # Credit (payment)
|
||||
credit_type_code => 'PAYMENT',
|
||||
debit_type_code => undef,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
my $expected = abs( $register3->outstanding_accountlines->total ); # 30.00
|
||||
my $actual = 25.00; # 5.00 deficit
|
||||
my $deficit = $expected - $actual;
|
||||
|
||||
my $cashup = $register3->add_cashup(
|
||||
{
|
||||
manager_id => $patron->id,
|
||||
amount => $actual
|
||||
}
|
||||
);
|
||||
|
||||
ok( $cashup, 'Deficit cashup created successfully' );
|
||||
is( sprintf( '%.0f', $cashup->amount ), sprintf( '%.0f', $actual ), 'Cashup amount matches actual amount' );
|
||||
|
||||
# Check deficit accountline was created
|
||||
my $deficit_lines = Koha::Account::Lines->search(
|
||||
{
|
||||
register_id => $register3->id,
|
||||
debit_type_code => 'CASHUP_DEFICIT'
|
||||
}
|
||||
);
|
||||
|
||||
is( $deficit_lines->count, 1, 'One deficit accountline created' );
|
||||
|
||||
my $deficit_line = $deficit_lines->next;
|
||||
is(
|
||||
sprintf( '%.0f', $deficit_line->amount ), sprintf( '%.0f', $deficit ),
|
||||
'Deficit amount is correct (positive for debit)'
|
||||
);
|
||||
is( $deficit_line->branchcode, $register3->branch, 'Deficit branchcode matches register branch' );
|
||||
is( $deficit_line->payment_type, 'CASH', 'Deficit payment_type is set to CASH' );
|
||||
is( sprintf( '%.0f', $deficit_line->amountoutstanding ), '0', 'Deficit amountoutstanding is set to 0' );
|
||||
|
||||
# Note should be undef for deficit without user note
|
||||
is( $deficit_line->note, undef, 'No note for deficit without user reconciliation note' );
|
||||
|
||||
# Test deficit with user note
|
||||
my $register_deficit_note = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $accountline_deficit_note = $builder->build_object(
|
||||
{
|
||||
class => 'Koha::Account::Lines',
|
||||
value => {
|
||||
register_id => $register_deficit_note->id,
|
||||
borrowernumber => $patron->id,
|
||||
amount => -20.00,
|
||||
credit_type_code => 'PAYMENT',
|
||||
debit_type_code => undef,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
my $cashup_deficit_note = $register_deficit_note->add_cashup(
|
||||
{
|
||||
manager_id => $patron->id,
|
||||
amount => 15.00, # 5.00 deficit
|
||||
reconciliation_note => 'Till was short, possibly due to incorrect change given'
|
||||
}
|
||||
);
|
||||
|
||||
my $deficit_with_note = Koha::Account::Lines->search(
|
||||
{
|
||||
register_id => $register_deficit_note->id,
|
||||
debit_type_code => 'CASHUP_DEFICIT'
|
||||
}
|
||||
)->next;
|
||||
|
||||
like(
|
||||
$deficit_with_note->note, qr/Till was short, possibly due to incorrect change given/,
|
||||
'User note included in deficit accountline'
|
||||
);
|
||||
is(
|
||||
$deficit_with_note->note, 'Till was short, possibly due to incorrect change given',
|
||||
'Only user note stored (no base reconciliation info)'
|
||||
);
|
||||
|
||||
$schema->storage->txn_rollback;
|
||||
};
|
||||
|
||||
subtest 'transaction_integrity' => sub {
|
||||
plan tests => 4;
|
||||
|
||||
$schema->storage->txn_begin;
|
||||
|
||||
my $register4 = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $accountline5 = $builder->build_object(
|
||||
{
|
||||
class => 'Koha::Account::Lines',
|
||||
value => {
|
||||
register_id => $register4->id,
|
||||
borrowernumber => $patron->id,
|
||||
amount => -10.00,
|
||||
credit_type_code => 'PAYMENT',
|
||||
debit_type_code => undef,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
my $initial_accountline_count = Koha::Account::Lines->search( { register_id => $register4->id } )->count;
|
||||
|
||||
my $initial_action_count = $register4->cashups->count;
|
||||
|
||||
# Test successful transaction
|
||||
my $cashup = $register4->add_cashup(
|
||||
{
|
||||
manager_id => $patron->id,
|
||||
amount => 15.00 # Creates surplus
|
||||
}
|
||||
);
|
||||
|
||||
# Check both cashup action and surplus accountline were created
|
||||
is( $register4->cashups->count, $initial_action_count + 1, 'Cashup action created' );
|
||||
|
||||
my $final_accountline_count = Koha::Account::Lines->search( { register_id => $register4->id } )->count;
|
||||
|
||||
is( $final_accountline_count, $initial_accountline_count + 1, 'Surplus accountline created' );
|
||||
|
||||
# Verify the new accountline is the surplus
|
||||
my $surplus_line = Koha::Account::Lines->search(
|
||||
{
|
||||
register_id => $register4->id,
|
||||
credit_type_code => 'CASHUP_SURPLUS'
|
||||
}
|
||||
)->next;
|
||||
|
||||
ok( $surplus_line, 'Surplus accountline exists' );
|
||||
is( $surplus_line->register_id, $register4->id, 'Surplus linked to correct register' );
|
||||
|
||||
$schema->storage->txn_rollback;
|
||||
};
|
||||
|
||||
subtest 'note_handling' => sub {
|
||||
plan tests => 2;
|
||||
|
||||
$schema->storage->txn_begin;
|
||||
|
||||
my $register_note_test = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $accountline_note_test = $builder->build_object(
|
||||
{
|
||||
class => 'Koha::Account::Lines',
|
||||
value => {
|
||||
register_id => $register_note_test->id,
|
||||
borrowernumber => $patron->id,
|
||||
amount => -10.00,
|
||||
credit_type_code => 'PAYMENT',
|
||||
debit_type_code => undef,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
# Test balanced cashup with note (should not create surplus/deficit)
|
||||
my $balanced_cashup = $register_note_test->add_cashup(
|
||||
{
|
||||
manager_id => $patron->id,
|
||||
amount => 10.00, # Balanced
|
||||
reconciliation_note => 'This note should be ignored for balanced cashup'
|
||||
}
|
||||
);
|
||||
|
||||
my $balanced_reconciliation_lines = Koha::Account::Lines->search(
|
||||
{
|
||||
register_id => $register_note_test->id,
|
||||
'-or' => [
|
||||
{ credit_type_code => 'CASHUP_SURPLUS' },
|
||||
{ debit_type_code => 'CASHUP_DEFICIT' }
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
is(
|
||||
$balanced_reconciliation_lines->count, 0,
|
||||
'No reconciliation accountlines created for balanced cashup with note'
|
||||
);
|
||||
|
||||
# Test empty/whitespace note handling
|
||||
my $register_empty_note = $builder->build_object( { class => 'Koha::Cash::Registers' } );
|
||||
my $accountline_empty_note = $builder->build_object(
|
||||
{
|
||||
class => 'Koha::Account::Lines',
|
||||
value => {
|
||||
register_id => $register_empty_note->id,
|
||||
borrowernumber => $patron->id,
|
||||
amount => -10.00,
|
||||
credit_type_code => 'PAYMENT',
|
||||
debit_type_code => undef,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
my $empty_note_cashup = $register_empty_note->add_cashup(
|
||||
{
|
||||
manager_id => $patron->id,
|
||||
amount => 12.00, # 2.00 surplus
|
||||
reconciliation_note => ' ' # Whitespace only
|
||||
}
|
||||
);
|
||||
|
||||
my $empty_note_surplus = Koha::Account::Lines->search(
|
||||
{
|
||||
register_id => $register_empty_note->id,
|
||||
credit_type_code => 'CASHUP_SURPLUS'
|
||||
}
|
||||
)->next;
|
||||
|
||||
is(
|
||||
$empty_note_surplus->note, undef,
|
||||
'No note stored when user note is empty/whitespace'
|
||||
);
|
||||
|
||||
$schema->storage->txn_rollback;
|
||||
};
|
||||
|
||||
$schema->storage->txn_rollback;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue