836cf36a7edcd5ffd6dfe99cda2a4ef0ca9c728f
[freeside.git] / FS / FS / cust_credit.pm
1 package FS::cust_credit;
2 use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::reason_Mixin
3              FS::Record );
4
5 use strict;
6 use vars qw( $conf $unsuspendauto $me $DEBUG
7              $otaker_upgrade_kludge $ignore_empty_reasonnum
8            );
9 use List::Util qw( min );
10 use Date::Format;
11 use FS::UID qw( dbh );
12 use FS::Misc qw(send_email);
13 use FS::Record qw( qsearch qsearchs dbdef );
14 use FS::CurrentUser;
15 use FS::cust_pkg;
16 use FS::cust_refund;
17 use FS::cust_credit_bill;
18 use FS::part_pkg;
19 use FS::reason_type;
20 use FS::reason;
21 use FS::cust_event;
22 use FS::agent;
23 use FS::sales;
24 use FS::cust_credit_void;
25 use FS::cust_bill_pkg;
26 use FS::upgrade_journal;
27
28 $me = '[ FS::cust_credit ]';
29 $DEBUG = 0;
30
31 $otaker_upgrade_kludge = 0;
32 $ignore_empty_reasonnum = 0;
33
34 #ask FS::UID to run this stuff for us later
35 $FS::UID::callback{'FS::cust_credit'} = sub { 
36
37   $conf = new FS::Conf;
38   $unsuspendauto = $conf->exists('unsuspendauto');
39
40 };
41
42 our %reasontype_map = ( 'referral_credit_type' => 'Referral Credit',
43                         'cancel_credit_type'   => 'Cancellation Credit',
44                       );
45
46 =head1 NAME
47
48 FS::cust_credit - Object methods for cust_credit records
49
50 =head1 SYNOPSIS
51
52   use FS::cust_credit;
53
54   $record = new FS::cust_credit \%hash;
55   $record = new FS::cust_credit { 'column' => 'value' };
56
57   $error = $record->insert;
58
59   $error = $new_record->replace($old_record);
60
61   $error = $record->delete;
62
63   $error = $record->check;
64
65 =head1 DESCRIPTION
66
67 An FS::cust_credit object represents a credit; the equivalent of a negative
68 B<cust_bill> record (see L<FS::cust_bill>).  FS::cust_credit inherits from
69 FS::Record.  The following fields are currently supported:
70
71 =over 4
72
73 =item crednum
74
75 Primary key (assigned automatically for new credits)
76
77 =item custnum
78
79 Customer (see L<FS::cust_main>)
80
81 =item amount
82
83 Amount of the credit
84
85 =item _date
86
87 Specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
88 L<Time::Local> and L<Date::Parse> for conversion functions.
89
90 =item usernum
91
92 Order taker (see L<FS::access_user>)
93
94 =item reason
95
96 Text ( deprecated )
97
98 =item reasonnum
99
100 Reason (see L<FS::reason>)
101
102 =item addlinfo
103
104 Text
105
106 =item closed
107
108 Books closed flag, empty or `Y'
109
110 =item pkgnum
111
112 Desired pkgnum when using experimental package balances.
113
114 =back
115
116 =head1 METHODS
117
118 =over 4
119
120 =item new HASHREF
121
122 Creates a new credit.  To add the credit to the database, see L<"insert">.
123
124 =cut
125
126 sub table { 'cust_credit'; }
127 sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum } 
128 sub cust_unlinked_msg {
129   my $self = shift;
130   "WARNING: can't find cust_main.custnum ". $self->custnum.
131   ' (cust_credit.crednum '. $self->crednum. ')';
132 }
133
134 =item insert [ OPTION => VALUE ... ]
135
136 Adds this credit to the database ("Posts" the credit).  If there is an error,
137 returns the error, otherwise returns false.
138
139 Ooptions are passed as a list of keys and values.  Available options:
140
141 =over 4
142
143 =item reason_type
144
145 L<FS::reason_type|Reason> type for newly-inserted reason
146
147 =item cust_credit_source_bill_pkg
148
149 An arrayref of
150 L<FS::cust_credit_source_bill_pkg|FS::cust_credit_source_bilL_pkg> objects.
151 They will have their crednum set and will be inserted along with this credit.
152
153 =back
154
155 =cut
156
157 sub insert {
158   my ($self, %options) = @_;
159
160   local $SIG{HUP} = 'IGNORE';
161   local $SIG{INT} = 'IGNORE';
162   local $SIG{QUIT} = 'IGNORE';
163   local $SIG{TERM} = 'IGNORE';
164   local $SIG{TSTP} = 'IGNORE';
165   local $SIG{PIPE} = 'IGNORE';
166
167   my $oldAutoCommit = $FS::UID::AutoCommit;
168   local $FS::UID::AutoCommit = 0;
169   my $dbh = dbh;
170
171   my $cust_main = qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
172   my $old_balance = $cust_main->balance;
173
174   if (!$self->reasonnum) {
175     my $reason_text = $self->get('reason')
176       or return "reason text or existing reason required";
177     my $reason_type = $options{'reason_type'}
178       or return "reason type required";
179
180     local $@;
181     my $reason = FS::reason->new_or_existing(
182       reason => $reason_text,
183       type   => $reason_type,
184       class  => 'R',
185     );
186     if ($@) {
187       $dbh->rollback if $oldAutoCommit;
188       return "failed to set credit reason: $@";
189     }
190     $self->set('reasonnum', $reason->reasonnum);
191   }
192
193   $self->setfield('reason', '');
194
195   my $error = $self->SUPER::insert;
196   if ( $error ) {
197     $dbh->rollback if $oldAutoCommit;
198     return "error inserting $self: $error";
199   }
200
201   if ( $options{'cust_credit_source_bill_pkg'} ) {
202     foreach my $ccsbr ( @{ $options{'cust_credit_source_bill_pkg'} } ) {
203       $ccsbr->crednum( $self->crednum );
204       $error = $ccsbr->insert;
205       if ( $error ) {
206         $dbh->rollback if $oldAutoCommit;
207         return "error inserting $ccsbr: $error";
208       }
209     }
210   }
211
212   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
213
214   #false laziness w/ cust_pay::insert
215   if ( $unsuspendauto && $old_balance && $cust_main->balance <= 0 ) {
216     my @errors = $cust_main->unsuspend;
217     #return 
218     # side-fx with nested transactions?  upstack rolls back?
219     warn "WARNING:Errors unsuspending customer ". $cust_main->custnum. ": ".
220          join(' / ', @errors)
221       if @errors;
222   }
223   #eslaf
224
225   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
226
227   '';
228
229 }
230
231 =item delete
232
233 Unless the closed flag is set, deletes this credit and all associated
234 applications (see L<FS::cust_credit_bill>).  In most cases, you want to use
235 the void method instead to leave a record of the deleted credit.
236
237 =cut
238
239 # very similar to FS::cust_pay::delete
240 sub delete {
241   my $self = shift;
242   my %opt = @_;
243
244   return "Can't delete closed credit" if $self->closed =~ /^Y/i;
245
246   local $SIG{HUP} = 'IGNORE';
247   local $SIG{INT} = 'IGNORE';
248   local $SIG{QUIT} = 'IGNORE';
249   local $SIG{TERM} = 'IGNORE';
250   local $SIG{TSTP} = 'IGNORE';
251   local $SIG{PIPE} = 'IGNORE';
252
253   my $oldAutoCommit = $FS::UID::AutoCommit;
254   local $FS::UID::AutoCommit = 0;
255   my $dbh = dbh;
256
257   foreach my $cust_credit_bill ( $self->cust_credit_bill ) {
258     my $error = $cust_credit_bill->delete;
259     if ( $error ) {
260       $dbh->rollback if $oldAutoCommit;
261       return $error;
262     }
263   }
264
265   foreach my $cust_credit_refund ( $self->cust_credit_refund ) {
266     my $error = $cust_credit_refund->delete;
267     if ( $error ) {
268       $dbh->rollback if $oldAutoCommit;
269       return $error;
270     }
271   }
272
273   my $error = $self->SUPER::delete(@_);
274   if ( $error ) {
275     $dbh->rollback if $oldAutoCommit;
276     return $error;
277   }
278
279   if ( !$opt{void} and $conf->config('deletecredits') ne '' ) {
280
281     my $cust_main = $self->cust_main;
282
283     my $error = send_email(
284       'from'    => $conf->invoice_from_full($self->cust_main->agentnum),
285                                  #invoice_from??? well as good as any
286       'to'      => $conf->config('deletecredits'),
287       'subject' => 'FREESIDE NOTIFICATION: Credit deleted',
288       'body'    => [
289         "This is an automatic message from your Freeside installation\n",
290         "informing you that the following credit has been deleted:\n",
291         "\n",
292         'crednum: '. $self->crednum. "\n",
293         'custnum: '. $self->custnum.
294           " (". $cust_main->last. ", ". $cust_main->first. ")\n",
295         'amount: $'. sprintf("%.2f", $self->amount). "\n",
296         'date: '. time2str("%a %b %e %T %Y", $self->_date). "\n",
297         'reason: '. $self->reason. "\n",
298       ],
299     );
300
301     if ( $error ) {
302       $dbh->rollback if $oldAutoCommit;
303       return "can't send credit deletion notification: $error";
304     }
305
306   }
307
308   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
309
310   '';
311
312 }
313
314 =item replace [ OLD_RECORD ]
315
316 You can, but probably shouldn't modify credits... 
317
318 Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
319 supplied, replaces this record.  If there is an error, returns the error,
320 otherwise returns false.
321
322 =cut
323
324 sub replace {
325   my $self = shift;
326   return "Can't modify closed credit" if $self->closed =~ /^Y/i;
327   $self->SUPER::replace(@_);
328 }
329
330 =item check
331
332 Checks all fields to make sure this is a valid credit.  If there is an error,
333 returns the error, otherwise returns false.  Called by the insert and replace
334 methods.
335
336 =cut
337
338 sub check {
339   my $self = shift;
340
341   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
342
343   my $error =
344     $self->ut_numbern('crednum')
345     || $self->ut_number('custnum')
346     || $self->ut_numbern('_date')
347     || $self->ut_money('amount')
348     || $self->ut_alphan('otaker')
349     || $self->ut_textn('reason')
350     || $self->ut_textn('addlinfo')
351     || $self->ut_enum('closed', [ '', 'Y' ])
352     || $self->ut_foreign_keyn('pkgnum', 'cust_pkg', 'pkgnum')
353     || $self->ut_foreign_keyn('eventnum', 'cust_event', 'eventnum')
354     || $self->ut_foreign_keyn('commission_agentnum',  'agent', 'agentnum')
355     || $self->ut_foreign_keyn('commission_salesnum',  'sales', 'salesnum')
356     || $self->ut_foreign_keyn('commission_pkgnum', 'cust_pkg', 'pkgnum')
357   ;
358   return $error if $error;
359
360   my $method = $ignore_empty_reasonnum ? 'ut_foreign_keyn' : 'ut_foreign_key';
361   $error = $self->$method('reasonnum', 'reason', 'reasonnum');
362   return $error if $error;
363
364   return "amount must be > 0 " if $self->amount <= 0;
365
366   return "amount must be greater or equal to amount applied"
367     if $self->unapplied < 0 && ! $otaker_upgrade_kludge;
368
369   return "Unknown customer"
370     unless qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
371
372   $self->_date(time) unless $self->_date;
373
374   $self->SUPER::check;
375 }
376
377 =item void [ REASON ]
378
379 Voids this credit: deletes the credit and all associated applications and 
380 adds a record of the voided credit to the cust_credit_void table.
381
382 =cut
383
384 sub void {
385   my $self = shift;
386   my $reason = shift;
387
388   unless (ref($reason) || !$reason) {
389     $reason = FS::reason->new_or_existing(
390       'class'  => 'X',
391       'type'   => 'Void credit',
392       'reason' => $reason
393     );
394   }
395
396   local $SIG{HUP} = 'IGNORE';
397   local $SIG{INT} = 'IGNORE';
398   local $SIG{QUIT} = 'IGNORE';
399   local $SIG{TERM} = 'IGNORE';
400   local $SIG{TSTP} = 'IGNORE';
401   local $SIG{PIPE} = 'IGNORE';
402
403   my $oldAutoCommit = $FS::UID::AutoCommit;
404   local $FS::UID::AutoCommit = 0;
405   my $dbh = dbh;
406
407   my $cust_credit_void = new FS::cust_credit_void ( {
408       map { $_ => $self->get($_) } $self->fields
409     } );
410   $cust_credit_void->set('void_reasonnum', $reason->reasonnum) if $reason;
411   my $error = $cust_credit_void->insert;
412   if ( $error ) {
413     $dbh->rollback if $oldAutoCommit;
414     return $error;
415   }
416
417   $error = $self->delete(void => 1); # suppress deletecredits warning
418   if ( $error ) {
419     $dbh->rollback if $oldAutoCommit;
420     return $error;
421   }
422
423   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
424
425   '';
426
427 }
428
429 =item cust_credit_refund
430
431 Returns all refund applications (see L<FS::cust_credit_refund>) for this credit.
432
433 =cut
434
435 sub cust_credit_refund {
436   my $self = shift;
437   map { $_ } #return $self->num_cust_credit_refund unless wantarray;
438   sort { $a->_date <=> $b->_date }
439     qsearch( 'cust_credit_refund', { 'crednum' => $self->crednum } )
440   ;
441 }
442
443 =item cust_credit_bill
444
445 Returns all application to invoices (see L<FS::cust_credit_bill>) for this
446 credit.
447
448 =cut
449
450 sub cust_credit_bill {
451   my $self = shift;
452   map { $_ } #return $self->num_cust_credit_bill unless wantarray;
453   sort { $a->_date <=> $b->_date }
454     qsearch( 'cust_credit_bill', { 'crednum' => $self->crednum } )
455   ;
456 }
457
458 =item unapplied
459
460 Returns the amount of this credit that is still unapplied/outstanding; 
461 amount minus all refund applications (see L<FS::cust_credit_refund>) and
462 applications to invoices (see L<FS::cust_credit_bill>).
463
464 =cut
465
466 sub unapplied {
467   my $self = shift;
468   my $amount = $self->amount;
469   $amount -= $_->amount foreach ( $self->cust_credit_refund );
470   $amount -= $_->amount foreach ( $self->cust_credit_bill );
471   sprintf( "%.2f", $amount );
472 }
473
474 =item credited
475
476 Deprecated name for the unapplied method.
477
478 =cut
479
480 sub credited {
481   my $self = shift;
482   #carp "cust_credit->credited deprecated; use ->unapplied";
483   $self->unapplied(@_);
484 }
485
486 =item cust_main
487
488 Returns the customer (see L<FS::cust_main>) for this credit.
489
490 =cut
491
492 # _upgrade_data
493 #
494 # Used by FS::Upgrade to migrate to a new database.
495
496 sub _upgrade_data {  # class method
497   my ($class, %opts) = @_;
498
499   warn "$me upgrading $class\n" if $DEBUG;
500
501   $class->_upgrade_reasonnum(%opts);
502
503   if (defined dbdef->table($class->table)->column('reason')) {
504
505     warn "$me Ensuring existance of auto reasons\n" if $DEBUG;
506
507     foreach ( keys %reasontype_map ) {
508       unless ($conf->config($_)) {       # hmmmm
509 #       warn "$me Found $_ reason type lacking\n" if $DEBUG;
510 #       my $hashref = { 'class' => 'R', 'type' => $reasontype_map{$_} };
511         my $hashref = { 'class' => 'R', 'type' => 'Legacy' };
512         my $reason_type = qsearchs( 'reason_type', $hashref );
513         unless ($reason_type) {
514           $reason_type  = new FS::reason_type( $hashref );
515           my $error   = $reason_type->insert();
516           die "$class had error inserting FS::reason_type into database: $error\n"
517             if $error;
518         }
519         $conf->set($_, $reason_type->typenum);
520       }
521     }
522
523     warn "$me Ensuring commission packages have a reason type\n" if $DEBUG;
524
525     my $hashref = { 'class' => 'R', 'type' => 'Legacy' };
526     my $reason_type = qsearchs( 'reason_type', $hashref );
527     unless ($reason_type) {
528       $reason_type  = new FS::reason_type( $hashref );
529       my $error   = $reason_type->insert();
530       die "$class had error inserting FS::reason_type into database: $error\n"
531         if $error;
532     }
533
534     my @plans = qw( flat_comission flat_comission_cust flat_comission_pkg );
535     foreach my $plan ( @plans ) {
536       foreach my $pkg ( qsearch('part_pkg', { 'plan' => $plan } ) ) {
537         unless ($pkg->option('reason_type', 1) ) { 
538           my $plandata = $pkg->plandata.
539                         "reason_type=". $reason_type->typenum. "\n";
540           $pkg->plandata($plandata);
541           my $error =
542             $pkg->replace( undef,
543                            'pkg_svc' => { map { $_->svcpart => $_->quantity }
544                                           $pkg->pkg_svc
545                                         },
546                            'primary_svc' => $pkg->svcpart,
547                          );
548             die "failed setting reason_type option: $error"
549               if $error;
550         }
551       }
552     }
553   }
554
555   local($otaker_upgrade_kludge) = 1;
556   local($ignore_empty_reasonnum) = 1;
557   $class->_upgrade_otaker(%opts);
558
559   if ( !FS::upgrade_journal->is_done('cust_credit__tax_link')
560       and !$conf->config('tax_data_vendor') ) {
561     # RT#25458: fix credit line item applications that should refer to a 
562     # specific tax allocation
563     my @cust_credit_bill_pkg = qsearch({
564         table     => 'cust_credit_bill_pkg',
565         select    => 'cust_credit_bill_pkg.*',
566         addl_from => ' LEFT JOIN cust_bill_pkg USING (billpkgnum)',
567         extra_sql =>
568           'WHERE cust_credit_bill_pkg.billpkgtaxlocationnum IS NULL '.
569           'AND cust_bill_pkg.pkgnum = 0', # is a tax
570     });
571     my %tax_items;
572     my %credits;
573     foreach (@cust_credit_bill_pkg) {
574       my $billpkgnum = $_->billpkgnum;
575       $tax_items{$billpkgnum} ||= FS::cust_bill_pkg->by_key($billpkgnum);
576       $credits{$billpkgnum} ||= [];
577       push @{ $credits{$billpkgnum} }, $_;
578     }
579     TAX_ITEM: foreach my $tax_item (values %tax_items) {
580       my $billpkgnum = $tax_item->billpkgnum;
581       # get all pkg/location/taxrate allocations of this tax line item
582       my @allocations = sort {$b->amount <=> $a->amount}
583                         qsearch('cust_bill_pkg_tax_location', {
584                             billpkgnum => $billpkgnum
585                         });
586       # and these are all credit applications to it
587       my @credits = sort {$b->amount <=> $a->amount}
588                     @{ $credits{$billpkgnum} };
589       my $c = shift @credits;
590       my $a = shift @allocations; # we will NOT modify these
591       while ($c and $a) {
592         if ( abs($c->amount - $a->amount) < 0.005 ) {
593           # by far the most common case: the tax line item is for a single
594           # tax, so we just fill in the billpkgtaxlocationnum
595           $c->set('billpkgtaxlocationnum', $a->billpkgtaxlocationnum);
596           my $error = $c->replace;
597           if ($error) {
598             warn "error fixing credit application to tax item #$billpkgnum:\n$error\n";
599             next TAX_ITEM;
600           }
601           $c = shift @credits;
602           $a = shift @allocations;
603         } elsif ( $c->amount > $a->amount ) {
604           # fairly common: the tax line contains tax for multiple packages
605           # (or multiple taxes) but the credit isn't divided up
606           my $new_link = FS::cust_credit_bill_pkg->new({
607               creditbillnum         => $c->creditbillnum,
608               billpkgnum            => $c->billpkgnum,
609               billpkgtaxlocationnum => $a->billpkgtaxlocationnum,
610               amount                => $a->amount,
611               setuprecur            => 'setup',
612           });
613           my $error = $new_link->insert;
614           if ($error) {
615             warn "error fixing credit application to tax item #$billpkgnum:\n$error\n";
616             next TAX_ITEM;
617           }
618           $c->set(amount => sprintf('%.2f', $c->amount - $a->amount));
619           $a = shift @allocations;
620         } elsif ( $c->amount < 0.005 ) {
621           # also fairly common; we can delete these with no harm
622           my $error = $c->delete;
623           warn "error removing zero-amount credit application (probably harmless):\n$error\n" if $error;
624           $c = shift @credits;
625         } elsif ( $c->amount < $a->amount ) {
626           # should never happen, but if it does, handle it gracefully
627           $c->set('billpkgtaxlocationnum', $a->billpkgtaxlocationnum);
628           my $error = $c->replace;
629           if ($error) {
630             warn "error fixing credit application to tax item #$billpkgnum:\n$error\n";
631             next TAX_ITEM;
632           }
633           $a->set(amount => $a->amount - $c->amount);
634           $c = shift @credits;
635         }
636       } # while $c and $a
637       if ( $c ) {
638         if ( $c->amount < 0.005 ) {
639           my $error = $c->delete;
640           warn "error removing zero-amount credit application (probably harmless):\n$error\n" if $error;
641         } elsif ( $c->modified ) {
642           # then we've allocated part of it, so reduce the nonspecific 
643           # application by that much
644           my $error = $c->replace;
645           warn "error fixing credit application to tax item #$billpkgnum:\n$error\n" if $error;
646         }
647         # else there are probably no allocations, i.e. this is a pre-3.x 
648         # record that was never migrated over, so leave it alone
649       } # if $c
650     } # foreach $tax_item
651     FS::upgrade_journal->set_done('cust_credit__tax_link');
652   }
653 }
654
655 =back
656
657 =head1 CLASS METHODS
658
659 =over 4
660
661 =item unapplied_sql
662
663 Returns an SQL fragment to retreive the unapplied amount.
664
665 =cut
666
667 sub unapplied_sql {
668   my ($class, $start, $end) = @_;
669
670   my $bill_start   = $start ? "AND cust_credit_bill._date <= $start"   : '';
671   my $bill_end     = $end   ? "AND cust_credit_bill._date > $end"     : '';
672   my $refund_start = $start ? "AND cust_credit_refund._date <= $start" : '';
673   my $refund_end   = $end   ? "AND cust_credit_refund._date > $end"   : '';
674
675   "amount
676         - COALESCE(
677                     ( SELECT SUM(amount) FROM cust_credit_refund
678                         WHERE cust_credit.crednum = cust_credit_refund.crednum
679                         $refund_start $refund_end )
680                     ,0
681                   )
682         - COALESCE(
683                     ( SELECT SUM(amount) FROM cust_credit_bill
684                         WHERE cust_credit.crednum = cust_credit_bill.crednum
685                         $bill_start $bill_end )
686                     ,0
687                   )
688   ";
689
690 }
691
692 =item credited_sql
693
694 Deprecated name for the unapplied_sql method.
695
696 =cut
697
698 sub credited_sql {
699   #my $class = shift;
700
701   #carp "cust_credit->credited_sql deprecated; use ->unapplied_sql";
702
703   #$class->unapplied_sql(@_);
704   unapplied_sql();
705 }
706
707 =item calculate_tax_adjustment PARAMS
708
709 Calculate the amount of tax that needs to be credited as part of a lineitem
710 credit.
711
712 PARAMS must include:
713
714 - billpkgnums: arrayref identifying the line items to credit
715 - setuprecurs: arrayref of 'setup' or 'recur', indicating which part of
716   the lineitem charge is being credited
717 - amounts: arrayref of the amounts to credit on each line item
718 - custnum: the customer all of these invoices belong to, for error checking
719
720 Returns a hash containing:
721 - subtotal: the total non-tax amount to be credited (the sum of the 'amounts')
722 - taxtotal: the total tax amount to be credited
723 - taxlines: an arrayref of hashrefs for each tax line to be credited, each with:
724   - table: "cust_bill_pkg_tax_location" or "cust_bill_pkg_tax_rate_location"
725   - num: the key within that table
726   - credit: the credit amount to apply to that line
727
728 =cut
729
730 sub calculate_tax_adjustment {
731   my ($class, %arg) = @_;
732
733   my $error;
734   my @taxlines;
735   my $subtotal = 0;
736   my $taxtotal = 0;
737
738   my (%cust_bill_pkg, %cust_bill);
739
740   for (my $i = 0; ; $i++) {
741     my $billpkgnum = $arg{billpkgnums}[$i]
742       or last;
743     my $setuprecur = $arg{setuprecurs}[$i];
744     my $amount = $arg{amounts}[$i];
745     next if $amount == 0;
746     $subtotal += $amount;
747     my $cust_bill_pkg = $cust_bill_pkg{$billpkgnum}
748                     ||= FS::cust_bill_pkg->by_key($billpkgnum)
749       or die "lineitem #$billpkgnum not found\n";
750
751     my $invnum = $cust_bill_pkg->invnum;
752     $cust_bill{ $invnum } ||= FS::cust_bill->by_key($invnum);
753     $cust_bill{ $invnum}->custnum == $arg{custnum}
754       or die "lineitem #$billpkgnum not found\n";
755
756     # calculate credit ratio.
757     # (First deduct any existing credits applied to this line item, to avoid
758     # rounding errors.)
759     my $charged = $cust_bill_pkg->get($setuprecur);
760     my $previously_credited =
761       $cust_bill_pkg->credited( '', '', setuprecur => $setuprecur) || 0;
762
763     $charged -= $previously_credited;
764     if ($charged < $amount) {
765       $error = "invoice #$invnum: tried to credit $amount, but only $charged was charged";
766       last;
767     }
768     my $ratio = $amount / $charged;
769
770     # gather taxes that apply to the selected item
771     foreach my $table (
772       qw(cust_bill_pkg_tax_location cust_bill_pkg_tax_rate_location)
773     ) {
774       foreach my $tax_link (
775         qsearch($table, { taxable_billpkgnum => $billpkgnum })
776       ) {
777         my $tax_amount = $tax_link->amount;
778         # deduct existing credits applied to the tax, for the same reason as
779         # above
780         foreach ($tax_link->cust_credit_bill_pkg) {
781           $tax_amount -= $_->amount;
782         }
783         my $tax_credit = sprintf('%.2f', $tax_amount * $ratio);
784         my $pkey = $tax_link->get($tax_link->primary_key);
785         push @taxlines, {
786           table   => $table,
787           num     => $pkey,
788           credit  => $tax_credit,
789         };
790         $taxtotal += $tax_credit;
791
792       } #foreach cust_bill_pkg_tax_(rate_)?location
793     }
794   } # foreach $billpkgnum
795
796   return (
797     subtotal => sprintf('%.2f', $subtotal),
798     taxtotal => sprintf('%.2f', $taxtotal),
799     taxlines => \@taxlines,
800   );
801 }
802
803 =item credit_lineitems
804
805 Example:
806
807   my $error = FS::cust_credit->credit_lineitems(
808
809     #the lineitems to credit
810     'billpkgnums'       => \@billpkgnums,
811     'setuprecurs'       => \@setuprecurs,
812     'amounts'           => \@amounts,
813     'apply'             => 1, #0 leaves the credit unapplied
814
815     #the credit
816     map { $_ => scalar($cgi->param($_)) }
817       #fields('cust_credit')  
818       qw( custnum _date amount reasonnum addlinfo ), #pkgnum eventnum
819
820   );
821
822 =cut
823
824 #maybe i should just be an insert with extra args instead of a class method
825 sub credit_lineitems {
826   my( $class, %arg ) = @_;
827   my $curuser = $FS::CurrentUser::CurrentUser;
828
829   #some false laziness w/misc/xmlhttp-cust_bill_pkg-calculate_taxes.html
830
831   my $cust_main = qsearchs({
832     'table'     => 'cust_main',
833     'hashref'   => { 'custnum' => $arg{custnum} },
834     'extra_sql' => ' AND '. $curuser->agentnums_sql,
835   }) or return 'unknown customer';
836
837
838   local $SIG{HUP} = 'IGNORE';
839   local $SIG{INT} = 'IGNORE';
840   local $SIG{QUIT} = 'IGNORE';
841   local $SIG{TERM} = 'IGNORE';
842   local $SIG{TSTP} = 'IGNORE';
843   local $SIG{PIPE} = 'IGNORE';
844
845   my $oldAutoCommit = $FS::UID::AutoCommit;
846   local $FS::UID::AutoCommit = 0;
847   my $dbh = dbh;
848
849   #my @cust_bill_pkg = qsearch({
850   #  'select'    => 'cust_bill_pkg.*',
851   #  'table'     => 'cust_bill_pkg',
852   #  'addl_from' => ' LEFT JOIN cust_bill USING (invnum)  '.
853   #                 ' LEFT JOIN cust_main USING (custnum) ',
854   #  'extra_sql' => ' WHERE custnum = $custnum AND billpkgnum IN ('.
855   #                     join( ',', @{$arg{billpkgnums}} ). ')',
856   #  'order_by'  => 'ORDER BY invnum ASC, billpkgnum ASC',
857   #});
858
859   my $error = '';
860
861   my $cust_credit = new FS::cust_credit ( {
862     map { $_ => $arg{$_} }
863       #fields('cust_credit')
864       qw( custnum _date amount reasonnum addlinfo ), #pkgnum eventnum
865   } );
866   $error = $cust_credit->insert;
867   if ( $error ) {
868     $dbh->rollback if $oldAutoCommit;
869     return "Error inserting credit: $error";
870   }
871
872   unless ( $arg{'apply'} ) {
873     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
874     return '';
875   }
876
877   #my $subtotal = 0;
878   # keys in all of these are invoice numbers
879   my %cust_credit_bill = ();
880   my %cust_bill_pkg = ();
881   my %cust_credit_bill_pkg = ();
882   my %unapplied_payments = (); #invoice numbers, and then billpaynums
883
884   # determine the tax adjustments
885   my %tax_adjust = $class->calculate_tax_adjustment(%arg);
886
887   foreach my $billpkgnum ( @{$arg{billpkgnums}} ) {
888     my $setuprecur = shift @{$arg{setuprecurs}};
889     my $amount = shift @{$arg{amounts}};
890
891     my $cust_bill_pkg = qsearchs({
892       'table'     => 'cust_bill_pkg',
893       'hashref'   => { 'billpkgnum' => $billpkgnum },
894       'addl_from' => 'LEFT JOIN cust_bill USING (invnum)',
895       'extra_sql' => 'AND custnum = '. $cust_main->custnum,
896     }) or die "unknown billpkgnum $billpkgnum";
897   
898     my $invnum = $cust_bill_pkg->invnum;
899
900     push @{$cust_bill_pkg{$invnum}}, $cust_bill_pkg;
901
902     $cust_credit_bill{$invnum} += $amount;
903     push @{ $cust_credit_bill_pkg{$invnum} },
904       new FS::cust_credit_bill_pkg {
905         'billpkgnum' => $billpkgnum,
906         'amount'     => sprintf('%.2f',$amount),
907         'setuprecur' => $setuprecur,
908         'sdate'      => $cust_bill_pkg->sdate,
909         'edate'      => $cust_bill_pkg->edate,
910       };
911     # unapply payments (but not other credits) from this line item
912     foreach my $cust_bill_pay_pkg (
913       $cust_bill_pkg->cust_bill_pay_pkg($setuprecur)
914     ) {
915       $error = $cust_bill_pay_pkg->delete;
916       if ( $error ) {
917         $dbh->rollback if $oldAutoCommit;
918         return "Error unapplying payment: $error";
919       }
920       $unapplied_payments{$invnum}{$cust_bill_pay_pkg->billpaynum}
921         += $cust_bill_pay_pkg->amount;
922     }
923   }
924
925   # do the same for taxes
926   foreach my $tax_credit ( @{ $tax_adjust{taxlines} } ) {
927     my $table = $tax_credit->{table};
928     my $tax_link = "FS::$table"->by_key( $tax_credit->{num} )
929       or die "tried to credit $table #$tax_credit->{num} but it doesn't exist";
930
931     my $billpkgnum = $tax_link->billpkgnum;
932     my $cust_bill_pkg = qsearchs({
933       'table'     => 'cust_bill_pkg',
934       'hashref'   => { 'billpkgnum' => $billpkgnum },
935       'addl_from' => 'LEFT JOIN cust_bill USING (invnum)',
936       'extra_sql' => 'AND custnum = '. $cust_main->custnum,
937     }) or die "unknown billpkgnum $billpkgnum";
938     
939     my $invnum = $cust_bill_pkg->invnum;
940     push @{$cust_bill_pkg{$invnum}}, $cust_bill_pkg;
941
942     my $amount = $tax_credit->{credit};
943     $cust_credit_bill{$invnum} += $amount;
944
945     # create a credit application record to the tax line item, earmarked
946     # to the specific cust_bill_pkg_Xlocation
947     push @{ $cust_credit_bill_pkg{$invnum} },
948       new FS::cust_credit_bill_pkg {
949         'billpkgnum' => $billpkgnum,
950         'amount'     => sprintf('%.2f', $amount),
951         'setuprecur' => 'setup',
952         $tax_link->primary_key, $tax_credit->{num}
953       };
954     # unapply any payments from the tax
955     foreach my $cust_bill_pay_pkg (
956       $cust_bill_pkg->cust_bill_pay_pkg('setup')
957     ) {
958       $error = $cust_bill_pay_pkg->delete;
959       if ( $error ) {
960         $dbh->rollback if $oldAutoCommit;
961         return "Error unapplying payment: $error";
962       }
963       $unapplied_payments{$invnum}{$cust_bill_pay_pkg->billpaynum}
964         += $cust_bill_pay_pkg->amount;
965     }
966   }
967
968   ###
969   # now loop through %cust_credit_bill and insert those
970   ###
971
972   # (hack to prevent cust_credit_bill_pkg insertion)
973   local($FS::cust_bill_ApplicationCommon::skip_apply_to_lineitems_hack) = 1;
974
975   foreach my $invnum ( sort { $a <=> $b } keys %cust_credit_bill ) {
976
977     # if we unapplied any payments from line items, also unapply that 
978     # amount from the invoice
979     foreach my $billpaynum (keys %{$unapplied_payments{$invnum}}) {
980       my $cust_bill_pay = FS::cust_bill_pay->by_key($billpaynum)
981         or die "broken payment application $billpaynum";
982       my @subapps = $cust_bill_pay->lineitem_applications;
983       $error = $cust_bill_pay->delete; # can't replace
984
985       my $new_cust_bill_pay = FS::cust_bill_pay->new({
986           $cust_bill_pay->hash,
987           billpaynum => '',
988           amount => sprintf('%.2f', 
989               $cust_bill_pay->amount 
990               - $unapplied_payments{$invnum}{$billpaynum}),
991       });
992
993       if ( $new_cust_bill_pay->amount > 0 ) {
994         $error ||= $new_cust_bill_pay->insert;
995         # Also reapply it to everything it was applied to before.
996         # Note that we've already deleted cust_bill_pay_pkg records for the
997         # items we're crediting, so they aren't on this list.
998         foreach my $cust_bill_pay_pkg (@subapps) {
999           $cust_bill_pay_pkg->billpaypkgnum('');
1000           $cust_bill_pay_pkg->billpaynum($new_cust_bill_pay->billpaynum);
1001           $error ||= $cust_bill_pay_pkg->insert;
1002         }
1003       }
1004       if ( $error ) {
1005         $dbh->rollback if $oldAutoCommit;
1006         return "Error unapplying payment: $error";
1007       }
1008     }
1009     #insert cust_credit_bill
1010
1011     my $cust_credit_bill = new FS::cust_credit_bill {
1012       'crednum' => $cust_credit->crednum,
1013       'invnum'  => $invnum,
1014       'amount'  => sprintf('%.2f', $cust_credit_bill{$invnum}),
1015     };
1016     $error = $cust_credit_bill->insert;
1017     if ( $error ) {
1018       $dbh->rollback if $oldAutoCommit;
1019       return "Error applying credit of $cust_credit_bill{$invnum} ".
1020              " to invoice $invnum: $error";
1021     }
1022
1023     #and then insert cust_credit_bill_pkg for each cust_bill_pkg
1024     foreach my $cust_credit_bill_pkg ( @{$cust_credit_bill_pkg{$invnum}} ) {
1025       $cust_credit_bill_pkg->creditbillnum( $cust_credit_bill->creditbillnum );
1026       $error = $cust_credit_bill_pkg->insert;
1027       if ( $error ) {
1028         $dbh->rollback if $oldAutoCommit;
1029         return "Error applying credit to line item: $error";
1030       }
1031     }
1032
1033   }
1034
1035   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1036   '';
1037
1038 }
1039
1040 =back
1041
1042 =head1 SUBROUTINES
1043
1044 =over 4
1045
1046 =item process_batch_import
1047
1048 =cut
1049
1050 use List::Util qw( min );
1051 use FS::cust_bill;
1052 use FS::cust_credit_bill;
1053 sub process_batch_import {
1054   my $job = shift;
1055
1056   my $opt = { 'table'   => 'cust_credit',
1057               'params'  => [ '_date', 'credbatch' ],
1058               'formats' => { 'simple' =>
1059                                [ 'custnum', 'amount', 'reasonnum', 'invnum' ],
1060                            },
1061               'default_csv' => 1,
1062               'postinsert_callback' => sub {
1063                 my $cust_credit = shift; #my ($cust_credit, $param ) = @_;
1064
1065                 if ( $cust_credit->invnum ) {
1066
1067                   my $cust_bill = qsearchs('cust_bill', { invnum=>$cust_credit->invnum } );
1068                   my $amount = min( $cust_credit->credited, $cust_bill->owed );
1069     
1070                   my $cust_credit_bill = new FS::cust_credit_bill ( {
1071                     'crednum' => $cust_credit->crednum,
1072                     'invnum'  => $cust_bill->invnum,
1073                     'amount'  => $amount,
1074                   } );
1075                   my $error = $cust_credit_bill->insert;
1076                   return '' unless $error;
1077
1078                 }
1079
1080                 #apply_payments_and_credits ?
1081                 $cust_credit->cust_main->apply_credits;
1082
1083                 return '';
1084
1085               },
1086             };
1087
1088   FS::Record::process_batch_import( $job, $opt, @_ );
1089
1090 }
1091
1092 =back
1093
1094 =head1 BUGS
1095
1096 The delete method.  The replace method.
1097
1098 B<credited> and B<credited_sql> are now called B<unapplied> and
1099 B<unapplied_sql>.  The old method names should start to give warnings.
1100
1101 =head1 SEE ALSO
1102
1103 L<FS::Record>, L<FS::cust_credit_refund>, L<FS::cust_refund>,
1104 L<FS::cust_credit_bill> L<FS::cust_bill>, schema.html from the base
1105 documentation.
1106
1107 =cut
1108
1109 1;
1110