6fc464178f81e8300bd1275ae541d90288a5941f
[koha.git] / Koha / Account.pm
1 package Koha::Account;
2
3 # Copyright 2016 ByWater Solutions
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20 use Modern::Perl;
21
22 use Carp;
23 use Data::Dumper;
24 use List::MoreUtils qw( uniq );
25 use Try::Tiny;
26
27 use C4::Circulation qw( ReturnLostItem );
28 use C4::Letters;
29 use C4::Log qw( logaction );
30 use C4::Stats qw( UpdateStats );
31
32 use Koha::Patrons;
33 use Koha::Account::Lines;
34 use Koha::Account::Offsets;
35 use Koha::Account::DebitTypes;
36 use Koha::DateUtils qw( dt_from_string );
37 use Koha::Exceptions;
38 use Koha::Exceptions::Account;
39
40 =head1 NAME
41
42 Koha::Accounts - Module for managing payments and fees for patrons
43
44 =cut
45
46 sub new {
47     my ( $class, $params ) = @_;
48
49     Carp::croak("No patron id passed in!") unless $params->{patron_id};
50
51     return bless( $params, $class );
52 }
53
54 =head2 pay
55
56 This method allows payments to be made against fees/fines
57
58 Koha::Account->new( { patron_id => $borrowernumber } )->pay(
59     {
60         amount      => $amount,
61         note        => $note,
62         description => $description,
63         library_id  => $branchcode,
64         lines       => $lines, # Arrayref of Koha::Account::Line objects to pay
65         credit_type => $type,  # credit_type_code code
66         offset_type => $offset_type,    # offset type code
67     }
68 );
69
70 =cut
71
72 sub pay {
73     my ( $self, $params ) = @_;
74
75     my $amount        = $params->{amount};
76     my $description   = $params->{description};
77     my $note          = $params->{note} || q{};
78     my $library_id    = $params->{library_id};
79     my $lines         = $params->{lines};
80     my $type          = $params->{type} || 'PAYMENT';
81     my $payment_type  = $params->{payment_type} || undef;
82     my $credit_type   = $params->{credit_type};
83     my $offset_type   = $params->{offset_type} || $type eq 'WRITEOFF' ? 'Writeoff' : 'Payment';
84     my $cash_register = $params->{cash_register};
85
86     my $userenv = C4::Context->userenv;
87
88     my $patron = Koha::Patrons->find( $self->{patron_id} );
89
90     my $manager_id = $userenv ? $userenv->{number} : 0;
91     my $interface = $params ? ( $params->{interface} || C4::Context->interface ) : C4::Context->interface;
92     Koha::Exceptions::Account::RegisterRequired->throw()
93       if ( C4::Context->preference("UseCashRegisters")
94         && defined($payment_type)
95         && ( $payment_type eq 'CASH' )
96         && !defined($cash_register) );
97
98     my @fines_paid; # List of account lines paid on with this payment
99
100     my $balance_remaining = $amount; # Set it now so we can adjust the amount if necessary
101     $balance_remaining ||= 0;
102
103     my @account_offsets;
104
105     # We were passed a specific line to pay
106     foreach my $fine ( @$lines ) {
107         my $amount_to_pay =
108             $fine->amountoutstanding > $balance_remaining
109           ? $balance_remaining
110           : $fine->amountoutstanding;
111
112         my $old_amountoutstanding = $fine->amountoutstanding;
113         my $new_amountoutstanding = $old_amountoutstanding - $amount_to_pay;
114         $fine->amountoutstanding($new_amountoutstanding)->store();
115         $balance_remaining = $balance_remaining - $amount_to_pay;
116
117         # Same logic exists in Koha::Account::Line::apply
118         if (   $new_amountoutstanding == 0
119             && $fine->itemnumber
120             && $fine->debit_type_code
121             && ( $fine->debit_type_code eq 'LOST' ) )
122         {
123             C4::Circulation::ReturnLostItem( $self->{patron_id}, $fine->itemnumber );
124         }
125
126         my $account_offset = Koha::Account::Offset->new(
127             {
128                 debit_id => $fine->id,
129                 type     => $offset_type,
130                 amount   => $amount_to_pay * -1,
131             }
132         );
133         push( @account_offsets, $account_offset );
134
135         if ( C4::Context->preference("FinesLog") ) {
136             logaction(
137                 "FINES", 'MODIFY',
138                 $self->{patron_id},
139                 Dumper(
140                     {
141                         action                => 'fee_payment',
142                         borrowernumber        => $fine->borrowernumber,
143                         old_amountoutstanding => $old_amountoutstanding,
144                         new_amountoutstanding => 0,
145                         amount_paid           => $old_amountoutstanding,
146                         accountlines_id       => $fine->id,
147                         manager_id            => $manager_id,
148                         note                  => $note,
149                     }
150                 ),
151                 $interface
152             );
153             push( @fines_paid, $fine->id );
154         }
155     }
156
157     # Were not passed a specific line to pay, or the payment was for more
158     # than the what was owed on the given line. In that case pay down other
159     # lines with remaining balance.
160     my @outstanding_fines;
161     @outstanding_fines = $self->lines->search(
162         {
163             amountoutstanding => { '>' => 0 },
164         }
165     ) if $balance_remaining > 0;
166
167     foreach my $fine (@outstanding_fines) {
168         my $amount_to_pay =
169             $fine->amountoutstanding > $balance_remaining
170           ? $balance_remaining
171           : $fine->amountoutstanding;
172
173         my $old_amountoutstanding = $fine->amountoutstanding;
174         $fine->amountoutstanding( $old_amountoutstanding - $amount_to_pay );
175         $fine->store();
176
177         if (   $fine->amountoutstanding == 0
178             && $fine->itemnumber
179             && $fine->debit_type_code
180             && ( $fine->debit_type_code eq 'LOST' ) )
181         {
182             C4::Circulation::ReturnLostItem( $self->{patron_id}, $fine->itemnumber );
183         }
184
185         my $account_offset = Koha::Account::Offset->new(
186             {
187                 debit_id => $fine->id,
188                 type     => $offset_type,
189                 amount   => $amount_to_pay * -1,
190             }
191         );
192         push( @account_offsets, $account_offset );
193
194         if ( C4::Context->preference("FinesLog") ) {
195             logaction(
196                 "FINES", 'MODIFY',
197                 $self->{patron_id},
198                 Dumper(
199                     {
200                         action                => "fee_$type",
201                         borrowernumber        => $fine->borrowernumber,
202                         old_amountoutstanding => $old_amountoutstanding,
203                         new_amountoutstanding => $fine->amountoutstanding,
204                         amount_paid           => $amount_to_pay,
205                         accountlines_id       => $fine->id,
206                         manager_id            => $manager_id,
207                         note                  => $note,
208                     }
209                 ),
210                 $interface
211             );
212             push( @fines_paid, $fine->id );
213         }
214
215         $balance_remaining = $balance_remaining - $amount_to_pay;
216         last unless $balance_remaining > 0;
217     }
218
219     $credit_type ||=
220       $type eq 'WRITEOFF'
221       ? 'WRITEOFF'
222       : 'PAYMENT';
223
224     $description ||= $type eq 'WRITEOFF' ? 'Writeoff' : q{};
225
226     my $payment = Koha::Account::Line->new(
227         {
228             borrowernumber    => $self->{patron_id},
229             date              => dt_from_string(),
230             amount            => 0 - $amount,
231             description       => $description,
232             credit_type_code  => $credit_type,
233             payment_type      => $payment_type,
234             amountoutstanding => 0 - $balance_remaining,
235             manager_id        => $manager_id,
236             interface         => $interface,
237             branchcode        => $library_id,
238             register_id       => $cash_register,
239             note              => $note,
240         }
241     )->store();
242
243     foreach my $o ( @account_offsets ) {
244         $o->credit_id( $payment->id() );
245         $o->store();
246     }
247
248     UpdateStats(
249         {
250             branch         => $library_id,
251             type           => lc($type),
252             amount         => $amount,
253             borrowernumber => $self->{patron_id},
254         }
255     );
256
257     if ( C4::Context->preference("FinesLog") ) {
258         logaction(
259             "FINES", 'CREATE',
260             $self->{patron_id},
261             Dumper(
262                 {
263                     action            => "create_$type",
264                     borrowernumber    => $self->{patron_id},
265                     amount            => 0 - $amount,
266                     amountoutstanding => 0 - $balance_remaining,
267                     credit_type_code  => $credit_type,
268                     accountlines_paid => \@fines_paid,
269                     manager_id        => $manager_id,
270                 }
271             ),
272             $interface
273         );
274     }
275
276     if ( C4::Context->preference('UseEmailReceipts') ) {
277         if (
278             my $letter = C4::Letters::GetPreparedLetter(
279                 module                 => 'circulation',
280                 letter_code            => uc("ACCOUNT_$type"),
281                 message_transport_type => 'email',
282                 lang    => $patron->lang,
283                 tables => {
284                     borrowers       => $self->{patron_id},
285                     branches        => $library_id,
286                 },
287                 substitute => {
288                     credit => $payment,
289                     offsets => \@account_offsets,
290                 },
291               )
292           )
293         {
294             C4::Letters::EnqueueLetter(
295                 {
296                     letter                 => $letter,
297                     borrowernumber         => $self->{patron_id},
298                     message_transport_type => 'email',
299                 }
300             ) or warn "can't enqueue letter $letter";
301         }
302     }
303
304     return $payment->id;
305 }
306
307 =head3 add_credit
308
309 This method allows adding credits to a patron's account
310
311 my $credit_line = Koha::Account->new({ patron_id => $patron_id })->add_credit(
312     {
313         amount       => $amount,
314         description  => $description,
315         note         => $note,
316         user_id      => $user_id,
317         interface    => $interface,
318         library_id   => $library_id,
319         payment_type => $payment_type,
320         type         => $credit_type,
321         item_id      => $item_id
322     }
323 );
324
325 $credit_type can be any of:
326   - 'CREDIT'
327   - 'PAYMENT'
328   - 'FORGIVEN'
329   - 'LOST_FOUND'
330   - 'WRITEOFF'
331
332 =cut
333
334 sub add_credit {
335
336     my ( $self, $params ) = @_;
337
338     # check for mandatory params
339     my @mandatory = ( 'interface', 'amount' );
340     for my $param (@mandatory) {
341         unless ( defined( $params->{$param} ) ) {
342             Koha::Exceptions::MissingParameter->throw(
343                 error => "The $param parameter is mandatory" );
344         }
345     }
346
347     # amount should always be passed as a positive value
348     my $amount = $params->{amount} * -1;
349     unless ( $amount < 0 ) {
350         Koha::Exceptions::Account::AmountNotPositive->throw(
351             error => 'Debit amount passed is not positive' );
352     }
353
354     my $description   = $params->{description} // q{};
355     my $note          = $params->{note} // q{};
356     my $user_id       = $params->{user_id};
357     my $interface     = $params->{interface};
358     my $library_id    = $params->{library_id};
359     my $cash_register = $params->{cash_register};
360     my $payment_type  = $params->{payment_type};
361     my $credit_type   = $params->{type} || 'PAYMENT';
362     my $item_id       = $params->{item_id};
363
364     Koha::Exceptions::Account::RegisterRequired->throw()
365       if ( C4::Context->preference("UseCashRegisters")
366         && defined($payment_type)
367         && ( $payment_type eq 'CASH' )
368         && !defined($cash_register) );
369
370     my $line;
371     my $schema = Koha::Database->new->schema;
372     try {
373         $schema->txn_do(
374             sub {
375
376                 # Insert the account line
377                 $line = Koha::Account::Line->new(
378                     {
379                         borrowernumber    => $self->{patron_id},
380                         date              => \'NOW()',
381                         amount            => $amount,
382                         description       => $description,
383                         credit_type_code  => $credit_type,
384                         amountoutstanding => $amount,
385                         payment_type      => $payment_type,
386                         note              => $note,
387                         manager_id        => $user_id,
388                         interface         => $interface,
389                         branchcode        => $library_id,
390                         register_id       => $cash_register,
391                         itemnumber        => $item_id,
392                     }
393                 )->store();
394
395                 # Record the account offset
396                 my $account_offset = Koha::Account::Offset->new(
397                     {
398                         credit_id => $line->id,
399                         type   => $Koha::Account::offset_type->{$credit_type},
400                         amount => $amount
401                     }
402                 )->store();
403
404                 UpdateStats(
405                     {
406                         branch         => $library_id,
407                         type           => lc($credit_type),
408                         amount         => $amount,
409                         borrowernumber => $self->{patron_id},
410                     }
411                 ) if grep { $credit_type eq $_ } ( 'PAYMENT', 'WRITEOFF' );
412
413                 if ( C4::Context->preference("FinesLog") ) {
414                     logaction(
415                         "FINES", 'CREATE',
416                         $self->{patron_id},
417                         Dumper(
418                             {
419                                 action            => "create_$credit_type",
420                                 borrowernumber    => $self->{patron_id},
421                                 amount            => $amount,
422                                 description       => $description,
423                                 amountoutstanding => $amount,
424                                 credit_type_code  => $credit_type,
425                                 note              => $note,
426                                 itemnumber        => $item_id,
427                                 manager_id        => $user_id,
428                                 branchcode        => $library_id,
429                             }
430                         ),
431                         $interface
432                     );
433                 }
434             }
435         );
436     }
437     catch {
438         if ( ref($_) eq 'Koha::Exceptions::Object::FKConstraint' ) {
439             if ( $_->broken_fk eq 'credit_type_code' ) {
440                 Koha::Exceptions::Account::UnrecognisedType->throw(
441                     error => 'Type of credit not recognised' );
442             }
443             else {
444                 $_->rethrow;
445             }
446         }
447     };
448
449     return $line;
450 }
451
452 =head3 add_debit
453
454 This method allows adding debits to a patron's account
455
456 my $debit_line = Koha::Account->new({ patron_id => $patron_id })->add_debit(
457     {
458         amount       => $amount,
459         description  => $description,
460         note         => $note,
461         user_id      => $user_id,
462         interface    => $interface,
463         library_id   => $library_id,
464         type         => $debit_type,
465         item_id      => $item_id,
466         issue_id     => $issue_id
467     }
468 );
469
470 $debit_type can be any of:
471   - ACCOUNT
472   - ACCOUNT_RENEW
473   - RESERVE_EXPIRED
474   - LOST
475   - sundry
476   - NEW_CARD
477   - OVERDUE
478   - PROCESSING
479   - RENT
480   - RENT_DAILY
481   - RENT_RENEW
482   - RENT_DAILY_RENEW
483   - RESERVE
484
485 =cut
486
487 sub add_debit {
488
489     my ( $self, $params ) = @_;
490
491     # check for mandatory params
492     my @mandatory = ( 'interface', 'type', 'amount' );
493     for my $param (@mandatory) {
494         unless ( defined( $params->{$param} ) ) {
495             Koha::Exceptions::MissingParameter->throw(
496                 error => "The $param parameter is mandatory" );
497         }
498     }
499
500     # amount should always be a positive value
501     my $amount = $params->{amount};
502     unless ( $amount > 0 ) {
503         Koha::Exceptions::Account::AmountNotPositive->throw(
504             error => 'Debit amount passed is not positive' );
505     }
506
507     my $description = $params->{description} // q{};
508     my $note        = $params->{note} // q{};
509     my $user_id     = $params->{user_id};
510     my $interface   = $params->{interface};
511     my $library_id  = $params->{library_id};
512     my $debit_type  = $params->{type};
513     my $item_id     = $params->{item_id};
514     my $issue_id    = $params->{issue_id};
515     my $offset_type = $Koha::Account::offset_type->{$debit_type} // 'Manual Debit';
516
517     my $line;
518     my $schema = Koha::Database->new->schema;
519     try {
520         $schema->txn_do(
521             sub {
522
523                 # Insert the account line
524                 $line = Koha::Account::Line->new(
525                     {
526                         borrowernumber    => $self->{patron_id},
527                         date              => \'NOW()',
528                         amount            => $amount,
529                         description       => $description,
530                         debit_type_code   => $debit_type,
531                         amountoutstanding => $amount,
532                         payment_type      => undef,
533                         note              => $note,
534                         manager_id        => $user_id,
535                         interface         => $interface,
536                         itemnumber        => $item_id,
537                         issue_id          => $issue_id,
538                         branchcode        => $library_id,
539                         (
540                             $debit_type eq 'OVERDUE'
541                             ? ( status => 'UNRETURNED' )
542                             : ()
543                         ),
544                     }
545                 )->store();
546
547                 # Record the account offset
548                 my $account_offset = Koha::Account::Offset->new(
549                     {
550                         debit_id => $line->id,
551                         type     => $offset_type,
552                         amount   => $amount
553                     }
554                 )->store();
555
556                 if ( C4::Context->preference("FinesLog") ) {
557                     logaction(
558                         "FINES", 'CREATE',
559                         $self->{patron_id},
560                         Dumper(
561                             {
562                                 action            => "create_$debit_type",
563                                 borrowernumber    => $self->{patron_id},
564                                 amount            => $amount,
565                                 description       => $description,
566                                 amountoutstanding => $amount,
567                                 debit_type_code   => $debit_type,
568                                 note              => $note,
569                                 itemnumber        => $item_id,
570                                 manager_id        => $user_id,
571                             }
572                         ),
573                         $interface
574                     );
575                 }
576             }
577         );
578     }
579     catch {
580         if ( ref($_) eq 'Koha::Exceptions::Object::FKConstraint' ) {
581             if ( $_->broken_fk eq 'debit_type_code' ) {
582                 Koha::Exceptions::Account::UnrecognisedType->throw(
583                     error => 'Type of debit not recognised' );
584             }
585             else {
586                 $_->rethrow;
587             }
588         }
589     };
590
591     return $line;
592 }
593
594 =head3 balance
595
596 my $balance = $self->balance
597
598 Return the balance (sum of amountoutstanding columns)
599
600 =cut
601
602 sub balance {
603     my ($self) = @_;
604     return $self->lines->total_outstanding;
605 }
606
607 =head3 outstanding_debits
608
609 my $lines = Koha::Account->new({ patron_id => $patron_id })->outstanding_debits;
610
611 It returns the debit lines with outstanding amounts for the patron.
612
613 In scalar context, it returns a Koha::Account::Lines iterator. In list context, it will
614 return a list of Koha::Account::Line objects.
615
616 =cut
617
618 sub outstanding_debits {
619     my ($self) = @_;
620
621     return $self->lines->search(
622         {
623             amount            => { '>' => 0 },
624             amountoutstanding => { '>' => 0 }
625         }
626     );
627 }
628
629 =head3 outstanding_credits
630
631 my $lines = Koha::Account->new({ patron_id => $patron_id })->outstanding_credits;
632
633 It returns the credit lines with outstanding amounts for the patron.
634
635 In scalar context, it returns a Koha::Account::Lines iterator. In list context, it will
636 return a list of Koha::Account::Line objects.
637
638 =cut
639
640 sub outstanding_credits {
641     my ($self) = @_;
642
643     return $self->lines->search(
644         {
645             amount            => { '<' => 0 },
646             amountoutstanding => { '<' => 0 }
647         }
648     );
649 }
650
651 =head3 non_issues_charges
652
653 my $non_issues_charges = $self->non_issues_charges
654
655 Calculates amount immediately owing by the patron - non-issue charges.
656
657 Charges exempt from non-issue are:
658 * Res (holds) if HoldsInNoissuesCharge syspref is set to false
659 * Rent (rental) if RentalsInNoissuesCharge syspref is set to false
660 * Manual invoices if ManInvInNoissuesCharge syspref is set to false
661
662 =cut
663
664 sub non_issues_charges {
665     my ($self) = @_;
666
667     #NOTE: With bug 23049 these preferences could be moved to being attached
668     #to individual debit types to give more flexability and specificity.
669     my @not_fines;
670     push @not_fines, 'RESERVE'
671       unless C4::Context->preference('HoldsInNoissuesCharge');
672     push @not_fines, ( 'RENT', 'RENT_DAILY', 'RENT_RENEW', 'RENT_DAILY_RENEW' )
673       unless C4::Context->preference('RentalsInNoissuesCharge');
674     unless ( C4::Context->preference('ManInvInNoissuesCharge') ) {
675         my @man_inv = Koha::Account::DebitTypes->search({ is_system => 0 })->get_column('code');
676         push @not_fines, @man_inv;
677     }
678
679     return $self->lines->search(
680         {
681             debit_type_code => { -not_in => \@not_fines }
682         },
683     )->total_outstanding;
684 }
685
686 =head3 lines
687
688 my $lines = $self->lines;
689
690 Return all credits and debits for the user, outstanding or otherwise
691
692 =cut
693
694 sub lines {
695     my ($self) = @_;
696
697     return Koha::Account::Lines->search(
698         {
699             borrowernumber => $self->{patron_id},
700         }
701     );
702 }
703
704 =head3 reconcile_balance
705
706 $account->reconcile_balance();
707
708 Find outstanding credits and use them to pay outstanding debits.
709 Currently, this implicitly uses the 'First In First Out' rule for
710 applying credits against debits.
711
712 =cut
713
714 sub reconcile_balance {
715     my ($self) = @_;
716
717     my $outstanding_debits  = $self->outstanding_debits;
718     my $outstanding_credits = $self->outstanding_credits;
719
720     while (     $outstanding_debits->total_outstanding > 0
721             and my $credit = $outstanding_credits->next )
722     {
723         # there's both outstanding debits and credits
724         $credit->apply( { debits => [ $outstanding_debits->as_list ] } );    # applying credit, no special offset
725
726         $outstanding_debits = $self->outstanding_debits;
727
728     }
729
730     return $self;
731 }
732
733 1;
734
735 =head2 Name mappings
736
737 =head3 $offset_type
738
739 =cut
740
741 our $offset_type = {
742     'CREDIT'           => 'Manual Credit',
743     'FORGIVEN'         => 'Writeoff',
744     'LOST_FOUND'       => 'Lost Item Found',
745     'PAYMENT'          => 'Payment',
746     'WRITEOFF'         => 'Writeoff',
747     'ACCOUNT'          => 'Account Fee',
748     'ACCOUNT_RENEW'    => 'Account Fee',
749     'RESERVE'          => 'Reserve Fee',
750     'PROCESSING'       => 'Processing Fee',
751     'LOST'             => 'Lost Item',
752     'RENT'             => 'Rental Fee',
753     'RENT_DAILY'       => 'Rental Fee',
754     'RENT_RENEW'       => 'Rental Fee',
755     'RENT_DAILY_RENEW' => 'Rental Fee',
756     'OVERDUE'          => 'OVERDUE',
757     'RESERVE_EXPIRED'  => 'Hold Expired'
758 };
759
760 =head1 AUTHORS
761
762 =encoding utf8
763
764 Kyle M Hall <kyle.m.hall@gmail.com>
765 Tomás Cohen Arazi <tomascohen@gmail.com>
766 Martin Renvoize <martin.renvoize@ptfs-europe.com>
767
768 =cut