use OpenSRF::Utils::Logger qw(:logger);
use OpenILS::Utils::CStoreEditor q/:funcs/;
use OpenILS::Const qw/:const/;
+use List::MoreUtils qw(uniq);
my $U = "OpenILS::Application::AppUtils";
# -----------------------------------------------------------------
-# Voids overdue fines on the given circ. if a backdate is
+# Voids overdue fines on the given circ. if a backdate is
# provided, then we only void back to the backdate, unless the
# backdate is to within the grace period, in which case we void all
# overdue fines.
sub void_overdues {
my($class, $e, $circ, $backdate, $note) = @_;
- my $bill_search = {
- xact => $circ->id,
- btype => 1
+ my $bill_search = {
+ xact => $circ->id,
+ btype => 1
};
if( $backdate ) {
# voiding fines that were applicable before the backdate.
# ------------------------------------------------------------------
- # if there is a raw time component (e.g. from postgres),
+ # if there is a raw time component (e.g. from postgres),
# turn it into an interval that interval_to_seconds can parse
my $duration = $circ->fine_interval;
$duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
}
}
- my $bills = $class->oustanding_bills_for_xact($e, $circ, $bill_search);
-
- # Sum any outstanding overdue billings
- my $outstanding_overdues = 0;
-
- foreach my $bill (@$bills) {
- $outstanding_overdues = ($outstanding_overdues*100 + $bill->amount*100)/100;
- }
-
- if ($outstanding_overdues >= 0.01) {
- # Make void payment
- my $payobj = Fieldmapper::money::void_payment->new;
- $payobj->amount($outstanding_overdues);
- $payobj->amount_collected($outstanding_overdues);
- $payobj->xact($circ->id);
- $payobj->note($note);
- $payobj->accepting_usr($e->requestor->id);
-
- $e->create_money_void_payment($payobj) or return $e->die_event;
+ my $billids = $e->search_money_billing([$bill_search, {idlist=>1}]);
+ if ($billids && @$billids) {
+ my $result = $class->real_void_bills($e->authtoken, $billids, $note);
+ if (ref($result)) {
+ return $result;
+ }
}
return undef;
# -----------------------------------------------------------------
# Given an editor and a xact, return a reference to an array of
-# billing objects which are outstanding (unpaid, not voided).
-# If a bill is partially paid, change the amount of the bill
-# to reflect the unpaid amount, not the original amount.
+# hashrefs that map billing objects to payment objects. Returns undef
+# if no bills are found for the given transaction.
+#
+# The bill amounts are adjusted to reflect the application of the
+# payments to the bills. The original bill amounts are retained in
+# the mapping.
+#
+# The payment objects may or may not have their amounts adjusted
+# depending on whether or not they apply to more than one bill. We
+# could really use a better logic here, perhaps, but if it was
+# consistent, it wouldn't be Evergreen.
+#
+# The data structure used in the array is a hashref that has the
+# following fields:
#
-# It also takes an optional last parameter as a bill search predicate
-# filter.
+# bill => the adjusted bill object
+# voids => an arrayref of void payments that apply directly to the
+# bill
+# payments => an arrayref of payment objects applied to the bill
+# bill_amount => original amount from the billing object
+# void_amount => total of the void payments that apply directly to the
+# bill
#
-# This function is adapted from code written by Jeff Godin of Traverse
-# Area District Library and submitted on LaunchPad bug #1009049.
+# Each bill is only mapped to payments one time. However, a single
+# payment may be mapped to more than one bill if the payment amount is
+# greater than the amount of each individual bill, such as a $3.00
+# payment for 30 $0.10 overdue bills. There is an attempt made to
+# first pay bills with payments that match the billing amount. This
+# is intended to catch payments for lost and/or long overdue bills so
+# that they will match up.
+#
+# NOTE: The original bill amount is considered the original bill
+# amount minus any amount of void payment linked to that bill. Void
+# payment not applied to any particular billing are handled like
+# regular payments.
+#
+# I wonder if it would not be better to just summarize the billings by
+# type?
+#
+# This function is heavily adapted from code written by Jeff Godin of
+# Traverse Area District Library and submitted on LaunchPad bug
+# #1009049.
# -----------------------------------------------------------------
-sub outstanding_bills_for_xact {
- my ($class, $e, $xact, $bill_predicate) = @_;
+sub bill_payment_map_for_xact {
+ my ($class, $e, $xact) = @_;
- # A little defensive coding never hurts.
- unless ($bill_predicate) {
- $bill_predicate = {xact => $xact->id};
- } else {
- $bill_predicate->{xact} = $xact->id unless ($bill_predicate->{xact});
- }
-
- # find all unvoided bills in order
+ # find all bills in order
my $bill_search = [
- $bill_predicate,
+ {xact => $xact->id()},
{ order_by => { mb => { billing_ts => { direction => 'asc' } } } },
];
{ order_by => { mp => { payment_ts => { direction => 'asc' } } } },
];
+ # At some point, we should get rid of the voided column on
+ # money.payment and family. It is not exposed in the client at
+ # the moment, and should be replaced with a void_bill type. The
+ # descendants of money.payment don't expose the voided field in
+ # the fieldmapper, only the mp object, based on the money.payment
+ # view, does. However, I want to leave that complication for
+ # later. I wonder if I'm not slowing things down too much with
+ # the current void_payment logic. It would probably be faster if
+ # we had direct Pg access at this layer. I can probably wrangle
+ # something via the drivers or store interfaces, but I haven't
+ # really figured those out, yet.
+
my $bills = $e->search_money_billing($bill_search);
- my $payments = $e->search_money_payment($payment_search);
-
- # "Pay" the bills, removing fully paid bills and
- # adjusting the amount for partially paid bills
- map {
- my $payment = $_;
- my $paybal = $payment->amount;
-
- while ($paybal > 0) {
- # get next billing
- my $bill = shift @{$bills};
- my $newbal = (($paybal*100) - ($bill->amount*100))/100;
- if ($newbal < 0) {
- $newbal = 0;
- my $new_bill_amount = (($bill->amount*100) - ($paybal*100))/100;
- $bill->amount($new_bill_amount);
- unshift(@{$bills}, $bill); # put the partially-paid bill back on top of the stack
+ # return undef if there are no bills.
+ return undef unless ($bills && @$bills);
+
+ # map the bills into our bill_payment_map entry format:
+ my @entries = map {
+ {
+ bill => $_,
+ bill_amount => $_->amount(),
+ payments => [],
+ voids => [],
+ void_amount => 0
+ }
+ } @$bills;
+
+ # Retrieve the payments. Flesh voids do that we don't have to
+ # retrieve them later.
+ my $payments = $e->search_money_payment(
+ [
+ $payment_search,
+ {
+ flesh=>1,
+ flesh_fields => {
+ mp => ['void_payment']
+ }
+ }
+ ]);
+
+ # If there were no payments, then we just return the bills.
+ return \@entries unless ($payments && @$payments);
+
+ # Now, we go through the rigmarole of mapping payments to bills
+ # and adjusting the bill balances.
+
+ # Apply the voids before "paying" other bills.
+ foreach my $entry (@entries) {
+ my $bill = $entry->{bill};
+ # Find only the voids that apply to individual bills.
+ my @voids = map {$_->void_payment()} grep {$_->payment_type() eq 'void_payment' && $_->void_payment()->billing() == $bill->id()} @$payments;
+ if (@voids) {
+ foreach my $void (@voids) {
+ my $new_amount = ($bill->amount() * 100 - $void->amount() * 100) / 100;
+ if ($new_amount >= 0) {
+ push @{$entry->{voids}}, $void;
+ $entry->{void_amount} += $void->amount();
+ $bill->amount($new_amount);
+ # Remove the used up void from list of payments:
+ my @p = grep {$_->id() != $void->id()} @$payments;
+ $payments = \@p;
+ } else {
+ # It should never happen that we have more void
+ # payments on a single bill than the amount of the
+ # bill. However, experience shows that the things
+ # that should never happen actually do happen with
+ # surprising regularity in a library setting.
+
+ # Clone the void to say how much of it actually
+ # applied to this bill.
+ my $new_void = $void->clone();
+ $new_void->amount($bill->amount());
+ $new_void->amount_collected($bill->amount());
+ push (@{$entry->{voids}}, $new_void);
+ $entry->{void_amount} += $new_void->amount();
+ $bill->amount(0);
+ $void->amount(-$new_amount);
+ # Could be a candidate for YAOUS about what to do
+ # with excess void amounts on a bill.
+ }
+ last if ($bill->amount() == 0);
+ }
+ }
+ }
+
+ # Try to map payments to bills by amounts starting with the
+ # largest payments:
+ foreach my $payment (sort {$b->amount() <=> $a->amount()} @$payments) {
+ my @bills2pay = grep {$_->{bill}->amount() == $payment->amount()} @entries;
+ if (@bills2pay) {
+ my $entry = $bills2pay[0];
+ $entry->{bill}->amount(0);
+ push @{$entry->{payments}}, $payment;
+ # Remove the payment from the master list.
+ my @p = grep {$_->id() != $payment->id()} @$payments;
+ $payments = \@p;
+ }
+ }
+
+ # Map remaining bills to payments in whatever order.
+ foreach my $entry (grep {$_->{bill}->amount() > 0} @entries) {
+ my $bill = $entry->{bill};
+ # We could run out of payments before bills.
+ if ($payments && @$payments) {
+ while ($bill->amount() > 0) {
+ my $payment = shift @$payments;
+ last unless $payment;
+ my $new_amount = ($bill->amount() * 100 - $payment->amount() * 100) / 100;
+ if ($new_amount < 0) {
+ # Clone the payment so we can say how much applied
+ # to this bill.
+ my $new_payment = $payment->clone();
+ $new_payment->amount($bill->amount());
+ $bill->amount(0);
+ push @{$entry->{payments}}, $new_payment;
+ # Reset the payment amount and put it back on the
+ # list for later use.
+ $payment->amount(-$new_amount);
+ unshift @$payments, $payment;
+ } else {
+ $bill->amount($new_amount);
+ push @{$entry->{payments}}, $payment;
+ }
+ }
+ }
+ }
+
+ return \@entries;
+}
+
+
+# This subroutine actually handles voiding of bills. It takes an
+# authtoken, an arrayref of bill ids or bills, and an optional note.
+sub real_void_bills {
+ my ($class, $authtoken, $billids, $note) = @_;
+
+ # Get an editor and see if we have permission to void bills.
+ my $e = new_editor( authtoken => $authtoken, xact => 1 );
+ return $e->die_event unless $e->checkauth;
+ return $e->die_event unless $e->allowed('VOID_BILLING');
+
+ my %users;
+
+ # Let's get all the billing objects and handle them by
+ # transaction.
+ my $bills;
+ if (ref($billids->[0])) {
+ $bills = $billids;
+ } else {
+ $bills = $e->search_money_billing([{id => $billids}])
+ or return $e->die_event;
+ }
+
+ my @xactids = uniq map {$_->xact()} @$bills;
+
+ foreach my $xactid (@xactids) {
+ my $mbt = $e->retrieve_money_billable_transaction(
+ [
+ $xactid,
+ {
+ flesh=> 2,
+ flesh_fields=> {
+ mbt=>['grocery','circulation'],
+ circ=>['target_copy']
+ }
+ }
+ ]
+ ) or return $e->die_event;
+ # Flesh grocery bills and circulations so we don't have to
+ # retrieve them later.
+ my ($circ, $grocery, $copy);
+ my $isgrocery = ($mbt->grocery()) ? 1 : 0;
+ if ($isgrocery) {
+ # We don't actually use this, yet, but just in case.
+ $grocery = $mbt->grocery();
+ } else {
+ $circ = $mbt->circulation();
+ $copy = $circ->target_copy();
+ }
+
+ # Retrieve settings based on transaction location and copy
+ # location if we have a circulation.
+ my ($neg_balance_default, $neg_balance_overdues,
+ $neg_balance_lost, $neg_balance_interval_default,
+ $neg_balance_interval_overdues, $neg_balance_interval_lost);
+ if (!$isgrocery) {
+ # defaults and overdue settings come from transaction org unit.
+ $neg_balance_default = $U->ou_ancestor_setting(
+ $circ->circ_lib(), 'circ.prohibit_negative_balance_default');
+ $neg_balance_overdues = $U->ou_ancestor_setting(
+ $circ->circ_lib(), 'circ.prohibit_negative_balance_on_overdues');
+ $neg_balance_interval_default = $U->ou_ancestor_setting(
+ $circ->circ_lib(), 'circ.negative_balance_interval_default');
+ $neg_balance_interval_overdues = $U->ou_ancestor_setting(
+ $circ->circ_lib(), 'circ.negative_balance_interval_on_overdues');
+ # settings for lost come from copy circlib.
+ $neg_balance_lost = (
+ $U->ou_ancestor_setting($copy->circ_lib(), 'circ.prohibit_negative_balance_on_lost')
+ ||
+ $U->ou_ancestor_setting($copy->circ_lib(), 'circ.prohibit_negative_balance_default')
+ );
+ $neg_balance_interval_lost = (
+ $U->ou_ancestor_setting($copy->circ_lib(), 'circ.negative_balance_interval_on_lost')
+ ||
+ $U->ou_ancestor_setting($copy->circ_lib(), 'circ.negative_balance_interval_default')
+ );
+ }
+
+ # Get the bill_payment_map for the transaction.
+ my $bpmap = $class->bill_payment_map_for_xact($e, $mbt);
+
+ # Get the bills for this transaction from the main list of bills.
+ my @xact_bills = grep {$_->xact() == $xactid} @$bills;
+ # Handle each bill in turn.
+ foreach my $bill (@xact_bills) {
+ # As the total open amount on the transaction will change
+ # as each bill is voided, we'll just recalculate it for
+ # each bill.
+ my $xact_total = 0;
+ map {$xact_total += $_->{bill}->amount()} @$bpmap;
+
+ # Get the bill_payment_map entry for this bill:
+ my ($bpentry) = grep {$_->{bill}->id() == $bill->id()} @$bpmap;
+
+ # Check if this bill is already voided. We don't allow
+ # "double" voids regardless of settings. The old code
+ # made it impossible to void an already voided bill, so
+ # we're doing the same.
+ if ($bpentry->{void_amount} > 0 && $bpentry->{void_amount} == $bpentry->{bill_amount}) {
+ $e->rollback;
+ return OpenILS::Event->new('BILL_ALREADY_VOIDED', payload => $bill);
+ }
+
+ # We'll use this variable to determine if we need a void
+ # and how much the void payment amount should be.
+ my $amount_to_void = 0;
+
+ # None of our new settings apply to grocery bills, so
+ # we'll just void them, regardless of balances, etc.
+ if ($isgrocery) {
+ $amount_to_void = $bpentry->{bill}->amount();
+ } else {
+ # Hang on tight. It's about to get hairy.
+
+ }
+
+ # Create the void payment if necessary:
+ if ($amount_to_void > 0) {
+ my $payobj = Fieldmapper::money::void_payment->new;
+ $payobj->amount($amount_to_void);
+ $payobj->amount_collected($amount_to_void);
+ $payobj->xact($xactid);
+ $payobj->accepting_usr($e->requestor->id);
+ $payobj->payment_ts('now');
+ $payobj->billing($bill->id());
+ $payobj->note($note) if ($note);
+ $e->create_money_void_payment($payobj) or return $e->die_event;
+ # Adjust our bill_payment_map
+ $bpentry->{void_amount} += $amount_to_void;
+ push @{$bpentry->{voids}}, $payobj;
+ # Should come to zero:
+ my $new_bill_amount = ($bpentry->{bill}->amount() * 100 - $amount_to_void * 100) / 100;
+ $bpentry->{bill}->amount($new_bill_amount);
}
- $paybal = $newbal;
}
- } @$payments;
+ my $org = $U->xact_org($xactid, $e);
+ $users{$mbt->usr} = {} unless $users{$mbt->usr};
+ $users{$mbt->usr}->{$org} = 1;
- return $bills;
+ my $evt = $U->check_open_xact($e, $xactid, $mbt);
+ return $evt if $evt;
+ }
+ # calculate penalties for all user/org combinations
+ for my $user_id (keys %users) {
+ for my $org_id (keys %{$users{$user_id}}) {
+ OpenILS::Utils::Penalty->calculate_penalties($e, $user_id, $org_id);
+ }
+ }
+ $e->commit;
+ return 1;
}
1;
'description'
),
'string', null)
-
,( 'circ.patron_edit.duplicate_patron_check_depth', 'circ',
oils_i18n_gettext(
'circ.patron_edit.duplicate_patron_check_depth',
'coust',
'description'),
'integer', null)
+,( 'bill.prohibit_negative_balance_default', 'finance',
+ oils_i18n_gettext(
+ 'bill.prohibit_negative_balance_default',
+ 'Prohibit negative balance on bills (DEFAULT)',
+ 'coust', 'label'),
+ oils_i18n_gettext(
+ 'bill.prohibit_negative_balance_default',
+ 'Default setting to prevent credits on circulation related bills',
+ 'coust', 'description'),
+ 'bool', null)
+,( 'bill.prohibit_negative_balance_on_overdues', 'finance',
+ oils_i18n_gettext(
+ 'bill.prohibit_negative_balance_on_overdues',
+ 'Prohibit negative balance on bills for overdue materials',
+ 'coust', 'label'),
+ oils_i18n_gettext(
+ 'bill.prohibit_negative_balance_on_overdues',
+ 'Prevent credits on bills for overdue materials',
+ 'coust', 'description'),
+ 'bool', null)
+,( 'bill.prohibit_negative_balance_on_lost', 'finance',
+ oils_i18n_gettext(
+ 'bill.prohibit_negative_balance_on_lost',
+ 'Prohibit negative balance on bills for lost materials',
+ 'coust', 'label'),
+ oils_i18n_gettext(
+ 'bill.prohibit_negative_balance_on_lost',
+ 'Prevent credits on bills for lost/long-overde materials',
+ 'coust', 'description'),
+ 'bool', null)
+,( 'bill.negative_balance_interval_default', 'finance',
+ oils_i18n_gettext(
+ 'bill.negative_balance_interval_default',
+ 'Negative Balance Interval (DEFAULT)',
+ 'coust', 'label'),
+ oils_i18n_gettext(
+ 'bill.negative_balance_interval_default',
+ 'Amount of time after which no negative balances or credits are allowed on circulation bills',
+ 'coust', 'description'),
+ 'interval', null)
+,( 'bill.negative_balance_interval_on_overdues', 'finance',
+ oils_i18n_gettext(
+ 'bill.negative_balance_interval_on_overdues',
+ 'Negative Balance Interval for Overdues',
+ 'coust', 'label'),
+ oils_i18n_gettext(
+ 'bill.negative_balance_interval_on_overdues',
+ 'Amount of time after which no negative balances or credits are allowed on bills for overdue materials',
+ 'coust', 'description'),
+ 'interval', null)
+,( 'bill.negative_balance_interval_on_lost', 'finance',
+ oils_i18n_gettext(
+ 'bill.negative_balance_interval_on_lost',
+ 'Negative Balance Interval for Lost',
+ 'coust', 'label'),
+ oils_i18n_gettext(
+ 'bill.negative_balance_interval_on_lost',
+ 'Amount of time after which no negative balances or credits are allowed on bills for lost/long overdue materials',
+ 'coust', 'description'),
+ 'interval', null)
;
UPDATE config.org_unit_setting_type