Bug 23051: (follow-up) Add renewal feedback and move code to subroutines and test
[koha.git] / Koha / Account / Line.pm
blobef53eddba4c23a5590f0e712be4fddc8bba20611
1 package Koha::Account::Line;
3 # This file is part of Koha.
5 # Koha is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
10 # Koha is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with Koha; if not, see <http://www.gnu.org/licenses>.
18 use Modern::Perl;
20 use Carp;
21 use Data::Dumper;
23 use C4::Log qw(logaction);
24 use C4::Overdues qw(GetFine);
26 use Koha::Account::CreditType;
27 use Koha::Account::DebitType;
28 use Koha::Account::Offsets;
29 use Koha::Database;
30 use Koha::Exceptions::Account;
31 use Koha::Items;
33 use base qw(Koha::Object);
35 =encoding utf8
37 =head1 NAME
39 Koha::Account::Line - Koha accountline Object class
41 =head1 API
43 =head2 Class methods
45 =cut
47 =head3 patron
49 Return the patron linked to this account line
51 =cut
53 sub patron {
54 my ( $self ) = @_;
55 my $rs = $self->_result->borrowernumber;
56 return unless $rs;
57 return Koha::Patron->_new_from_dbic( $rs );
60 =head3 item
62 Return the item linked to this account line if exists
64 =cut
66 sub item {
67 my ( $self ) = @_;
68 my $rs = $self->_result->itemnumber;
69 return unless $rs;
70 return Koha::Item->_new_from_dbic( $rs );
73 =head3 checkout
75 Return the checkout linked to this account line if exists
77 =cut
79 sub checkout {
80 my ( $self ) = @_;
81 return unless $self->issue_id ;
83 $self->{_checkout} ||= Koha::Checkouts->find( $self->issue_id );
84 $self->{_checkout} ||= Koha::Old::Checkouts->find( $self->issue_id );
85 return $self->{_checkout};
88 =head3 credit_type
90 Return the credit_type linked to this account line
92 =cut
94 sub credit_type {
95 my ( $self ) = @_;
96 my $rs = $self->_result->credit_type_code;
97 return unless $rs;
98 return Koha::Account::CreditType->_new_from_dbic( $rs );
101 =head3 debit_type
103 Return the debit_type linked to this account line
105 =cut
107 sub debit_type {
108 my ( $self ) = @_;
109 my $rs = $self->_result->debit_type_code;
110 return unless $rs;
111 return Koha::Account::DebitType->_new_from_dbic( $rs );
114 =head3 credit_offsets
116 Return the credit_offsets linked to this account line if some exist
118 =cut
120 sub credit_offsets {
121 my ( $self ) = @_;
122 my $rs = $self->_result->account_offsets_credits;
123 return unless $rs;
124 return Koha::Account::Offsets->_new_from_dbic($rs);
127 =head3 debit_offsets
129 Return the debit_offsets linked to this account line if some exist
131 =cut
133 sub debit_offsets {
134 my ( $self ) = @_;
135 my $rs = $self->_result->account_offsets_debits;
136 return unless $rs;
137 return Koha::Account::Offsets->_new_from_dbic($rs);
141 =head3 credits
143 my $credits = $accountline->credits;
144 my $credits = $accountline->credits( $cond, $attr );
146 Return the credits linked to this account line if some exist.
147 Search conditions and attributes may be passed if you wish to filter
148 the resultant resultant resultset.
150 =cut
152 sub credits {
153 my ( $self, $cond, $attr ) = @_;
155 unless ( $self->is_debit ) {
156 Koha::Exceptions::Account::IsNotCredit->throw(
157 error => 'Account line ' . $self->id . ' is not a debit'
161 my $rs =
162 $self->_result->search_related('account_offsets_debits')
163 ->search_related( 'credit', $cond, $attr );
164 return unless $rs;
165 return Koha::Account::Lines->_new_from_dbic($rs);
168 =head3 debits
170 my $debits = $accountline->debits;
171 my $debits = $accountline->debits( $cond, $attr );
173 Return the debits linked to this account line if some exist.
174 Search conditions and attributes may be passed if you wish to filter
175 the resultant resultant resultset.
177 =cut
179 sub debits {
180 my ( $self, $cond, $attr ) = @_;
182 unless ( $self->is_credit ) {
183 Koha::Exceptions::Account::IsNotCredit->throw(
184 error => 'Account line ' . $self->id . ' is not a credit'
188 my $rs =
189 $self->_result->search_related('account_offsets_credits')
190 ->search_related( 'debit', $cond, $attr );
191 return unless $rs;
192 return Koha::Account::Lines->_new_from_dbic($rs);
195 =head3 void
197 $payment_accountline->void();
199 Used to 'void' (or reverse) a payment/credit. It will roll back any offsets
200 created by the application of this credit upon any debits and mark the credit
201 as 'void' by updating it's status to "VOID".
203 =cut
205 sub void {
206 my ($self) = @_;
208 # Make sure it is a payment we are voiding
209 return unless $self->amount < 0;
211 my @account_offsets =
212 Koha::Account::Offsets->search(
213 { credit_id => $self->id, amount => { '<' => 0 } } );
215 $self->_result->result_source->schema->txn_do(
216 sub {
217 foreach my $account_offset (@account_offsets) {
218 my $fee_paid =
219 Koha::Account::Lines->find( $account_offset->debit_id );
221 next unless $fee_paid;
223 my $amount_paid = $account_offset->amount * -1; # amount paid is stored as a negative amount
224 my $new_amount = $fee_paid->amountoutstanding + $amount_paid;
225 $fee_paid->amountoutstanding($new_amount);
226 $fee_paid->store();
228 Koha::Account::Offset->new(
230 credit_id => $self->id,
231 debit_id => $fee_paid->id,
232 amount => $amount_paid,
233 type => 'Void Payment',
235 )->store();
238 if ( C4::Context->preference("FinesLog") ) {
239 logaction(
240 "FINES", 'VOID',
241 $self->borrowernumber,
242 Dumper(
244 action => 'void_payment',
245 borrowernumber => $self->borrowernumber,
246 amount => $self->amount,
247 amountoutstanding => $self->amountoutstanding,
248 description => $self->description,
249 credit_type_code => $self->credit_type_code,
250 payment_type => $self->payment_type,
251 note => $self->note,
252 itemnumber => $self->itemnumber,
253 manager_id => $self->manager_id,
254 offsets =>
255 [ map { $_->unblessed } @account_offsets ],
261 $self->set(
263 status => 'VOID',
264 amountoutstanding => 0,
265 amount => 0,
268 $self->store();
274 =head3 reduce
276 $charge_accountline->reduce({
277 reduction_type => $reduction_type
280 Used to 'reduce' a charge/debit by adding a credit to offset against the amount
281 outstanding.
283 May be used to apply a discount whilst retaining the original debit amounts or
284 to apply a full or partial refund for example when a lost item is found and
285 returned.
287 It will immediately be applied to the given debit unless the debit has already
288 been paid, in which case a 'zero' offset will be added to maintain a link to
289 the debit but the outstanding credit will be left so it may be applied to other
290 debts.
292 Reduction type may be one of:
294 * REFUND
295 * DISCOUNT
297 Returns the reduction accountline (which will be a credit)
299 =cut
301 sub reduce {
302 my ( $self, $params ) = @_;
304 # Make sure it is a charge we are reducing
305 unless ( $self->is_debit ) {
306 Koha::Exceptions::Account::IsNotDebit->throw(
307 error => 'Account line ' . $self->id . 'is not a debit' );
309 if ( $self->debit_type_code eq 'PAYOUT' ) {
310 Koha::Exceptions::Account::IsNotDebit->throw(
311 error => 'Account line ' . $self->id . 'is a payout' );
314 # Check for mandatory parameters
315 my @mandatory = ( 'interface', 'reduction_type', 'amount' );
316 for my $param (@mandatory) {
317 unless ( defined( $params->{$param} ) ) {
318 Koha::Exceptions::MissingParameter->throw(
319 error => "The $param parameter is mandatory" );
323 # More mandatory parameters
324 if ( $params->{interface} eq 'intranet' ) {
325 my @optional = ( 'staff_id', 'branch' );
326 for my $param (@optional) {
327 unless ( defined( $params->{$param} ) ) {
328 Koha::Exceptions::MissingParameter->throw( error =>
329 "The $param parameter is mandatory when interface is set to 'intranet'"
335 # Make sure the reduction isn't more than the original
336 my $original = $self->amount;
337 Koha::Exceptions::Account::AmountNotPositive->throw(
338 error => 'Reduce amount passed is not positive' )
339 unless ( $params->{amount} > 0 );
340 Koha::Exceptions::ParameterTooHigh->throw( error =>
341 "Amount to reduce ($params->{amount}) is higher than original amount ($original)"
342 ) unless ( $original >= $params->{amount} );
343 my $reduced =
344 $self->credits( { credit_type_code => [ 'DISCOUNT', 'REFUND' ] } )->total;
345 Koha::Exceptions::ParameterTooHigh->throw( error =>
346 "Combined reduction ($params->{amount} + $reduced) is higher than original amount ("
347 . abs($original)
348 . ")" )
349 unless ( $original >= ( $params->{amount} + abs($reduced) ) );
351 my $status = { 'REFUND' => 'REFUNDED', 'DISCOUNT' => 'DISCOUNTED' };
353 my $reduction;
354 $self->_result->result_source->schema->txn_do(
355 sub {
357 # A 'reduction' is a 'credit'
358 $reduction = Koha::Account::Line->new(
360 date => \'NOW()',
361 amount => 0 - $params->{amount},
362 credit_type_code => $params->{reduction_type},
363 status => 'ADDED',
364 amountoutstanding => 0 - $params->{amount},
365 manager_id => $params->{staff_id},
366 borrowernumber => $self->borrowernumber,
367 interface => $params->{interface},
368 branchcode => $params->{branch},
370 )->store();
372 my $reduction_offset = Koha::Account::Offset->new(
374 credit_id => $reduction->accountlines_id,
375 type => uc( $params->{reduction_type} ),
376 amount => $params->{amount}
378 )->store();
380 # Link reduction to charge (and apply as required)
381 my $debit_outstanding = $self->amountoutstanding;
382 if ( $debit_outstanding >= $params->{amount} ) {
384 $reduction->apply(
386 debits => [$self],
387 offset_type => uc( $params->{reduction_type} )
390 $reduction->status('APPLIED')->store();
392 else {
394 # Zero amount offset used to link original 'debit' to reduction 'credit'
395 my $link_reduction_offset = Koha::Account::Offset->new(
397 credit_id => $reduction->accountlines_id,
398 debit_id => $self->accountlines_id,
399 type => uc( $params->{reduction_type} ),
400 amount => 0
402 )->store();
405 # Update status of original debit
406 $self->status( $status->{ $params->{reduction_type} } )->store;
410 $reduction->discard_changes;
411 return $reduction;
414 =head3 apply
416 my $debits = $account->outstanding_debits;
417 my $outstanding_amount = $credit->apply( { debits => $debits, [ offset_type => $offset_type ] } );
419 Applies the credit to a given debits array reference.
421 =head4 arguments hashref
423 =over 4
425 =item debits - Koha::Account::Lines object set of debits
427 =item offset_type (optional) - a string indicating the offset type (valid values are those from
428 the 'account_offset_types' table)
430 =back
432 =cut
434 sub apply {
435 my ( $self, $params ) = @_;
437 my $debits = $params->{debits};
438 my $offset_type = $params->{offset_type} // 'Credit Applied';
440 unless ( $self->is_credit ) {
441 Koha::Exceptions::Account::IsNotCredit->throw(
442 error => 'Account line ' . $self->id . ' is not a credit'
446 my $available_credit = $self->amountoutstanding * -1;
448 unless ( $available_credit > 0 ) {
449 Koha::Exceptions::Account::NoAvailableCredit->throw(
450 error => 'Outstanding credit is ' . $available_credit . ' and cannot be applied'
454 my $schema = Koha::Database->new->schema;
456 $schema->txn_do( sub {
457 for my $debit ( @{$debits} ) {
459 unless ( $debit->is_debit ) {
460 Koha::Exceptions::Account::IsNotDebit->throw(
461 error => 'Account line ' . $debit->id . 'is not a debit'
464 my $amount_to_cancel;
465 my $owed = $debit->amountoutstanding;
467 if ( $available_credit >= $owed ) {
468 $amount_to_cancel = $owed;
470 else { # $available_credit < $debit->amountoutstanding
471 $amount_to_cancel = $available_credit;
474 # record the account offset
475 Koha::Account::Offset->new(
476 { credit_id => $self->id,
477 debit_id => $debit->id,
478 amount => $amount_to_cancel * -1,
479 type => $offset_type,
481 )->store();
483 $available_credit -= $amount_to_cancel;
485 $self->amountoutstanding( $available_credit * -1 )->store;
486 $debit->amountoutstanding( $owed - $amount_to_cancel )->store;
488 # Attempt to renew the item associated with this debit if
489 # appropriate
490 if ($debit->renewable) {
491 $debit->renew_item($params->{interface});
494 # Same logic exists in Koha::Account::pay
495 if ( $debit->amountoutstanding == 0
496 && $debit->itemnumber
497 && $debit->debit_type_code
498 && $debit->debit_type_code eq 'LOST' )
500 C4::Circulation::ReturnLostItem( $self->borrowernumber, $debit->itemnumber );
506 return $available_credit;
509 =head3 payout
511 $credit_accountline->payout(
513 payout_type => $payout_type,
514 register_id => $register_id,
515 staff_id => $staff_id,
516 interface => 'intranet',
517 amount => $amount
521 Used to 'pay out' a credit to a user.
523 Payout type may be one of any existing payment types
525 Returns the payout debit line that is created via this transaction.
527 =cut
529 sub payout {
530 my ( $self, $params ) = @_;
532 # Make sure it is a credit we are paying out
533 unless ( $self->is_credit ) {
534 Koha::Exceptions::Account::IsNotCredit->throw(
535 error => 'Account line ' . $self->id . ' is not a credit' );
538 # Check for mandatory parameters
539 my @mandatory =
540 ( 'interface', 'staff_id', 'branch', 'payout_type', 'amount' );
541 for my $param (@mandatory) {
542 unless ( defined( $params->{$param} ) ) {
543 Koha::Exceptions::MissingParameter->throw(
544 error => "The $param parameter is mandatory" );
548 # Make sure there is outstanding credit to pay out
549 my $outstanding = -1 * $self->amountoutstanding;
550 my $amount =
551 $params->{amount} ? $params->{amount} : $outstanding;
552 Koha::Exceptions::Account::AmountNotPositive->throw(
553 error => 'Payout amount passed is not positive' )
554 unless ( $amount > 0 );
555 Koha::Exceptions::ParameterTooHigh->throw(
556 error => "Amount to payout ($amount) is higher than amountoutstanding ($outstanding)" )
557 unless ($outstanding >= $amount );
559 # Make sure we record the cash register for cash transactions
560 Koha::Exceptions::Account::RegisterRequired->throw()
561 if ( C4::Context->preference("UseCashRegisters")
562 && defined( $params->{payout_type} )
563 && ( $params->{payout_type} eq 'CASH' )
564 && !defined( $params->{cash_register} ) );
566 my $payout;
567 $self->_result->result_source->schema->txn_do(
568 sub {
570 # A 'payout' is a 'debit'
571 $payout = Koha::Account::Line->new(
573 date => \'NOW()',
574 amount => $amount,
575 debit_type_code => 'PAYOUT',
576 payment_type => $params->{payout_type},
577 amountoutstanding => $amount,
578 manager_id => $params->{staff_id},
579 borrowernumber => $self->borrowernumber,
580 interface => $params->{interface},
581 branchcode => $params->{branch},
582 register_id => $params->{cash_register}
584 )->store();
586 my $payout_offset = Koha::Account::Offset->new(
588 debit_id => $payout->accountlines_id,
589 type => 'PAYOUT',
590 amount => $amount
592 )->store();
594 $self->apply( { debits => [$payout], offset_type => 'PAYOUT' } );
595 $self->status('PAID')->store;
599 $payout->discard_changes;
600 return $payout;
603 =head3 adjust
605 This method allows updating a debit or credit on a patron's account
607 $account_line->adjust(
609 amount => $amount,
610 type => $update_type,
611 interface => $interface
615 $update_type can be any of:
616 - overdue_update
618 Authors Note: The intention here is that this method is only used
619 to adjust accountlines where the final amount is not yet known/fixed.
620 Incrementing fines are the only existing case at the time of writing,
621 all other forms of 'adjustment' should be recorded as distinct credits
622 or debits and applied, via an offset, to the corresponding debit or credit.
624 =cut
626 sub adjust {
627 my ( $self, $params ) = @_;
629 my $amount = $params->{amount};
630 my $update_type = $params->{type};
631 my $interface = $params->{interface};
633 unless ( exists($Koha::Account::Line::allowed_update->{$update_type}) ) {
634 Koha::Exceptions::Account::UnrecognisedType->throw(
635 error => 'Update type not recognised'
639 my $debit_type_code = $self->debit_type_code;
640 my $account_status = $self->status;
641 unless (
643 exists(
644 $Koha::Account::Line::allowed_update->{$update_type}
645 ->{$debit_type_code}
647 && ( $Koha::Account::Line::allowed_update->{$update_type}
648 ->{$debit_type_code} eq $account_status )
652 Koha::Exceptions::Account::UnrecognisedType->throw(
653 error => 'Update type not allowed on this debit_type' );
656 my $schema = Koha::Database->new->schema;
658 $schema->txn_do(
659 sub {
661 my $amount_before = $self->amount;
662 my $amount_outstanding_before = $self->amountoutstanding;
663 my $difference = $amount - $amount_before;
664 my $new_outstanding = $amount_outstanding_before + $difference;
666 my $offset_type = $debit_type_code;
667 $offset_type .= ( $difference > 0 ) ? "_INCREASE" : "_DECREASE";
669 # Catch cases that require patron refunds
670 if ( $new_outstanding < 0 ) {
671 my $account =
672 Koha::Patrons->find( $self->borrowernumber )->account;
673 my $credit = $account->add_credit(
675 amount => $new_outstanding * -1,
676 description => 'Overpayment refund',
677 type => 'CREDIT',
678 interface => $interface,
679 ( $update_type eq 'overdue_update' ? ( item_id => $self->itemnumber ) : ()),
682 $new_outstanding = 0;
685 # Update the account line
686 $self->set(
688 date => \'NOW()',
689 amount => $amount,
690 amountoutstanding => $new_outstanding,
692 )->store();
694 # Record the account offset
695 my $account_offset = Koha::Account::Offset->new(
697 debit_id => $self->id,
698 type => $offset_type,
699 amount => $difference
701 )->store();
703 if ( C4::Context->preference("FinesLog") ) {
704 logaction(
705 "FINES", 'UPDATE', #undef becomes UPDATE in UpdateFine
706 $self->borrowernumber,
707 Dumper(
708 { action => $update_type,
709 borrowernumber => $self->borrowernumber,
710 amount => $amount,
711 description => undef,
712 amountoutstanding => $new_outstanding,
713 debit_type_code => $self->debit_type_code,
714 note => undef,
715 itemnumber => $self->itemnumber,
716 manager_id => undef,
719 ) if ( $update_type eq 'overdue_update' );
724 return $self;
727 =head3 is_credit
729 my $bool = $line->is_credit;
731 =cut
733 sub is_credit {
734 my ($self) = @_;
736 return ( $self->amount < 0 );
739 =head3 is_debit
741 my $bool = $line->is_debit;
743 =cut
745 sub is_debit {
746 my ($self) = @_;
748 return !$self->is_credit;
751 =head3 to_api_mapping
753 This method returns the mapping for representing a Koha::Account::Line object
754 on the API.
756 =cut
758 sub to_api_mapping {
759 return {
760 accountlines_id => 'account_line_id',
761 credit_type_code => 'credit_type',
762 debit_type_code => 'debit_type',
763 amountoutstanding => 'amount_outstanding',
764 borrowernumber => 'patron_id',
765 branchcode => 'library_id',
766 issue_id => 'checkout_id',
767 itemnumber => 'item_id',
768 manager_id => 'user_id',
769 note => 'internal_note',
772 =head3 renewable
774 my $bool = $line->renewable;
776 =cut
778 sub renewable {
779 my ($self) = @_;
781 return (
782 $self->amountoutstanding == 0 &&
783 $self->accounttype &&
784 $self->accounttype eq 'OVERDUE' &&
785 $self->status &&
786 $self->status eq 'UNRETURNED'
787 ) ? 1 : 0;
790 =head3 renew_item
792 my $renew_result = $line->renew_item;
794 Conditionally attempt to renew an item and return the outcome. This is
795 as a consequence of the fine on an item being fully paid off
797 =cut
799 sub renew_item {
800 my ($self, $params) = @_;
802 my $outcome = {};
804 # We want to reject the call to renew if any of these apply:
805 # - The RenewAccruingItemWhenPaid syspref is off
806 # - The line item doesn't have an item attached to it
807 # - The line item doesn't have a patron attached to it
809 # - The RenewAccruingItemInOpac syspref is off
810 # AND
811 # - There is an interface param passed and it's value is 'opac'
813 if (
814 !C4::Context->preference('RenewAccruingItemWhenPaid') ||
815 !$self->item ||
816 !$self->patron ||
818 !C4::Context->preference('RenewAccruingItemInOpac') &&
819 $params->{interface} &&
820 $params->{interface} eq 'opac'
823 return;
826 my $itemnumber = $self->item->itemnumber;
827 my $borrowernumber = $self->patron->borrowernumber;
828 # Only do something if this item has no fines left on it
829 my $fine = C4::Overdues::GetFine($itemnumber, $borrowernumber);
830 if ($fine && $fine > 0) {
831 return {
832 itemnumber => $itemnumber,
833 error => 'has_fine',
834 success => 0
837 my ( $can_renew, $error ) = C4::Circulation::CanBookBeRenewed(
838 $borrowernumber,
839 $itemnumber
841 if ( $can_renew ) {
842 my $due_date = C4::Circulation::AddRenewal(
843 $borrowernumber,
844 $itemnumber,
845 $self->{branchcode},
846 undef,
847 undef,
850 return {
851 itemnumber => $itemnumber,
852 due_date => $due_date,
853 success => 1
855 } else {
856 return {
857 itemnumber => $itemnumber,
858 error => $error,
859 success => 0
865 =head2 Internal methods
867 =cut
869 =head3 _type
871 =cut
873 sub _type {
874 return 'Accountline';
879 =head2 Name mappings
881 =head3 $allowed_update
883 =cut
885 our $allowed_update = { 'overdue_update' => { 'OVERDUE' => 'UNRETURNED' } };
887 =head1 AUTHORS
889 Kyle M Hall <kyle@bywatersolutions.com >
890 Tomás Cohen Arazi <tomascohen@theke.io>
891 Martin Renvoize <martin.renvoize@ptfs-europe.com>
893 =cut