Bug 24081: Add DISCOUNT to the allowed 'reduce' types
[koha.git] / Koha / Account / Line.pm
1 package Koha::Account::Line;
2
3 # This file is part of Koha.
4 #
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.
9 #
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.
14 #
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>.
17
18 use Modern::Perl;
19
20 use Carp;
21 use Data::Dumper;
22
23 use C4::Log qw(logaction);
24
25 use Koha::Account::CreditType;
26 use Koha::Account::DebitType;
27 use Koha::Account::Offsets;
28 use Koha::Database;
29 use Koha::Exceptions::Account;
30 use Koha::Items;
31
32 use base qw(Koha::Object);
33
34 =encoding utf8
35
36 =head1 NAME
37
38 Koha::Account::Line - Koha accountline Object class
39
40 =head1 API
41
42 =head2 Class methods
43
44 =cut
45
46 =head3 patron
47
48 Return the patron linked to this account line
49
50 =cut
51
52 sub patron {
53     my ( $self ) = @_;
54     my $rs = $self->_result->borrowernumber;
55     return unless $rs;
56     return Koha::Patron->_new_from_dbic( $rs );
57 }
58
59 =head3 item
60
61 Return the item linked to this account line if exists
62
63 =cut
64
65 sub item {
66     my ( $self ) = @_;
67     my $rs = $self->_result->itemnumber;
68     return unless $rs;
69     return Koha::Item->_new_from_dbic( $rs );
70 }
71
72 =head3 checkout
73
74 Return the checkout linked to this account line if exists
75
76 =cut
77
78 sub checkout {
79     my ( $self ) = @_;
80     return unless $self->issue_id ;
81
82     $self->{_checkout} ||= Koha::Checkouts->find( $self->issue_id );
83     $self->{_checkout} ||= Koha::Old::Checkouts->find( $self->issue_id );
84     return $self->{_checkout};
85 }
86
87 =head3 credit_type
88
89 Return the credit_type linked to this account line
90
91 =cut
92
93 sub credit_type {
94     my ( $self ) = @_;
95     my $rs = $self->_result->credit_type_code;
96     return unless $rs;
97     return Koha::Account::CreditType->_new_from_dbic( $rs );
98 }
99
100 =head3 debit_type
101
102 Return the debit_type linked to this account line
103
104 =cut
105
106 sub debit_type {
107     my ( $self ) = @_;
108     my $rs = $self->_result->debit_type_code;
109     return unless $rs;
110     return Koha::Account::DebitType->_new_from_dbic( $rs );
111 }
112
113 =head3 credit_offsets
114
115 Return the credit_offsets linked to this account line if some exist
116
117 =cut
118
119 sub credit_offsets {
120     my ( $self ) = @_;
121     my $rs = $self->_result->account_offsets_credits;
122     return unless $rs;
123     return Koha::Account::Offsets->_new_from_dbic($rs);
124 }
125
126 =head3 debit_offsets
127
128 Return the debit_offsets linked to this account line if some exist
129
130 =cut
131
132 sub debit_offsets {
133     my ( $self ) = @_;
134     my $rs = $self->_result->account_offsets_debits;
135     return unless $rs;
136     return Koha::Account::Offsets->_new_from_dbic($rs);
137 }
138
139
140 =head3 credits
141
142   my $credits = $accountline->credits;
143   my $credits = $accountline->credits( $cond, $attr );
144
145 Return the credits linked to this account line if some exist.
146 Search conditions and attributes may be passed if you wish to filter
147 the resultant resultant resultset.
148
149 =cut
150
151 sub credits {
152     my ( $self, $cond, $attr ) = @_;
153
154     unless ( $self->is_debit ) {
155         Koha::Exceptions::Account::IsNotCredit->throw(
156             error => 'Account line ' . $self->id . ' is not a debit'
157         );
158     }
159
160     my $rs =
161       $self->_result->search_related('account_offsets_debits')
162       ->search_related( 'credit', $cond, $attr );
163     return unless $rs;
164     return Koha::Account::Lines->_new_from_dbic($rs);
165 }
166
167 =head3 debits
168
169   my $debits = $accountline->debits;
170   my $debits = $accountline->debits( $cond, $attr );
171
172 Return the debits linked to this account line if some exist.
173 Search conditions and attributes may be passed if you wish to filter
174 the resultant resultant resultset.
175
176 =cut
177
178 sub debits {
179     my ( $self, $cond, $attr ) = @_;
180
181     unless ( $self->is_credit ) {
182         Koha::Exceptions::Account::IsNotCredit->throw(
183             error => 'Account line ' . $self->id . ' is not a credit'
184         );
185     }
186
187     my $rs =
188       $self->_result->search_related('account_offsets_credits')
189       ->search_related( 'debit', $cond, $attr );
190     return unless $rs;
191     return Koha::Account::Lines->_new_from_dbic($rs);
192 }
193
194 =head3 void
195
196   $payment_accountline->void();
197
198 Used to 'void' (or reverse) a payment/credit. It will roll back any offsets
199 created by the application of this credit upon any debits and mark the credit
200 as 'void' by updating it's status to "VOID".
201
202 =cut
203
204 sub void {
205     my ($self) = @_;
206
207     # Make sure it is a payment we are voiding
208     return unless $self->amount < 0;
209
210     my @account_offsets =
211       Koha::Account::Offsets->search(
212         { credit_id => $self->id, amount => { '<' => 0 }  } );
213
214     $self->_result->result_source->schema->txn_do(
215         sub {
216             foreach my $account_offset (@account_offsets) {
217                 my $fee_paid =
218                   Koha::Account::Lines->find( $account_offset->debit_id );
219
220                 next unless $fee_paid;
221
222                 my $amount_paid = $account_offset->amount * -1; # amount paid is stored as a negative amount
223                 my $new_amount = $fee_paid->amountoutstanding + $amount_paid;
224                 $fee_paid->amountoutstanding($new_amount);
225                 $fee_paid->store();
226
227                 Koha::Account::Offset->new(
228                     {
229                         credit_id => $self->id,
230                         debit_id  => $fee_paid->id,
231                         amount    => $amount_paid,
232                         type      => 'Void Payment',
233                     }
234                 )->store();
235             }
236
237             if ( C4::Context->preference("FinesLog") ) {
238                 logaction(
239                     "FINES", 'VOID',
240                     $self->borrowernumber,
241                     Dumper(
242                         {
243                             action         => 'void_payment',
244                             borrowernumber => $self->borrowernumber,
245                             amount            => $self->amount,
246                             amountoutstanding => $self->amountoutstanding,
247                             description       => $self->description,
248                             credit_type_code  => $self->credit_type_code,
249                             payment_type      => $self->payment_type,
250                             note              => $self->note,
251                             itemnumber        => $self->itemnumber,
252                             manager_id        => $self->manager_id,
253                             offsets =>
254                               [ map { $_->unblessed } @account_offsets ],
255                         }
256                     )
257                 );
258             }
259
260             $self->set(
261                 {
262                     status            => 'VOID',
263                     amountoutstanding => 0,
264                     amount            => 0,
265                 }
266             );
267             $self->store();
268         }
269     );
270
271 }
272
273 =head3 reduce
274
275   $charge_accountline->reduce({
276       reduction_type => $reduction_type
277   });
278
279 Used to 'reduce' a charge/debit by adding a credit to offset against the amount
280 outstanding.
281
282 May be used to apply a discount whilst retaining the original debit amounts or
283 to apply a full or partial refund for example when a lost item is found and
284 returned.
285
286 It will immediately be applied to the given debit unless the debit has already
287 been paid, in which case a 'zero' offset will be added to maintain a link to
288 the debit but the outstanding credit will be left so it may be applied to other
289 debts.
290
291 Reduction type may be one of:
292
293 * REFUND
294 * DISCOUNT
295
296 Returns the reduction accountline (which will be a credit)
297
298 =cut
299
300 sub reduce {
301     my ( $self, $params ) = @_;
302
303     # Make sure it is a charge we are reducing
304     unless ( $self->is_debit ) {
305         Koha::Exceptions::Account::IsNotDebit->throw(
306             error => 'Account line ' . $self->id . 'is not a debit' );
307     }
308     if ( $self->debit_type_code eq 'PAYOUT' ) {
309         Koha::Exceptions::Account::IsNotDebit->throw(
310             error => 'Account line ' . $self->id . 'is a payout' );
311     }
312
313     # Check for mandatory parameters
314     my @mandatory = ( 'interface', 'reduction_type', 'amount' );
315     for my $param (@mandatory) {
316         unless ( defined( $params->{$param} ) ) {
317             Koha::Exceptions::MissingParameter->throw(
318                 error => "The $param parameter is mandatory" );
319         }
320     }
321
322     # More mandatory parameters
323     if ( $params->{interface} eq 'intranet' ) {
324         my @optional = ( 'staff_id', 'branch' );
325         for my $param (@optional) {
326             unless ( defined( $params->{$param} ) ) {
327                 Koha::Exceptions::MissingParameter->throw( error =>
328 "The $param parameter is mandatory when interface is set to 'intranet'"
329                 );
330             }
331         }
332     }
333
334     # Make sure the reduction isn't more than the original
335     my $original = $self->amount;
336     Koha::Exceptions::Account::AmountNotPositive->throw(
337         error => 'Reduce amount passed is not positive' )
338       unless ( $params->{amount} > 0 );
339     Koha::Exceptions::ParameterTooHigh->throw( error =>
340 "Amount to reduce ($params->{amount}) is higher than original amount ($original)"
341     ) unless ( $original >= $params->{amount} );
342     my $reduced =
343       $self->credits( { credit_type_code => [ 'DISCOUNT', 'REFUND' ] } )->total;
344     Koha::Exceptions::ParameterTooHigh->throw( error =>
345 "Combined reduction ($params->{amount} + $reduced) is higher than original amount ("
346           . abs($original)
347           . ")" )
348       unless ( $original >= ( $params->{amount} + abs($reduced) ) );
349
350     my $status = { 'REFUND' => 'REFUNDED', 'DISCOUNT' => 'DISCOUNTED' };
351
352     my $reduction;
353     $self->_result->result_source->schema->txn_do(
354         sub {
355
356             # A 'reduction' is a 'credit'
357             $reduction = Koha::Account::Line->new(
358                 {
359                     date              => \'NOW()',
360                     amount            => 0 - $params->{amount},
361                     credit_type_code  => $params->{reduction_type},
362                     status            => 'ADDED',
363                     amountoutstanding => 0 - $params->{amount},
364                     manager_id        => $params->{staff_id},
365                     borrowernumber    => $self->borrowernumber,
366                     interface         => $params->{interface},
367                     branchcode        => $params->{branch},
368                 }
369             )->store();
370
371             my $reduction_offset = Koha::Account::Offset->new(
372                 {
373                     credit_id => $reduction->accountlines_id,
374                     type      => uc( $params->{reduction_type} ),
375                     amount    => $params->{amount}
376                 }
377             )->store();
378
379             # Link reduction to charge (and apply as required)
380             my $debit_outstanding = $self->amountoutstanding;
381             if ( $debit_outstanding >= $params->{amount} ) {
382
383                 $reduction->apply(
384                     {
385                         debits      => [$self],
386                         offset_type => uc( $params->{reduction_type} )
387                     }
388                 );
389                 $reduction->status('APPLIED')->store();
390             }
391             else {
392
393         # Zero amount offset used to link original 'debit' to reduction 'credit'
394                 my $link_reduction_offset = Koha::Account::Offset->new(
395                     {
396                         credit_id => $reduction->accountlines_id,
397                         debit_id  => $self->accountlines_id,
398                         type      => uc( $params->{reduction_type} ),
399                         amount    => 0
400                     }
401                 )->store();
402             }
403
404             # Update status of original debit
405             $self->status( $status->{ $params->{reduction_type} } )->store;
406         }
407     );
408
409     $reduction->discard_changes;
410     return $reduction;
411 }
412
413 =head3 apply
414
415     my $debits = $account->outstanding_debits;
416     my $outstanding_amount = $credit->apply( { debits => $debits, [ offset_type => $offset_type ] } );
417
418 Applies the credit to a given debits array reference.
419
420 =head4 arguments hashref
421
422 =over 4
423
424 =item debits - Koha::Account::Lines object set of debits
425
426 =item offset_type (optional) - a string indicating the offset type (valid values are those from
427 the 'account_offset_types' table)
428
429 =back
430
431 =cut
432
433 sub apply {
434     my ( $self, $params ) = @_;
435
436     my $debits      = $params->{debits};
437     my $offset_type = $params->{offset_type} // 'Credit Applied';
438
439     unless ( $self->is_credit ) {
440         Koha::Exceptions::Account::IsNotCredit->throw(
441             error => 'Account line ' . $self->id . ' is not a credit'
442         );
443     }
444
445     my $available_credit = $self->amountoutstanding * -1;
446
447     unless ( $available_credit > 0 ) {
448         Koha::Exceptions::Account::NoAvailableCredit->throw(
449             error => 'Outstanding credit is ' . $available_credit . ' and cannot be applied'
450         );
451     }
452
453     my $schema = Koha::Database->new->schema;
454
455     $schema->txn_do( sub {
456         for my $debit ( @{$debits} ) {
457
458             unless ( $debit->is_debit ) {
459                 Koha::Exceptions::Account::IsNotDebit->throw(
460                     error => 'Account line ' . $debit->id . 'is not a debit'
461                 );
462             }
463             my $amount_to_cancel;
464             my $owed = $debit->amountoutstanding;
465
466             if ( $available_credit >= $owed ) {
467                 $amount_to_cancel = $owed;
468             }
469             else {    # $available_credit < $debit->amountoutstanding
470                 $amount_to_cancel = $available_credit;
471             }
472
473             # record the account offset
474             Koha::Account::Offset->new(
475                 {   credit_id => $self->id,
476                     debit_id  => $debit->id,
477                     amount    => $amount_to_cancel * -1,
478                     type      => $offset_type,
479                 }
480             )->store();
481
482             $available_credit -= $amount_to_cancel;
483
484             $self->amountoutstanding( $available_credit * -1 )->store;
485             $debit->amountoutstanding( $owed - $amount_to_cancel )->store;
486
487             # Same logic exists in Koha::Account::pay
488             if (   $debit->amountoutstanding == 0
489                 && $debit->itemnumber
490                 && $debit->debit_type_code
491                 && $debit->debit_type_code eq 'LOST' )
492             {
493                 C4::Circulation::ReturnLostItem( $self->borrowernumber, $debit->itemnumber );
494             }
495
496         }
497     });
498
499     return $available_credit;
500 }
501
502 =head3 payout
503
504   $credit_accountline->payout(
505     {
506         payout_type => $payout_type,
507         register_id => $register_id,
508         staff_id    => $staff_id,
509         interface   => 'intranet',
510         amount      => $amount
511     }
512   );
513
514 Used to 'pay out' a credit to a user.
515
516 Payout type may be one of any existing payment types
517
518 Returns the payout debit line that is created via this transaction.
519
520 =cut
521
522 sub payout {
523     my ( $self, $params ) = @_;
524
525     # Make sure it is a credit we are paying out
526     unless ( $self->is_credit ) {
527         Koha::Exceptions::Account::IsNotCredit->throw(
528             error => 'Account line ' . $self->id . ' is not a credit' );
529     }
530
531     # Check for mandatory parameters
532     my @mandatory =
533       ( 'interface', 'staff_id', 'branch', 'payout_type', 'amount' );
534     for my $param (@mandatory) {
535         unless ( defined( $params->{$param} ) ) {
536             Koha::Exceptions::MissingParameter->throw(
537                 error => "The $param parameter is mandatory" );
538         }
539     }
540
541     # Make sure there is outstanding credit to pay out
542     my $outstanding = -1 * $self->amountoutstanding;
543     my $amount =
544       $params->{amount} ? $params->{amount} : $outstanding;
545     Koha::Exceptions::Account::AmountNotPositive->throw(
546         error => 'Payout amount passed is not positive' )
547       unless ( $amount > 0 );
548     Koha::Exceptions::ParameterTooHigh->throw(
549         error => "Amount to payout ($amount) is higher than amountoutstanding ($outstanding)" )
550       unless ($outstanding >= $amount );
551
552     # Make sure we record the cash register for cash transactions
553     Koha::Exceptions::Account::RegisterRequired->throw()
554       if ( C4::Context->preference("UseCashRegisters")
555         && defined( $params->{payout_type} )
556         && ( $params->{payout_type} eq 'CASH' )
557         && !defined( $params->{cash_register} ) );
558
559     my $payout;
560     $self->_result->result_source->schema->txn_do(
561         sub {
562
563             # A 'payout' is a 'debit'
564             $payout = Koha::Account::Line->new(
565                 {
566                     date              => \'NOW()',
567                     amount            => $amount,
568                     debit_type_code   => 'PAYOUT',
569                     payment_type      => $params->{payout_type},
570                     amountoutstanding => $amount,
571                     manager_id        => $params->{staff_id},
572                     borrowernumber    => $self->borrowernumber,
573                     interface         => $params->{interface},
574                     branchcode        => $params->{branch},
575                     register_id       => $params->{cash_register}
576                 }
577             )->store();
578
579             my $payout_offset = Koha::Account::Offset->new(
580                 {
581                     debit_id => $payout->accountlines_id,
582                     type     => 'PAYOUT',
583                     amount   => $amount
584                 }
585             )->store();
586
587             $self->apply( { debits => [$payout], offset_type => 'PAYOUT' } );
588             $self->status('PAID')->store;
589         }
590     );
591
592     $payout->discard_changes;
593     return $payout;
594 }
595
596 =head3 adjust
597
598 This method allows updating a debit or credit on a patron's account
599
600     $account_line->adjust(
601         {
602             amount    => $amount,
603             type      => $update_type,
604             interface => $interface
605         }
606     );
607
608 $update_type can be any of:
609   - overdue_update
610
611 Authors Note: The intention here is that this method is only used
612 to adjust accountlines where the final amount is not yet known/fixed.
613 Incrementing fines are the only existing case at the time of writing,
614 all other forms of 'adjustment' should be recorded as distinct credits
615 or debits and applied, via an offset, to the corresponding debit or credit.
616
617 =cut
618
619 sub adjust {
620     my ( $self, $params ) = @_;
621
622     my $amount       = $params->{amount};
623     my $update_type  = $params->{type};
624     my $interface    = $params->{interface};
625
626     unless ( exists($Koha::Account::Line::allowed_update->{$update_type}) ) {
627         Koha::Exceptions::Account::UnrecognisedType->throw(
628             error => 'Update type not recognised'
629         );
630     }
631
632     my $debit_type_code = $self->debit_type_code;
633     my $account_status  = $self->status;
634     unless (
635         (
636             exists(
637                 $Koha::Account::Line::allowed_update->{$update_type}
638                   ->{$debit_type_code}
639             )
640             && ( $Koha::Account::Line::allowed_update->{$update_type}
641                 ->{$debit_type_code} eq $account_status )
642         )
643       )
644     {
645         Koha::Exceptions::Account::UnrecognisedType->throw(
646             error => 'Update type not allowed on this debit_type' );
647     }
648
649     my $schema = Koha::Database->new->schema;
650
651     $schema->txn_do(
652         sub {
653
654             my $amount_before             = $self->amount;
655             my $amount_outstanding_before = $self->amountoutstanding;
656             my $difference                = $amount - $amount_before;
657             my $new_outstanding           = $amount_outstanding_before + $difference;
658
659             my $offset_type = $debit_type_code;
660             $offset_type .= ( $difference > 0 ) ? "_INCREASE" : "_DECREASE";
661
662             # Catch cases that require patron refunds
663             if ( $new_outstanding < 0 ) {
664                 my $account =
665                   Koha::Patrons->find( $self->borrowernumber )->account;
666                 my $credit = $account->add_credit(
667                     {
668                         amount      => $new_outstanding * -1,
669                         description => 'Overpayment refund',
670                         type        => 'CREDIT',
671                         interface   => $interface,
672                         ( $update_type eq 'overdue_update' ? ( item_id => $self->itemnumber ) : ()),
673                     }
674                 );
675                 $new_outstanding = 0;
676             }
677
678             # Update the account line
679             $self->set(
680                 {
681                     date              => \'NOW()',
682                     amount            => $amount,
683                     amountoutstanding => $new_outstanding,
684                 }
685             )->store();
686
687             # Record the account offset
688             my $account_offset = Koha::Account::Offset->new(
689                 {
690                     debit_id => $self->id,
691                     type     => $offset_type,
692                     amount   => $difference
693                 }
694             )->store();
695
696             if ( C4::Context->preference("FinesLog") ) {
697                 logaction(
698                     "FINES", 'UPDATE', #undef becomes UPDATE in UpdateFine
699                     $self->borrowernumber,
700                     Dumper(
701                         {   action            => $update_type,
702                             borrowernumber    => $self->borrowernumber,
703                             amount            => $amount,
704                             description       => undef,
705                             amountoutstanding => $new_outstanding,
706                             debit_type_code   => $self->debit_type_code,
707                             note              => undef,
708                             itemnumber        => $self->itemnumber,
709                             manager_id        => undef,
710                         }
711                     )
712                 ) if ( $update_type eq 'overdue_update' );
713             }
714         }
715     );
716
717     return $self;
718 }
719
720 =head3 is_credit
721
722     my $bool = $line->is_credit;
723
724 =cut
725
726 sub is_credit {
727     my ($self) = @_;
728
729     return ( $self->amount < 0 );
730 }
731
732 =head3 is_debit
733
734     my $bool = $line->is_debit;
735
736 =cut
737
738 sub is_debit {
739     my ($self) = @_;
740
741     return !$self->is_credit;
742 }
743
744 =head3 to_api_mapping
745
746 This method returns the mapping for representing a Koha::Account::Line object
747 on the API.
748
749 =cut
750
751 sub to_api_mapping {
752     return {
753         accountlines_id   => 'account_line_id',
754         credit_type_code  => 'credit_type',
755         debit_type_code   => 'debit_type',
756         amountoutstanding => 'amount_outstanding',
757         borrowernumber    => 'patron_id',
758         branchcode        => 'library_id',
759         issue_id          => 'checkout_id',
760         itemnumber        => 'item_id',
761         manager_id        => 'user_id',
762         note              => 'internal_note',
763     };
764 }
765
766 =head2 Internal methods
767
768 =cut
769
770 =head3 _type
771
772 =cut
773
774 sub _type {
775     return 'Accountline';
776 }
777
778 1;
779
780 =head2 Name mappings
781
782 =head3 $allowed_update
783
784 =cut
785
786 our $allowed_update = { 'overdue_update' => { 'OVERDUE' => 'UNRETURNED' } };
787
788 =head1 AUTHORS
789
790 Kyle M Hall <kyle@bywatersolutions.com >
791 Tomás Cohen Arazi <tomascohen@theke.io>
792 Martin Renvoize <martin.renvoize@ptfs-europe.com>
793
794 =cut