]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/CircCommon.pm
LP#1198465 CircCommon fine generator repairs
[working/Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Circ / CircCommon.pm
1 package OpenILS::Application::Circ::CircCommon;
2 use strict; use warnings;
3 use DateTime;
4 use DateTime::Format::ISO8601;
5 use OpenILS::Application::AppUtils;
6 use OpenSRF::Utils qw/:datetime/;
7 use OpenILS::Event;
8 use OpenSRF::Utils::Logger qw(:logger);
9 use OpenILS::Utils::CStoreEditor q/:funcs/;
10 use OpenILS::Const qw/:const/;
11 use POSIX qw(ceil);
12
13 my $U = "OpenILS::Application::AppUtils";
14 my $parser = DateTime::Format::ISO8601->new;
15
16 # -----------------------------------------------------------------
17 # Do not publish methods here.  This code is shared across apps.
18 # -----------------------------------------------------------------
19
20
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
25 # overdue fines.
26 # -----------------------------------------------------------------
27 sub void_overdues {
28     my($class, $e, $circ, $backdate, $note) = @_;
29
30     my $bill_search = { 
31         xact => $circ->id, 
32         btype => 1 
33     };
34
35     if( $backdate ) {
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         # ------------------------------------------------------------------
42
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);
48
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");
54         } else {
55             $backdate = $U->epoch2ISO8601($date->epoch + $interval);
56             $logger->info("applying backdate $backdate in overdue voiding");
57             $$bill_search{billing_ts} = {'>=' => $backdate};
58         }
59     }
60
61     my $bills = $e->search_money_billing($bill_search);
62     
63     for my $bill (@$bills) {
64         next if $U->is_true($bill->voided);
65         $logger->info("voiding overdue bill ".$bill->id);
66         $bill->voided('t');
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;
72     }
73
74     return undef;
75 }
76
77 # ------------------------------------------------------------------
78 # remove charge from patron's account if lost item is returned
79 # ------------------------------------------------------------------
80 sub void_lost {
81     my ($class, $e, $circ, $btype) = @_;
82
83     my $bills = $e->search_money_billing(
84         {
85             xact => $circ->id,
86             btype => $btype,
87             voided => 'f'
88         }
89     );
90
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);
95             $bill->voided('t');
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");
100
101             return $e->event
102                 unless $e->update_money_billing($bill);
103         }
104     }
105     return undef;
106 }
107
108 sub reopen_xact {
109     my($class, $e, $xactid) = @_;
110
111     # -----------------------------------------------------------------
112     # make sure the transaction is not closed
113     my $xact = $e->retrieve_money_billable_transaction($xactid)
114         or return $e->die_event;
115
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;
123         } 
124     }
125
126     return undef;
127 }
128
129
130 sub create_bill {
131     my($class, $e, $amount, $btype, $type, $xactid, $note) = @_;
132
133     $logger->info("The system is charging $amount [$type] on xact $xactid");
134     $note ||= 'SYSTEM GENERATED';
135
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); 
143     $bill->note($note);
144     $e->create_money_billing($bill) or return $e->die_event;
145
146     return undef;
147 }
148
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;
155
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;
161
162             $logger->info( "Circ lib has an hours-of-operation entry and grace period extension is enabled." );
163
164             my $closed = 0;
165             my %h_closed = {};
166             for my $i (0 .. 6) {
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') {
170                     $closed++;
171                     $h_closed{$i} = 1;
172                 } else {
173                     $h_closed{$i} = 0;
174                 }
175             }
176
177             if($closed == 7) {
178                 $logger->info("Circ lib is closed all week according to hours-of-operation entry. Skipping grace period extension checks.");
179             } else {
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;
184
185                 my $grace_extend_all = $U->ou_ancestor_setting_value($circ_lib, 'circ.grace.extend.all');
186
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 );
192                 } else {
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 );
197                 }
198
199                 my $count = 0; # Infinite loop protection
200                 do {
201                     $closed = 0; # Starting assumption for day: We are not closed
202                     $count++; # We limit the number of loops below.
203
204                     # get the day of the week for the day we are looking at
205                     my $dow = $due_dt->day_of_week_0;
206
207                     # Check hours of operation first.
208                     if ($h_closed{$dow}) {
209                         $closed = 1;
210                         $new_grace_period += 86400;
211                         $due_dt->add( seconds => 86400 );
212                     } else {
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 }
219                         );
220                         if ($cl and @$cl) {
221                             $closed = 1;
222                             foreach (@$cl) {
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;
227                                 }
228                             }
229                         } else {
230                             $due_dt->add( seconds => 86400 );
231                         }
232                     }
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 ) . "]" );
237                 }
238             }
239         }
240     }
241     return $grace_period;
242 }
243
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.
247 sub can_close_circ {
248     my ($class, $e, $circ) = @_;
249     my $can_close = 0;
250
251     my $reason = $circ->stop_fines;
252
253     # We definitely want to close if this circulation was
254     # checked in or renewed.
255     if ($circ->checkin_time) {
256         $can_close = 1;
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);
261         if ($copy) {
262             $can_close = !$U->is_true(
263                 $U->ou_ancestor_setting_value(
264                     $copy->circ_lib,
265                     'circ.lost.xact_open_on_zero',
266                     $e
267                 )
268             );
269         }
270
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);
275         if ($copy) {
276             $can_close = !$U->is_true(
277                 $U->ou_ancestor_setting_value(
278                     $copy->circ_lib,
279                     'circ.longoverdue.xact_open_on_zero',
280                     $e
281                 )
282             );
283         }
284     }
285
286     return $can_close;
287 }
288
289 sub seconds_to_interval_hash {
290         my $interval = shift;
291         my $limit = shift || 's';
292         $limit =~ s/^(.)/$1/o;
293
294         my %output;
295
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');
299
300         if ($y = int($interval / (60 * 60 * 24 * 365))) {
301                 $output{$year} = $y;
302                 $ym = $interval % (60 * 60 * 24 * 365);
303         } else {
304                 $ym = $interval;
305         }
306         return %output if ($limit eq 'y');
307
308         if ($M = int($ym / ((60 * 60 * 24 * 365)/12))) {
309                 $output{$month} = $M;
310                 $Mm = $ym % ((60 * 60 * 24 * 365)/12);
311         } else {
312                 $Mm = $ym;
313         }
314         return %output if ($limit eq 'M');
315
316         if ($w = int($Mm / 604800)) {
317                 $output{$week} = $w;
318                 $wm = $Mm % 604800;
319         } else {
320                 $wm = $Mm;
321         }
322         return %output if ($limit eq 'w');
323
324         if ($d = int($wm / 86400)) {
325                 $output{$day} = $d;
326                 $dm = $wm % 86400;
327         } else {
328                 $dm = $wm;
329         }
330         return %output if ($limit eq 'd');
331
332         if ($h = int($dm / 3600)) {
333                 $output{$hour} = $h;
334                 $hm = $dm % 3600;
335         } else {
336                 $hm = $dm;
337         }
338         return %output if ($limit eq 'h');
339
340         if ($m = int($hm / 60)) {
341                 $output{$minute} = $m;
342                 $mm = $hm % 60;
343         } else {
344                 $mm = $hm;
345         }
346         return %output if ($limit eq 'm');
347
348         if ($s = int($mm)) {
349                 $output{$second} = $s;
350         } else {
351                 $output{$second} = 0 unless (keys %output);
352         }
353         return %output;
354 }
355
356 sub generate_fines {
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};
364
365     my $commit = 0;
366     unless ($e) {
367         # Transactions are opened/closed with each circ, reservation, etc.
368         # The first $e->xact_begin (below) will cause a connect.
369         $e = new_editor();
370         $commit = 1;
371     }
372
373     my %hoo = map { ( $_->id => $_ ) } @{ $e->retrieve_all_actor_org_unit_hours_of_operation };
374
375     my $handling_resvs = 0;
376     for my $c (@$circs) {
377
378         my $ctype = ref($c);
379
380         if (!$ctype) { # we received only an idlist, not objects
381             if ($handling_resvs) {
382                 $c = $e->retrieve_booking_reservation($c);
383             } elsif (not defined $c) {
384                 # an undef value is the indicator that we are moving
385                 # from processing circulations to reservations.
386                 $handling_resvs = 1;
387                 next;
388             } else {
389                 $c = $e->retrieve_action_circulation($c);
390             }
391             $ctype = ref($c);
392         }
393
394         $ctype =~ s/^.+::(\w+)$/$1/;
395     
396         my $due_date_method = 'due_date';
397         my $target_copy_method = 'target_copy';
398         my $circ_lib_method = 'circ_lib';
399         my $recurring_fine_method = 'recurring_fine';
400         my $is_reservation = 0;
401         if ($ctype eq 'reservation') {
402             $is_reservation = 1;
403             $due_date_method = 'end_time';
404             $target_copy_method = 'current_resource';
405             $circ_lib_method = 'pickup_lib';
406             $recurring_fine_method = 'fine_amount';
407             next unless ($c->fine_interval);
408         }
409         #TODO: reservation grace periods
410         my $grace_period = ($is_reservation ? 0 : interval_to_seconds($c->grace_period));
411
412         eval {
413
414             # Clean up after previous transaction.  
415             # This is a no-op if there is no open transaction.
416             $e->xact_rollback if $commit;
417
418             $logger->info(sprintf("Processing $ctype %d...", $c->id));
419
420             # each (ils) transaction is processed in its own (db) transaction
421             $e->xact_begin if $commit;
422
423             my $due_dt = $parser->parse_datetime( cleanse_ISO8601( $c->$due_date_method ) );
424     
425             my $due = $due_dt->epoch;
426             my $now = time;
427
428             my $fine_interval = $c->fine_interval;
429             $fine_interval =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
430             $fine_interval = interval_to_seconds( $fine_interval );
431     
432             if ( $fine_interval == 0 || int($c->$recurring_fine_method * 100) == 0 || int($c->max_fine * 100) == 0 ) {
433                 $conn->respond( "Fine Generator skipping circ due to 0 fine interval, 0 fine rate, or 0 max fine.\n" ) if $conn;
434                 $logger->info( "Fine Generator skipping circ " . $c->id . " due to 0 fine interval, 0 fine rate, or 0 max fine." );
435                 return;
436             }
437
438             if ( $is_reservation and $fine_interval >= interval_to_seconds('1d') ) {    
439                 my $tz_offset_s = 0;
440                 if ($due_dt->strftime('%z') =~ /(-|\+)(\d{2}):?(\d{2})/) {
441                     $tz_offset_s = $1 . interval_to_seconds( "${2}h ${3}m"); 
442                 }
443     
444                 $due -= ($due % $fine_interval) + $tz_offset_s;
445                 $now -= ($now % $fine_interval) + $tz_offset_s;
446             }
447     
448             $conn->respond(
449                 "ARG! Overdue $ctype ".$c->id.
450                 " for item ".$c->$target_copy_method.
451                 " (user ".$c->usr.").\n".
452                 "\tItem was due on or before: ".localtime($due)."\n") if $conn;
453     
454             my @fines = @{$e->search_money_billing([
455                 { xact => $c->id,
456                   btype => 1,
457                   billing_ts => { '>' => $c->$due_date_method } },
458                 { order_by => {mb => 'billing_ts DESC'}}
459             ])};
460
461             my $f_idx = 0;
462             my $fine = $fines[$f_idx] if (@fines);
463             my $current_fine_total = 0;
464             $current_fine_total += int($_->amount * 100) for (grep { $_ and !$U->is_true($_->voided) } @fines);
465     
466             my $last_fine;
467             if ($fine) {
468                 $conn->respond( "Last billing time: ".$fine->billing_ts." (clensed format: ".cleanse_ISO8601( $fine->billing_ts ).")") if $conn;
469                 $last_fine = $parser->parse_datetime( cleanse_ISO8601( $fine->billing_ts ) )->epoch;
470             } else {
471                 $logger->info( "Potential first billing for circ ".$c->id );
472                 $last_fine = $due;
473
474                 $grace_period = extend_grace_period($class, $c->$circ_lib_method,$c->$due_date_method,$grace_period,undef,$hoo{$c->$circ_lib_method});
475             }
476
477             return if ($last_fine > $now);
478             # Generate fines for each past interval, including the one we are inside
479             my $pending_fine_count = ceil( ($now - $last_fine) / $fine_interval );
480
481             if ( $last_fine == $due                         # we have no fines yet
482                  && $grace_period                           # and we have a grace period
483                  && $now < $due + $grace_period             # and some date math says were are within the grace period
484             ) {
485                 $conn->respond( "Still inside grace period of: ". seconds_to_interval( $grace_period )."\n" ) if $conn;
486                 $logger->info( "Circ ".$c->id." is still inside grace period of: $grace_period [". seconds_to_interval( $grace_period ).']' );
487                 return;
488             }
489
490             $conn->respond( "\t$pending_fine_count pending fine(s)\n" ) if $conn;
491             return unless ($pending_fine_count);
492
493             my $recurring_fine = int($c->$recurring_fine_method * 100);
494             my $max_fine = int($c->max_fine * 100);
495
496             my $skip_closed_check = $U->ou_ancestor_setting_value(
497                 $c->$circ_lib_method, 'circ.fines.charge_when_closed');
498             $skip_closed_check = $U->is_true($skip_closed_check);
499
500             my $truncate_to_max_fine = $U->ou_ancestor_setting_value(
501                 $c->$circ_lib_method, 'circ.fines.truncate_to_max_fine');
502             $truncate_to_max_fine = $U->is_true($truncate_to_max_fine);
503
504             my ($latest_billing_ts, $latest_amount) = ('',0);
505             for (my $bill = 1; $bill <= $pending_fine_count; $bill++) {
506     
507                 if ($current_fine_total >= $max_fine) {
508                     if ($ctype eq 'circulation') {
509                         $c->stop_fines('MAXFINES');
510                         $c->stop_fines_time('now');
511                         $e->update_action_circulation($c);
512                     }
513                     $conn->respond(
514                         "\tMaximum fine level of ".$c->max_fine.
515                         " reached for this $ctype.\n".
516                         "\tNo more fines will be generated.\n" ) if $conn;
517                     last;
518                 }
519                 
520                 # XXX Use org time zone (or default to 'local') once we have the ou setting built for that
521                 my $billing_ts = DateTime->from_epoch( epoch => $last_fine, time_zone => 'local' );
522                 my $current_bill_count = $bill;
523                 while ( $current_bill_count ) {
524                     $billing_ts->add( seconds_to_interval_hash( $fine_interval ) );
525                     $current_bill_count--;
526                 }
527
528                 my $timestamptz = $billing_ts->strftime('%FT%T%z');
529                 if (!$skip_closed_check) {
530                     my $dow = $billing_ts->day_of_week_0();
531                     my $dow_open = "dow_${dow}_open";
532                     my $dow_close = "dow_${dow}_close";
533
534                     if (my $h = $hoo{$c->$circ_lib_method}) {
535                         next if ( $h->$dow_open eq '00:00:00' and $h->$dow_close eq '00:00:00');
536                     }
537     
538                     my @cl = @{$e->search_actor_org_unit_closed_date(
539                             { close_start   => { '<=' => $timestamptz },
540                               close_end => { '>=' => $timestamptz },
541                               org_unit  => $c->$circ_lib_method }
542                     )};
543                     next if (@cl);
544                 }
545
546                 # The billing amount for this billing normally ought to be the recurring fine amount.
547                 # However, if the recurring fine amount would cause total fines to exceed the max fine amount,
548                 # we may wish to reduce the amount for this billing (if circ.fines.truncate_to_max_fine is true).
549                 my $this_billing_amount = $recurring_fine;
550                 if ( $truncate_to_max_fine && ($current_fine_total + $this_billing_amount) > $max_fine ) {
551                     $this_billing_amount = ($max_fine - $current_fine_total);
552                 }
553                 $current_fine_total += $this_billing_amount;
554                 $latest_amount += $this_billing_amount;
555                 $latest_billing_ts = $timestamptz;
556
557                 my $bill = Fieldmapper::money::billing->new;
558                 $bill->xact($c->id);
559                 $bill->note("System Generated Overdue Fine");
560                 $bill->billing_type("Overdue materials");
561                 $bill->btype(1);
562                 $bill->amount(sprintf('%0.2f', $this_billing_amount/100));
563                 $bill->billing_ts($timestamptz);
564                 $e->create_money_billing($bill);
565
566             }
567
568             $conn->respond( "\t\tAdding fines totaling $latest_amount for overdue up to $latest_billing_ts\n" )
569                 if ($conn and $latest_billing_ts and $latest_amount);
570
571
572             # Calculate penalties inline
573             OpenILS::Utils::Penalty->calculate_penalties(
574                 $e, $c->usr, $c->$circ_lib_method);
575
576             $e->xact_commit if $commit;
577
578         };
579
580         if ($@) {
581             my $e = $@;
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             last if ($e =~ /IS NOT CONNECTED TO THE NETWORK/o);
585         }
586     }
587
588     # roll back any (potentially) orphaned transaction and disconnect.
589     $e->rollback if $commit;
590
591     return undef;
592 }
593
594 1;