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:
Martin Renvoize 2026-02-16 07:41:52 +00:00 committed by Pedro Amorim
parent 6a38111462
commit 6d5c2f463f
No known key found for this signature in database
GPG Key ID: 06B75BA7F3894D55
7 changed files with 827 additions and 34 deletions

View File

@ -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

View File

@ -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;

View File

@ -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">

View File

@ -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 %]

View File

@ -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>"
);
}
}

View File

@ -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 );
}

View File

@ -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;
};