1 package OpenILS::Application::Circ::CircCommon;
2 use strict; use warnings;
4 use DateTime::Format::ISO8601;
5 use OpenILS::Application::AppUtils;
6 use OpenSRF::Utils qw/:datetime/;
8 use OpenSRF::Utils::Logger qw(:logger);
9 use OpenILS::Utils::CStoreEditor q/:funcs/;
10 use OpenILS::Const qw/:const/;
13 my $U = "OpenILS::Application::AppUtils";
14 my $parser = DateTime::Format::ISO8601->new;
16 # -----------------------------------------------------------------
17 # Do not publish methods here. This code is shared across apps.
18 # -----------------------------------------------------------------
21 # -----------------------------------------------------------------
22 # Voids overdue fines on the given circ. if a backdate is
23 # provided, then we only void back to the backdate, unless the
24 # backdate is to within the grace period, in which case we void all
26 # -----------------------------------------------------------------
28 my($class, $e, $circ, $backdate, $note) = @_;
36 # ------------------------------------------------------------------
37 # Fines for overdue materials are assessed up to, but not including,
38 # one fine interval after the fines are applicable. Here, we add
39 # one fine interval to the backdate to ensure that we are not
40 # voiding fines that were applicable before the backdate.
41 # ------------------------------------------------------------------
43 # if there is a raw time component (e.g. from postgres),
44 # turn it into an interval that interval_to_seconds can parse
45 my $duration = $circ->fine_interval;
46 $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
47 my $interval = OpenSRF::Utils->interval_to_seconds($duration);
49 my $date = DateTime::Format::ISO8601->parse_datetime(cleanse_ISO8601($backdate));
50 my $due_date = DateTime::Format::ISO8601->parse_datetime(cleanse_ISO8601($circ->due_date))->epoch;
51 my $grace_period = extend_grace_period( $class, $circ->circ_lib, $circ->due_date, OpenSRF::Utils->interval_to_seconds($circ->grace_period), $e);
52 if($date->epoch <= $due_date + $grace_period) {
53 $logger->info("backdate $backdate is within grace period, voiding all");
55 $backdate = $U->epoch2ISO8601($date->epoch + $interval);
56 $logger->info("applying backdate $backdate in overdue voiding");
57 $$bill_search{billing_ts} = {'>=' => $backdate};
61 my $bills = $e->search_money_billing($bill_search);
63 for my $bill (@$bills) {
64 next if $U->is_true($bill->voided);
65 $logger->info("voiding overdue bill ".$bill->id);
67 $bill->void_time('now');
68 $bill->voider($e->requestor->id);
69 my $n = ($bill->note) ? sprintf("%s\n", $bill->note) : "";
70 $bill->note(sprintf("$n%s", ($note) ? $note : "System: VOIDED FOR BACKDATE"));
71 $e->update_money_billing($bill) or return $e->die_event;
77 # ------------------------------------------------------------------
78 # remove charge from patron's account if lost item is returned
79 # ------------------------------------------------------------------
81 my ($class, $e, $circ, $btype) = @_;
83 my $bills = $e->search_money_billing(
91 $logger->debug("voiding lost item charge of ".scalar(@$bills));
92 for my $bill (@$bills) {
93 if( !$U->is_true($bill->voided) ) {
94 $logger->info("lost item returned - voiding bill ".$bill->id);
96 $bill->void_time('now');
97 $bill->voider($e->requestor->id);
98 my $note = ($bill->note) ? $bill->note . "\n" : '';
99 $bill->note("${note}System: VOIDED FOR LOST ITEM RETURNED");
102 unless $e->update_money_billing($bill);
109 my($class, $e, $xactid) = @_;
111 # -----------------------------------------------------------------
112 # make sure the transaction is not closed
113 my $xact = $e->retrieve_money_billable_transaction($xactid)
114 or return $e->die_event;
116 if( $xact->xact_finish ) {
117 my ($mbts) = $U->fetch_mbts($xactid, $e);
118 if( $mbts->balance_owed != 0 ) {
119 $logger->info("* re-opening xact $xactid, orig xact_finish is ".$xact->xact_finish);
120 $xact->clear_xact_finish;
121 $e->update_money_billable_transaction($xact)
122 or return $e->die_event;
131 my($class, $e, $amount, $btype, $type, $xactid, $note) = @_;
133 $logger->info("The system is charging $amount [$type] on xact $xactid");
134 $note ||= 'SYSTEM GENERATED';
136 # -----------------------------------------------------------------
137 # now create the billing
138 my $bill = Fieldmapper::money::billing->new;
139 $bill->xact($xactid);
140 $bill->amount($amount);
141 $bill->billing_type($type);
142 $bill->btype($btype);
144 $e->create_money_billing($bill) or return $e->die_event;
149 sub extend_grace_period {
150 my($class, $circ_lib, $due_date, $grace_period, $e, $h) = @_;
151 if ($grace_period >= 86400) { # Only extend grace periods greater than or equal to a full day
152 my $parser = DateTime::Format::ISO8601->new;
153 my $due_dt = $parser->parse_datetime( cleanse_ISO8601( $due_date ) );
154 my $due = $due_dt->epoch;
156 my $grace_extend = $U->ou_ancestor_setting_value($circ_lib, 'circ.grace.extend');
157 $e = new_editor() if (!$e);
158 $h = $e->retrieve_actor_org_unit_hours_of_operation($circ_lib) if (!$h);
159 if ($grace_extend and $h) {
160 my $new_grace_period = $grace_period;
162 $logger->info( "Circ lib has an hours-of-operation entry and grace period extension is enabled." );
167 my $dow_open = "dow_${i}_open";
168 my $dow_close = "dow_${i}_close";
169 if($h->$dow_open() eq '00:00:00' and $h->$dow_close() eq '00:00:00') {
178 $logger->info("Circ lib is closed all week according to hours-of-operation entry. Skipping grace period extension checks.");
180 # Extra nice grace periods
181 # AKA, merge closed dates trailing the grace period into the grace period
182 my $grace_extend_into_closed = $U->ou_ancestor_setting_value($circ_lib, 'circ.grace.extend.into_closed');
183 $due += 86400 if $grace_extend_into_closed;
185 my $grace_extend_all = $U->ou_ancestor_setting_value($circ_lib, 'circ.grace.extend.all');
187 if ( $grace_extend_all ) {
188 # Start checking the day after the item was due
189 # This is "The grace period only counts open days"
190 # NOTE: Adding 86400 seconds is not the same as adding one day. This uses seconds intentionally.
191 $due_dt = $due_dt->add( seconds => 86400 );
193 # Jump to the end of the grace period
194 # This is "If the grace period ends on a closed day extend it"
195 # NOTE: This adds grace period as a number of seconds intentionally
196 $due_dt = $due_dt->add( seconds => $grace_period );
199 my $count = 0; # Infinite loop protection
201 $closed = 0; # Starting assumption for day: We are not closed
202 $count++; # We limit the number of loops below.
204 # get the day of the week for the day we are looking at
205 my $dow = $due_dt->day_of_week_0;
207 # Check hours of operation first.
208 if ($h_closed{$dow}) {
210 $new_grace_period += 86400;
211 $due_dt->add( seconds => 86400 );
213 # Check for closed dates for this period
214 my $timestamptz = $due_dt->strftime('%FT%T%z');
215 my $cl = $e->search_actor_org_unit_closed_date(
216 { close_start => { '<=' => $timestamptz },
217 close_end => { '>=' => $timestamptz },
218 org_unit => $circ_lib }
223 my $cl_dt = $parser->parse_datetime( cleanse_ISO8601( $_->close_end ) );
224 while ($due_dt <= $cl_dt) {
225 $due_dt->add( seconds => 86400 );
226 $new_grace_period += 86400;
230 $due_dt->add( seconds => 86400 );
233 } while ( $count <= 366 and ( $closed or $due_dt->epoch <= $due + $new_grace_period ) );
234 if ($new_grace_period > $grace_period) {
235 $grace_period = $new_grace_period;
236 $logger->info( "Grace period for circ extended to $grace_period [" . seconds_to_interval( $grace_period ) . "]" );
241 return $grace_period;
244 # check if a circulation transaction can be closed
245 # takes a CStoreEditor and a circ transaction.
246 # Returns 1 if the circ should be closed, 0 if not.
248 my ($class, $e, $circ) = @_;
251 my $reason = $circ->stop_fines;
253 # We definitely want to close if this circulation was
254 # checked in or renewed.
255 if ($circ->checkin_time) {
257 } elsif ($reason eq OILS_STOP_FINES_LOST) {
258 # Check the copy circ_lib to see if they close
259 # transactions when lost are paid.
260 my $copy = $e->retrieve_asset_copy($circ->target_copy);
262 $can_close = !$U->is_true(
263 $U->ou_ancestor_setting_value(
265 'circ.lost.xact_open_on_zero',
271 } elsif ($reason eq OILS_STOP_FINES_LONGOVERDUE) {
272 # Check the copy circ_lib to see if they close
273 # transactions when long-overdue are paid.
274 my $copy = $e->retrieve_asset_copy($circ->target_copy);
276 $can_close = !$U->is_true(
277 $U->ou_ancestor_setting_value(
279 'circ.longoverdue.xact_open_on_zero',
289 sub seconds_to_interval_hash {
290 my $interval = shift;
291 my $limit = shift || 's';
292 $limit =~ s/^(.)/$1/o;
296 my ($y,$ym,$M,$Mm,$w,$wm,$d,$dm,$h,$hm,$m,$mm,$s);
297 my ($year, $month, $week, $day, $hour, $minute, $second) =
298 ('years','months','weeks','days', 'hours', 'minutes', 'seconds');
300 if ($y = int($interval / (60 * 60 * 24 * 365))) {
302 $ym = $interval % (60 * 60 * 24 * 365);
306 return %output if ($limit eq 'y');
308 if ($M = int($ym / ((60 * 60 * 24 * 365)/12))) {
309 $output{$month} = $M;
310 $Mm = $ym % ((60 * 60 * 24 * 365)/12);
314 return %output if ($limit eq 'M');
316 if ($w = int($Mm / 604800)) {
322 return %output if ($limit eq 'w');
324 if ($d = int($wm / 86400)) {
330 return %output if ($limit eq 'd');
332 if ($h = int($dm / 3600)) {
338 return %output if ($limit eq 'h');
340 if ($m = int($hm / 60)) {
341 $output{$minute} = $m;
346 return %output if ($limit eq 'm');
349 $output{$second} = $s;
351 $output{$second} = 0 unless (keys %output);
357 my ($class, $args) = @_;
358 my $circs = $args->{circs};
359 return unless $circs and @$circs;
360 my $e = $args->{editor};
361 # if a client connection is passed in, this will be chatty like
362 # the old storage version
363 my $conn = $args->{conn};
367 $e = new_editor(xact => 1);
371 my %hoo = map { ( $_->id => $_ ) } @{ $e->retrieve_all_actor_org_unit_hours_of_operation };
373 my $penalty = OpenSRF::AppSession->create('open-ils.penalty');
374 my $handling_resvs = 0;
375 for my $c (@$circs) {
379 if (!$ctype) { # we received only an idlist, not objects
380 if ($handling_resvs) {
381 $c = $e->retrieve_booking_reservation($c);
382 } elsif (not defined $c) {
383 # an undef value is the indicator that we are moving
384 # from processing circulations to reservations.
388 $c = $e->retrieve_action_circulation($c);
393 $ctype =~ s/^.+::(\w+)$/$1/;
395 my $due_date_method = 'due_date';
396 my $target_copy_method = 'target_copy';
397 my $circ_lib_method = 'circ_lib';
398 my $recurring_fine_method = 'recurring_fine';
399 my $is_reservation = 0;
400 if ($ctype eq 'reservation') {
402 $due_date_method = 'end_time';
403 $target_copy_method = 'current_resource';
404 $circ_lib_method = 'pickup_lib';
405 $recurring_fine_method = 'fine_amount';
406 next unless ($c->fine_interval);
408 #TODO: reservation grace periods
409 my $grace_period = ($is_reservation ? 0 : interval_to_seconds($c->grace_period));
412 # if ($self->method_lookup('open-ils.storage.transaction.current')->run) {
413 # $logger->debug("Cleaning up after previous transaction\n");
414 # $self->method_lookup('open-ils.storage.transaction.rollback')->run;
416 # $self->method_lookup('open-ils.storage.transaction.begin')->run( $client );
418 sprintf("Processing %s %d...",
419 ($is_reservation ? "reservation" : "circ"), $c->id
424 my $due_dt = $parser->parse_datetime( cleanse_ISO8601( $c->$due_date_method ) );
426 my $due = $due_dt->epoch;
429 my $fine_interval = $c->fine_interval;
430 $fine_interval =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
431 $fine_interval = interval_to_seconds( $fine_interval );
433 if ( $fine_interval == 0 || int($c->$recurring_fine_method * 100) == 0 || int($c->max_fine * 100) == 0 ) {
434 $conn->respond( "Fine Generator skipping circ due to 0 fine interval, 0 fine rate, or 0 max fine.\n" ) if $conn;
435 $logger->info( "Fine Generator skipping circ " . $c->id . " due to 0 fine interval, 0 fine rate, or 0 max fine." );
439 if ( $is_reservation and $fine_interval >= interval_to_seconds('1d') ) {
441 if ($due_dt->strftime('%z') =~ /(-|\+)(\d{2}):?(\d{2})/) {
442 $tz_offset_s = $1 . interval_to_seconds( "${2}h ${3}m");
445 $due -= ($due % $fine_interval) + $tz_offset_s;
446 $now -= ($now % $fine_interval) + $tz_offset_s;
450 "ARG! Overdue $ctype ".$c->id.
451 " for item ".$c->$target_copy_method.
452 " (user ".$c->usr.").\n".
453 "\tItem was due on or before: ".localtime($due)."\n") if $conn;
455 my @fines = @{$e->search_money_billing(
458 billing_ts => { '>' => $c->$due_date_method } },
459 { order_by => 'billing_ts DESC'}
463 my $fine = $fines[$f_idx] if (@fines);
464 my $current_fine_total = 0;
465 $current_fine_total += int($_->amount * 100) for (grep { $_ and !$U->is_true($_->voided) } @fines);
469 $conn->respond( "Last billing time: ".$fine->billing_ts." (clensed format: ".cleanse_ISO8601( $fine->billing_ts ).")") if $conn;
470 $last_fine = $parser->parse_datetime( cleanse_ISO8601( $fine->billing_ts ) )->epoch;
472 $logger->info( "Potential first billing for circ ".$c->id );
475 $grace_period = extend_grace_period($class, $c->$circ_lib_method,$c->$due_date_method,$grace_period,undef,$hoo{$c->$circ_lib_method});
478 return if ($last_fine > $now);
479 # Generate fines for each past interval, including the one we are inside
480 my $pending_fine_count = ceil( ($now - $last_fine) / $fine_interval );
482 if ( $last_fine == $due # we have no fines yet
483 && $grace_period # and we have a grace period
484 && $now < $due + $grace_period # and some date math says were are within the grace period
486 $conn->respond( "Still inside grace period of: ". seconds_to_interval( $grace_period )."\n" ) if $conn;
487 $logger->info( "Circ ".$c->id." is still inside grace period of: $grace_period [". seconds_to_interval( $grace_period ).']' );
491 $conn->respond( "\t$pending_fine_count pending fine(s)\n" ) if $conn;
492 return unless ($pending_fine_count);
494 my $recurring_fine = int($c->$recurring_fine_method * 100);
495 my $max_fine = int($c->max_fine * 100);
497 my $skip_closed_check = $U->ou_ancestor_setting_value(
498 $c->$circ_lib_method, 'circ.fines.charge_when_closed');
499 $skip_closed_check = $U->is_true($skip_closed_check);
501 my $truncate_to_max_fine = $U->ou_ancestor_setting_value(
502 $c->$circ_lib_method, 'circ.fines.truncate_to_max_fine');
503 $truncate_to_max_fine = $U->is_true($truncate_to_max_fine);
505 my ($latest_billing_ts, $latest_amount) = ('',0);
506 for (my $bill = 1; $bill <= $pending_fine_count; $bill++) {
508 if ($current_fine_total >= $max_fine) {
509 if ($ctype eq 'circulation') {
510 $c->stop_fines('MAXFINES');
511 $c->stop_fines_time('now');
512 $e->update_action_circulation($c);
515 "\tMaximum fine level of ".$c->max_fine.
516 " reached for this $ctype.\n".
517 "\tNo more fines will be generated.\n" ) if $conn;
521 # XXX Use org time zone (or default to 'local') once we have the ou setting built for that
522 my $billing_ts = DateTime->from_epoch( epoch => $last_fine, time_zone => 'local' );
523 my $current_bill_count = $bill;
524 while ( $current_bill_count ) {
525 $billing_ts->add( seconds_to_interval_hash( $fine_interval ) );
526 $current_bill_count--;
529 my $timestamptz = $billing_ts->strftime('%FT%T%z');
530 if (!$skip_closed_check) {
531 my $dow = $billing_ts->day_of_week_0();
532 my $dow_open = "dow_${dow}_open";
533 my $dow_close = "dow_${dow}_close";
535 if (my $h = $hoo{$c->$circ_lib_method}) {
536 next if ( $h->$dow_open eq '00:00:00' and $h->$dow_close eq '00:00:00');
539 my @cl = @{$e->search_actor_org_unit_closed_date(
540 { close_start => { '<=' => $timestamptz },
541 close_end => { '>=' => $timestamptz },
542 org_unit => $c->$circ_lib_method }
547 # The billing amount for this billing normally ought to be the recurring fine amount.
548 # However, if the recurring fine amount would cause total fines to exceed the max fine amount,
549 # we may wish to reduce the amount for this billing (if circ.fines.truncate_to_max_fine is true).
550 my $this_billing_amount = $recurring_fine;
551 if ( $truncate_to_max_fine && ($current_fine_total + $this_billing_amount) > $max_fine ) {
552 $this_billing_amount = ($max_fine - $current_fine_total);
554 $current_fine_total += $this_billing_amount;
555 $latest_amount += $this_billing_amount;
556 $latest_billing_ts = $timestamptz;
558 my $bill = Fieldmapper::money::billing->new;
560 $bill->note("System Generated Overdue Fine");
561 $bill->billing_type("Overdue materials");
563 $bill->amount(sprintf('%0.2f', $this_billing_amount/100));
564 $bill->billing_ts($timestamptz);
565 $e->create_money_billing($bill);
569 $conn->respond( "\t\tAdding fines totaling $latest_amount for overdue up to $latest_billing_ts\n" )
570 if ($conn and $latest_billing_ts and $latest_amount);
572 # $self->method_lookup('open-ils.storage.transaction.commit')->run;
574 # Calculate penalties inline
575 OpenILS::Utils::Penalty->calculate_penalties(
576 $e, $c->usr, $c->$circ_lib_method);
582 $conn->respond( "Error processing overdue $ctype [".$c->id."]:\n\n$e\n" ) if $conn;
583 $logger->error("Error processing overdue $ctype [".$c->id."]:\n$e\n");
584 # $self->method_lookup('open-ils.storage.transaction.rollback')->run;
585 last if ($e =~ /IS NOT CONNECTED TO THE NETWORK/o);
589 $e->commit if ($commit);