SureTax, #31639, #33015, #34598
[freeside.git] / FS / FS / cust_credit.pm
index 6cbe1d0..f63d86f 100644 (file)
@@ -1,23 +1,36 @@
 package FS::cust_credit;
 package FS::cust_credit;
+use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::reason_Mixin
+             FS::Record );
 
 use strict;
 
 use strict;
-use vars qw( @ISA $conf $unsuspendauto $me $DEBUG );
+use vars qw( $conf $unsuspendauto $me $DEBUG
+             $otaker_upgrade_kludge $ignore_empty_reasonnum
+           );
+use List::Util qw( min );
 use Date::Format;
 use Date::Format;
-use FS::UID qw( dbh getotaker );
+use FS::UID qw( dbh );
 use FS::Misc qw(send_email);
 use FS::Record qw( qsearch qsearchs dbdef );
 use FS::Misc qw(send_email);
 use FS::Record qw( qsearch qsearchs dbdef );
-use FS::cust_main_Mixin;
-use FS::cust_main;
+use FS::CurrentUser;
+use FS::cust_pkg;
 use FS::cust_refund;
 use FS::cust_credit_bill;
 use FS::part_pkg;
 use FS::reason_type;
 use FS::reason;
 use FS::cust_refund;
 use FS::cust_credit_bill;
 use FS::part_pkg;
 use FS::reason_type;
 use FS::reason;
+use FS::cust_event;
+use FS::agent;
+use FS::sales;
+use FS::cust_credit_void;
+use FS::cust_bill_pkg;
+use FS::upgrade_journal;
 
 
-@ISA = qw( FS::cust_main_Mixin FS::Record );
 $me = '[ FS::cust_credit ]';
 $DEBUG = 0;
 
 $me = '[ FS::cust_credit ]';
 $DEBUG = 0;
 
+$otaker_upgrade_kludge = 0;
+$ignore_empty_reasonnum = 0;
+
 #ask FS::UID to run this stuff for us later
 $FS::UID::callback{'FS::cust_credit'} = sub { 
 
 #ask FS::UID to run this stuff for us later
 $FS::UID::callback{'FS::cust_credit'} = sub { 
 
@@ -58,22 +71,46 @@ FS::Record.  The following fields are currently supported:
 
 =over 4
 
 
 =over 4
 
-=item crednum - primary key (assigned automatically for new credits)
+=item crednum
+
+Primary key (assigned automatically for new credits)
+
+=item custnum
+
+Customer (see L<FS::cust_main>)
 
 
-=item custnum - customer (see L<FS::cust_main>)
+=item amount
 
 
-=item amount - amount of the credit
+Amount of the credit
 
 
-=item _date - specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
+=item _date
+
+Specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
-=item otaker - order taker (assigned automatically, see L<FS::UID>)
+=item usernum
+
+Order taker (see L<FS::access_user>)
+
+=item reason
+
+Text ( deprecated )
+
+=item reasonnum
+
+Reason (see L<FS::reason>)
+
+=item addlinfo
+
+Text
+
+=item closed
 
 
-=item reason - text ( deprecated )
+Books closed flag, empty or `Y'
 
 
-=item reasonum - int reason (see L<FS::reason>)
+=item pkgnum
 
 
-=item closed - books closed flag, empty or `Y'
+Desired pkgnum when using experimental package balances.
 
 =back
 
 
 =back
 
@@ -88,18 +125,34 @@ Creates a new credit.  To add the credit to the database, see L<"insert">.
 =cut
 
 sub table { 'cust_credit'; }
 =cut
 
 sub table { 'cust_credit'; }
-sub cust_linked { $_[0]->cust_main_custnum; } 
+sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum } 
 sub cust_unlinked_msg {
   my $self = shift;
   "WARNING: can't find cust_main.custnum ". $self->custnum.
   ' (cust_credit.crednum '. $self->crednum. ')';
 }
 
 sub cust_unlinked_msg {
   my $self = shift;
   "WARNING: can't find cust_main.custnum ". $self->custnum.
   ' (cust_credit.crednum '. $self->crednum. ')';
 }
 
-=item insert
+=item insert [ OPTION => VALUE ... ]
 
 Adds this credit to the database ("Posts" the credit).  If there is an error,
 returns the error, otherwise returns false.
 
 
 Adds this credit to the database ("Posts" the credit).  If there is an error,
 returns the error, otherwise returns false.
 
+Ooptions are passed as a list of keys and values.  Available options:
+
+=over 4
+
+=item reason_type
+
+L<FS::reason_type|Reason> type for newly-inserted reason
+
+=item cust_credit_source_bill_pkg
+
+An arrayref of
+L<FS::cust_credit_source_bill_pkg|FS::cust_credit_source_bilL_pkg> objects.
+They will have their crednum set and will be inserted along with this credit.
+
+=back
+
 =cut
 
 sub insert {
 =cut
 
 sub insert {
@@ -119,16 +172,23 @@ sub insert {
   my $cust_main = qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
   my $old_balance = $cust_main->balance;
 
   my $cust_main = qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
   my $old_balance = $cust_main->balance;
 
-  unless ($self->reasonnum) {
-    my $result = $self->reason( $self->getfield('reason'),
-                                exists($options{ 'reason_type' })
-                                  ? ('reason_type' => $options{ 'reason_type' })
-                                  : (),
-                              );
-    unless($result) {
+  if (!$self->reasonnum) {
+    my $reason_text = $self->get('reason')
+      or return "reason text or existing reason required";
+    my $reason_type = $options{'reason_type'}
+      or return "reason type required";
+
+    local $@;
+    my $reason = FS::reason->new_or_existing(
+      reason => $reason_text,
+      type   => $reason_type,
+      class  => 'R',
+    );
+    if ($@) {
       $dbh->rollback if $oldAutoCommit;
       $dbh->rollback if $oldAutoCommit;
-      return "failed to set reason for $me: ". $dbh->errstr;
+      return "failed to set credit reason: $@";
     }
     }
+    $self->set('reasonnum', $reason->reasonnum);
   }
 
   $self->setfield('reason', '');
   }
 
   $self->setfield('reason', '');
@@ -139,9 +199,20 @@ sub insert {
     return "error inserting $self: $error";
   }
 
     return "error inserting $self: $error";
   }
 
+  if ( $options{'cust_credit_source_bill_pkg'} ) {
+    foreach my $ccsbr ( @{ $options{'cust_credit_source_bill_pkg'} } ) {
+      $ccsbr->crednum( $self->crednum );
+      $error = $ccsbr->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "error inserting $ccsbr: $error";
+      }
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
-  #false laziness w/ cust_credit::insert
+  #false laziness w/ cust_pay::insert
   if ( $unsuspendauto && $old_balance && $cust_main->balance <= 0 ) {
     my @errors = $cust_main->unsuspend;
     #return 
   if ( $unsuspendauto && $old_balance && $cust_main->balance <= 0 ) {
     my @errors = $cust_main->unsuspend;
     #return 
@@ -169,6 +240,8 @@ the void method instead to leave a record of the deleted credit.
 # very similar to FS::cust_pay::delete
 sub delete {
   my $self = shift;
 # very similar to FS::cust_pay::delete
 sub delete {
   my $self = shift;
+  my %opt = @_;
+
   return "Can't delete closed credit" if $self->closed =~ /^Y/i;
 
   local $SIG{HUP} = 'IGNORE';
   return "Can't delete closed credit" if $self->closed =~ /^Y/i;
 
   local $SIG{HUP} = 'IGNORE';
@@ -204,12 +277,13 @@ sub delete {
     return $error;
   }
 
     return $error;
   }
 
-  if ( $conf->config('deletecredits') ne '' ) {
+  if ( !$opt{void} and $conf->config('deletecredits') ne '' ) {
 
     my $cust_main = $self->cust_main;
 
     my $error = send_email(
 
     my $cust_main = $self->cust_main;
 
     my $error = send_email(
-      'from'    => $conf->config('invoice_from'), #??? well as good as any
+      'from'    => $conf->invoice_from_full($self->cust_main->agentnum),
+                                 #invoice_from??? well as good as any
       'to'      => $conf->config('deletecredits'),
       'subject' => 'FREESIDE NOTIFICATION: Credit deleted',
       'body'    => [
       'to'      => $conf->config('deletecredits'),
       'subject' => 'FREESIDE NOTIFICATION: Credit deleted',
       'body'    => [
@@ -238,14 +312,17 @@ sub delete {
 
 }
 
 
 }
 
-=item replace OLD_RECORD
+=item replace [ OLD_RECORD ]
 
 You can, but probably shouldn't modify credits... 
 
 
 You can, but probably shouldn't modify credits... 
 
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record.  If there is an error, returns the error,
+otherwise returns false.
+
 =cut
 
 sub replace {
 =cut
 
 sub replace {
-  #return "Can't modify credit!"
   my $self = shift;
   return "Can't modify closed credit" if $self->closed =~ /^Y/i;
   $self->SUPER::replace(@_);
   my $self = shift;
   return "Can't modify closed credit" if $self->closed =~ /^Y/i;
   $self->SUPER::replace(@_);
@@ -262,45 +339,92 @@ methods.
 sub check {
   my $self = shift;
 
 sub check {
   my $self = shift;
 
+  $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
+
   my $error =
     $self->ut_numbern('crednum')
     || $self->ut_number('custnum')
     || $self->ut_numbern('_date')
     || $self->ut_money('amount')
   my $error =
     $self->ut_numbern('crednum')
     || $self->ut_number('custnum')
     || $self->ut_numbern('_date')
     || $self->ut_money('amount')
+    || $self->ut_alphan('otaker')
     || $self->ut_textn('reason')
     || $self->ut_textn('reason')
-    || $self->ut_foreign_key('reasonnum', 'reason', 'reasonnum')
+    || $self->ut_textn('addlinfo')
     || $self->ut_enum('closed', [ '', 'Y' ])
     || $self->ut_enum('closed', [ '', 'Y' ])
+    || $self->ut_foreign_keyn('pkgnum', 'cust_pkg', 'pkgnum')
+    || $self->ut_foreign_keyn('eventnum', 'cust_event', 'eventnum')
+    || $self->ut_foreign_keyn('commission_agentnum',  'agent', 'agentnum')
+    || $self->ut_foreign_keyn('commission_salesnum',  'sales', 'salesnum')
+    || $self->ut_foreign_keyn('commission_pkgnum', 'cust_pkg', 'pkgnum')
   ;
   return $error if $error;
 
   ;
   return $error if $error;
 
+  my $method = $ignore_empty_reasonnum ? 'ut_foreign_keyn' : 'ut_foreign_key';
+  $error = $self->$method('reasonnum', 'reason', 'reasonnum');
+  return $error if $error;
+
   return "amount must be > 0 " if $self->amount <= 0;
 
   return "amount must be > 0 " if $self->amount <= 0;
 
+  return "amount must be greater or equal to amount applied"
+    if $self->unapplied < 0 && ! $otaker_upgrade_kludge;
+
   return "Unknown customer"
     unless qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
 
   $self->_date(time) unless $self->_date;
 
   return "Unknown customer"
     unless qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
 
   $self->_date(time) unless $self->_date;
 
-  $self->otaker(getotaker);
-
   $self->SUPER::check;
 }
 
   $self->SUPER::check;
 }
 
-=item cust_refund
-
-Depreciated.  See the cust_credit_refund method.
+=item void [ REASON ]
 
 
-#Returns all refunds (see L<FS::cust_refund>) for this credit.
+Voids this credit: deletes the credit and all associated applications and 
+adds a record of the voided credit to the cust_credit_void table.
 
 =cut
 
 
 =cut
 
-sub cust_refund {
-  use Carp;
-  croak "FS::cust_credit->cust_pay depreciated; see ".
-        "FS::cust_credit->cust_credit_refund";
-  #my $self = shift;
-  #sort { $a->_date <=> $b->_date }
-  #  qsearch( 'cust_refund', { 'crednum' => $self->crednum } )
-  #;
+sub void {
+  my $self = shift;
+  my $reason = shift;
+
+  unless (ref($reason) || !$reason) {
+    $reason = FS::reason->new_or_existing(
+      'class'  => 'X',
+      'type'   => 'Void credit',
+      'reason' => $reason
+    );
+  }
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $cust_credit_void = new FS::cust_credit_void ( {
+      map { $_ => $self->get($_) } $self->fields
+    } );
+  $cust_credit_void->set('void_reasonnum', $reason->reasonnum);
+  my $error = $cust_credit_void->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $error = $self->delete(void => 1); # suppress deletecredits warning
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
 }
 
 =item cust_credit_refund
 }
 
 =item cust_credit_refund
@@ -311,6 +435,7 @@ Returns all refund applications (see L<FS::cust_credit_refund>) for this credit.
 
 sub cust_credit_refund {
   my $self = shift;
 
 sub cust_credit_refund {
   my $self = shift;
+  map { $_ } #return $self->num_cust_credit_refund unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_credit_refund', { 'crednum' => $self->crednum } )
   ;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_credit_refund', { 'crednum' => $self->crednum } )
   ;
@@ -325,20 +450,21 @@ credit.
 
 sub cust_credit_bill {
   my $self = shift;
 
 sub cust_credit_bill {
   my $self = shift;
+  map { $_ } #return $self->num_cust_credit_bill unless wantarray;
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_credit_bill', { 'crednum' => $self->crednum } )
   ;
 }
 
   sort { $a->_date <=> $b->_date }
     qsearch( 'cust_credit_bill', { 'crednum' => $self->crednum } )
   ;
 }
 
-=item credited
+=item unapplied
 
 
-Returns the amount of this credit that is still outstanding; which is
+Returns the amount of this credit that is still unapplied/outstanding; 
 amount minus all refund applications (see L<FS::cust_credit_refund>) and
 applications to invoices (see L<FS::cust_credit_bill>).
 
 =cut
 
 amount minus all refund applications (see L<FS::cust_credit_refund>) and
 applications to invoices (see L<FS::cust_credit_bill>).
 
 =cut
 
-sub credited {
+sub unapplied {
   my $self = shift;
   my $amount = $self->amount;
   $amount -= $_->amount foreach ( $self->cust_credit_refund );
   my $self = shift;
   my $amount = $self->amount;
   $amount -= $_->amount foreach ( $self->cust_credit_refund );
@@ -346,123 +472,36 @@ sub credited {
   sprintf( "%.2f", $amount );
 }
 
   sprintf( "%.2f", $amount );
 }
 
-=item cust_main
+=item credited
 
 
-Returns the customer (see L<FS::cust_main>) for this credit.
+Deprecated name for the unapplied method.
 
 =cut
 
 
 =cut
 
-sub cust_main {
+sub credited {
   my $self = shift;
   my $self = shift;
-  qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
+  #carp "cust_credit->credited deprecated; use ->unapplied";
+  $self->unapplied(@_);
 }
 
 }
 
+=item cust_main
 
 
-=item reason
-
-Returns the text of the associated reason (see L<FS::reason>) for this credit.
+Returns the customer (see L<FS::cust_main>) for this credit.
 
 =cut
 
 
 =cut
 
-sub reason {
-  my ($self, $value, %options) = @_;
-  my $dbh = dbh;
-  my $reason;
-  my $typenum = $options{'reason_type'};
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;  # this should already be in
-  local $FS::UID::AutoCommit = 0;            # a transaction if it matters
-
-  if ( defined( $value ) ) {
-    my $hashref = { 'reason' => $value };
-    $hashref->{'reason_type'} = $typenum if $typenum;
-    my $addl_from = "LEFT JOIN reason_type ON ( reason_type = typenum ) ";
-    my $extra_sql = " AND reason_type.class='R'"; 
-
-    $reason = qsearchs( { 'table'     => 'reason',
-                          'hashref'   => $hashref,
-                          'addl_from' => $addl_from,
-                          'extra_sql' => $extra_sql,
-                       } );
-
-    if (!$reason && $typenum) {
-      $reason = new FS::reason( { 'reason_type' => $typenum,
-                                  'reason' => $value,
-                              } );
-      $reason->insert and $reason = undef;
-    }
-
-    $self->reasonnum($reason ? $reason->reasonnum : '') ;
-    warn "$me reason used in set mode with non-existant reason -- clearing"
-      unless $reason;
-  }
-  $reason = qsearchs( 'reason', { 'reasonnum' => $self->reasonnum } );
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
-  $reason ? $reason->reason : '';
-}
-
 # _upgrade_data
 #
 # Used by FS::Upgrade to migrate to a new database.
 # _upgrade_data
 #
 # Used by FS::Upgrade to migrate to a new database.
-#
-#
 
 sub _upgrade_data {  # class method
 
 sub _upgrade_data {  # class method
-  my ($self, %opts) = @_;
-
-  warn "$me upgrading $self\n" if $DEBUG;
-
-  if (defined dbdef->table($self->table)->column('reason')) {
-
-    warn "$me Checking for unmigrated reasons\n" if $DEBUG;
-
-    my @cust_credits = qsearch({ 'table' => $self->table,
-                                 'hashref' => {},
-                                 'extrasql' => 'WHERE reason IS NOT NULL',
-                              });
-
-    if (scalar(grep { $_->getfield('reason') =~ /\S/ } @cust_credits)) {
-      warn "$me Found unmigrated reasons\n" if $DEBUG;
-      my $hashref = { 'class' => 'R', 'type' => 'Legacy' };
-      my $reason_type = qsearchs( 'reason_type', $hashref );
-      unless ($reason_type) {
-        $reason_type  = new FS::reason_type( $hashref );
-        my $error   = $reason_type->insert();
-        die "$self had error inserting FS::reason_type into database: $error\n"
-          if $error;
-      }
+  my ($class, %opts) = @_;
 
 
-      $hashref = { 'reason_type' => $reason_type->typenum,
-                   'reason' => '(none)'
-                 };
-      my $noreason = qsearchs( 'reason', $hashref );
-      unless ($noreason) {
-        $noreason = new FS::reason( $hashref );
-        my $error  = $noreason->insert();
-        die "can't insert legacy reason '(none)' into database: $error\n"
-          if $error;
-      }
+  warn "$me upgrading $class\n" if $DEBUG;
 
 
-      foreach my $cust_credit ( @cust_credits ) {
-        my $reason = $cust_credit->getfield('reason');
-        warn "Contemplating reason $reason\n" if $DEBUG > 1;
-        if ($reason =~ /\S/) {
-          $cust_credit->reason($reason, 'reason_type' => $reason_type->typenum)
-            or die "can't insert legacy reason $reason into database\n";
-        }else{
-          $cust_credit->reasonnum($noreason->reasonnum);
-        }
+  $class->_upgrade_reasonnum(%opts);
 
 
-        $cust_credit->setfield('reason', '');
-        my $error = $cust_credit->replace;
-
-        warn "*** WARNING: error replacing reason in $self ".
-             $self->crednum. ": $error ***\n"
-          if $error;
-      }
-    }
+  if (defined dbdef->table($class->table)->column('reason')) {
 
     warn "$me Ensuring existance of auto reasons\n" if $DEBUG;
 
 
     warn "$me Ensuring existance of auto reasons\n" if $DEBUG;
 
@@ -475,11 +514,10 @@ sub _upgrade_data {  # class method
         unless ($reason_type) {
           $reason_type  = new FS::reason_type( $hashref );
           my $error   = $reason_type->insert();
         unless ($reason_type) {
           $reason_type  = new FS::reason_type( $hashref );
           my $error   = $reason_type->insert();
-          die "$self had error inserting FS::reason_type into database: $error\n"
+          die "$class had error inserting FS::reason_type into database: $error\n"
             if $error;
         }
             if $error;
         }
-                                            # or clause for 1.7.x
-        $conf->set($_, $reason_type->typenum) or die "failed setting config";
+        $conf->set($_, $reason_type->typenum);
       }
     }
 
       }
     }
 
@@ -490,7 +528,7 @@ sub _upgrade_data {  # class method
     unless ($reason_type) {
       $reason_type  = new FS::reason_type( $hashref );
       my $error   = $reason_type->insert();
     unless ($reason_type) {
       $reason_type  = new FS::reason_type( $hashref );
       my $error   = $reason_type->insert();
-      die "$self had error inserting FS::reason_type into database: $error\n"
+      die "$class had error inserting FS::reason_type into database: $error\n"
         if $error;
     }
 
         if $error;
     }
 
@@ -515,8 +553,104 @@ sub _upgrade_data {  # class method
     }
   }
 
     }
   }
 
-  '';
-
+  local($otaker_upgrade_kludge) = 1;
+  local($ignore_empty_reasonnum) = 1;
+  $class->_upgrade_otaker(%opts);
+
+  if ( !FS::upgrade_journal->is_done('cust_credit__tax_link')
+      and !$conf->config('tax_data_vendor') ) {
+    # RT#25458: fix credit line item applications that should refer to a 
+    # specific tax allocation
+    my @cust_credit_bill_pkg = qsearch({
+        table     => 'cust_credit_bill_pkg',
+        select    => 'cust_credit_bill_pkg.*',
+        addl_from => ' LEFT JOIN cust_bill_pkg USING (billpkgnum)',
+        extra_sql =>
+          'WHERE cust_credit_bill_pkg.billpkgtaxlocationnum IS NULL '.
+          'AND cust_bill_pkg.pkgnum = 0', # is a tax
+    });
+    my %tax_items;
+    my %credits;
+    foreach (@cust_credit_bill_pkg) {
+      my $billpkgnum = $_->billpkgnum;
+      $tax_items{$billpkgnum} ||= FS::cust_bill_pkg->by_key($billpkgnum);
+      $credits{$billpkgnum} ||= [];
+      push @{ $credits{$billpkgnum} }, $_;
+    }
+    TAX_ITEM: foreach my $tax_item (values %tax_items) {
+      my $billpkgnum = $tax_item->billpkgnum;
+      # get all pkg/location/taxrate allocations of this tax line item
+      my @allocations = sort {$b->amount <=> $a->amount}
+                        qsearch('cust_bill_pkg_tax_location', {
+                            billpkgnum => $billpkgnum
+                        });
+      # and these are all credit applications to it
+      my @credits = sort {$b->amount <=> $a->amount}
+                    @{ $credits{$billpkgnum} };
+      my $c = shift @credits;
+      my $a = shift @allocations; # we will NOT modify these
+      while ($c and $a) {
+        if ( abs($c->amount - $a->amount) < 0.005 ) {
+          # by far the most common case: the tax line item is for a single
+          # tax, so we just fill in the billpkgtaxlocationnum
+          $c->set('billpkgtaxlocationnum', $a->billpkgtaxlocationnum);
+          my $error = $c->replace;
+          if ($error) {
+            warn "error fixing credit application to tax item #$billpkgnum:\n$error\n";
+            next TAX_ITEM;
+          }
+          $c = shift @credits;
+          $a = shift @allocations;
+        } elsif ( $c->amount > $a->amount ) {
+          # fairly common: the tax line contains tax for multiple packages
+          # (or multiple taxes) but the credit isn't divided up
+          my $new_link = FS::cust_credit_bill_pkg->new({
+              creditbillnum         => $c->creditbillnum,
+              billpkgnum            => $c->billpkgnum,
+              billpkgtaxlocationnum => $a->billpkgtaxlocationnum,
+              amount                => $a->amount,
+              setuprecur            => 'setup',
+          });
+          my $error = $new_link->insert;
+          if ($error) {
+            warn "error fixing credit application to tax item #$billpkgnum:\n$error\n";
+            next TAX_ITEM;
+          }
+          $c->set(amount => sprintf('%.2f', $c->amount - $a->amount));
+          $a = shift @allocations;
+        } elsif ( $c->amount < 0.005 ) {
+          # also fairly common; we can delete these with no harm
+          my $error = $c->delete;
+          warn "error removing zero-amount credit application (probably harmless):\n$error\n" if $error;
+          $c = shift @credits;
+        } elsif ( $c->amount < $a->amount ) {
+          # should never happen, but if it does, handle it gracefully
+          $c->set('billpkgtaxlocationnum', $a->billpkgtaxlocationnum);
+          my $error = $c->replace;
+          if ($error) {
+            warn "error fixing credit application to tax item #$billpkgnum:\n$error\n";
+            next TAX_ITEM;
+          }
+          $a->set(amount => $a->amount - $c->amount);
+          $c = shift @credits;
+        }
+      } # while $c and $a
+      if ( $c ) {
+        if ( $c->amount < 0.005 ) {
+          my $error = $c->delete;
+          warn "error removing zero-amount credit application (probably harmless):\n$error\n" if $error;
+        } elsif ( $c->modified ) {
+          # then we've allocated part of it, so reduce the nonspecific 
+          # application by that much
+          my $error = $c->replace;
+          warn "error fixing credit application to tax item #$billpkgnum:\n$error\n" if $error;
+        }
+        # else there are probably no allocations, i.e. this is a pre-3.x 
+        # record that was never migrated over, so leave it alone
+      } # if $c
+    } # foreach $tax_item
+    FS::upgrade_journal->set_done('cust_credit__tax_link');
+  }
 }
 
 =back
 }
 
 =back
@@ -525,38 +659,431 @@ sub _upgrade_data {  # class method
 
 =over 4
 
 
 =over 4
 
-=item credited_sql
+=item unapplied_sql
 
 Returns an SQL fragment to retreive the unapplied amount.
 
 =cut
 
 
 Returns an SQL fragment to retreive the unapplied amount.
 
 =cut
 
-sub credited_sql {
-  #my $class = shift;
+sub unapplied_sql {
+  my ($class, $start, $end) = @_;
+
+  my $bill_start   = $start ? "AND cust_credit_bill._date <= $start"   : '';
+  my $bill_end     = $end   ? "AND cust_credit_bill._date > $end"     : '';
+  my $refund_start = $start ? "AND cust_credit_refund._date <= $start" : '';
+  my $refund_end   = $end   ? "AND cust_credit_refund._date > $end"   : '';
 
   "amount
         - COALESCE(
                     ( SELECT SUM(amount) FROM cust_credit_refund
 
   "amount
         - COALESCE(
                     ( SELECT SUM(amount) FROM cust_credit_refund
-                        WHERE cust_credit.crednum = cust_credit_refund.crednum )
+                        WHERE cust_credit.crednum = cust_credit_refund.crednum
+                        $refund_start $refund_end )
                     ,0
                   )
         - COALESCE(
                     ( SELECT SUM(amount) FROM cust_credit_bill
                     ,0
                   )
         - COALESCE(
                     ( SELECT SUM(amount) FROM cust_credit_bill
-                        WHERE cust_credit.crednum = cust_credit_bill.crednum )
+                        WHERE cust_credit.crednum = cust_credit_bill.crednum
+                        $bill_start $bill_end )
                     ,0
                   )
   ";
 
 }
 
                     ,0
                   )
   ";
 
 }
 
+=item credited_sql
+
+Deprecated name for the unapplied_sql method.
+
+=cut
+
+sub credited_sql {
+  #my $class = shift;
+
+  #carp "cust_credit->credited_sql deprecated; use ->unapplied_sql";
+
+  #$class->unapplied_sql(@_);
+  unapplied_sql();
+}
+
+=item credit_lineitems
+
+Example:
+
+  my $error = FS::cust_credit->credit_lineitems(
+
+    #the lineitems to credit
+    'billpkgnums'       => \@billpkgnums,
+    'setuprecurs'       => \@setuprecurs,
+    'amounts'           => \@amounts,
+    'apply'             => 1, #0 leaves the credit unapplied
+
+    #the credit
+    map { $_ => scalar($cgi->param($_)) }
+      #fields('cust_credit')  
+      qw( custnum _date amount reasonnum addlinfo ), #pkgnum eventnum
+
+  );
+
+=cut
+
+#maybe i should just be an insert with extra args instead of a class method
+sub credit_lineitems {
+  my( $class, %arg ) = @_;
+  my $curuser = $FS::CurrentUser::CurrentUser;
+
+  #some false laziness w/misc/xmlhttp-cust_bill_pkg-calculate_taxes.html
+
+  my $cust_main = qsearchs({
+    'table'     => 'cust_main',
+    'hashref'   => { 'custnum' => $arg{custnum} },
+    'extra_sql' => ' AND '. $curuser->agentnums_sql,
+  }) or return 'unknown customer';
+
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  #my @cust_bill_pkg = qsearch({
+  #  'select'    => 'cust_bill_pkg.*',
+  #  'table'     => 'cust_bill_pkg',
+  #  'addl_from' => ' LEFT JOIN cust_bill USING (invnum)  '.
+  #                 ' LEFT JOIN cust_main USING (custnum) ',
+  #  'extra_sql' => ' WHERE custnum = $custnum AND billpkgnum IN ('.
+  #                     join( ',', @{$arg{billpkgnums}} ). ')',
+  #  'order_by'  => 'ORDER BY invnum ASC, billpkgnum ASC',
+  #});
+
+  my $error = '';
+
+  my $cust_credit = new FS::cust_credit ( {
+    map { $_ => $arg{$_} }
+      #fields('cust_credit')
+      qw( custnum _date amount reasonnum addlinfo ), #pkgnum eventnum
+  } );
+  $error = $cust_credit->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return "Error inserting credit: $error";
+  }
+
+  unless ( $arg{'apply'} ) {
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    return '';
+  }
+
+  #my $subtotal = 0;
+  # keys in all of these are invoice numbers
+  my %cust_credit_bill = ();
+  my %cust_bill_pkg = ();
+  my %cust_credit_bill_pkg = ();
+  my %taxlisthash = ();
+  my %unapplied_payments = (); #invoice numbers, and then billpaynums
+  foreach my $billpkgnum ( @{$arg{billpkgnums}} ) {
+    my $setuprecur = shift @{$arg{setuprecurs}};
+    my $amount = shift @{$arg{amounts}};
+
+    my $cust_bill_pkg = qsearchs({
+      'table'     => 'cust_bill_pkg',
+      'hashref'   => { 'billpkgnum' => $billpkgnum },
+      'addl_from' => 'LEFT JOIN cust_bill USING (invnum)',
+      'extra_sql' => 'AND custnum = '. $cust_main->custnum,
+    }) or die "unknown billpkgnum $billpkgnum";
+  
+    my $invnum = $cust_bill_pkg->invnum;
+
+    if ( $setuprecur eq 'setup' ) {
+      $cust_bill_pkg->setup($amount);
+      $cust_bill_pkg->recur(0);
+      $cust_bill_pkg->unitrecur(0);
+      $cust_bill_pkg->type('');
+    } else {
+      $setuprecur = 'recur'; #in case its a usage classnum?
+      $cust_bill_pkg->recur($amount);
+      $cust_bill_pkg->setup(0);
+      $cust_bill_pkg->unitsetup(0);
+    }
+
+    push @{$cust_bill_pkg{$invnum}}, $cust_bill_pkg;
+
+    #unapply any payments applied to this line item (other credits too?)
+    foreach my $cust_bill_pay_pkg ( $cust_bill_pkg->cust_bill_pay_pkg($setuprecur) ) {
+      $error = $cust_bill_pay_pkg->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error unapplying payment: $error";
+      }
+      $unapplied_payments{$invnum}{$cust_bill_pay_pkg->billpaynum}
+        += $cust_bill_pay_pkg->amount;
+    }
+
+    #$subtotal += $amount;
+    $cust_credit_bill{$invnum} += $amount;
+    push @{ $cust_credit_bill_pkg{$invnum} },
+      new FS::cust_credit_bill_pkg {
+        'billpkgnum' => $cust_bill_pkg->billpkgnum,
+        'amount'     => sprintf('%.2f',$amount),
+        'setuprecur' => $setuprecur,
+        'sdate'      => $cust_bill_pkg->sdate,
+        'edate'      => $cust_bill_pkg->edate,
+      };
+
+    # recalculate taxes with new amounts
+    $taxlisthash{$invnum} ||= {};
+    if ( $cust_bill_pkg->pkgnum or $cust_bill_pkg->feepart ) {
+      $cust_main->_handle_taxes( $taxlisthash{$invnum}, $cust_bill_pkg );
+    } # otherwise the item itself is a tax, and assume the caller knows
+      # what they're doing
+  }
+
+  ###
+  # now loop through %cust_credit_bill and insert those
+  ###
+
+  # (hack to prevent cust_credit_bill_pkg insertion)
+  local($FS::cust_bill_ApplicationCommon::skip_apply_to_lineitems_hack) = 1;
+
+  foreach my $invnum ( sort { $a <=> $b } keys %cust_credit_bill ) {
+
+    local $@;
+    my $arrayref_or_error = eval { $cust_main->calculate_taxes(
+        $cust_bill_pkg{$invnum}, # list of taxable items that we're crediting
+        $taxlisthash{$invnum},   # list of tax-item bindings
+        $cust_bill_pkg{$invnum}->[0]->cust_bill->_date, # invoice time
+      ) };
+
+    if ( $@ ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error calculating taxes: $@";
+    }
+    
+    my %tax_links; # {tax billpkgnum}{nontax billpkgnum}
+
+    #taxes
+    foreach my $cust_bill_pkg ( @{ $cust_bill_pkg{$invnum} } ) {
+      my $billpkgnum = $cust_bill_pkg->billpkgnum;
+      my %hash = ( 'taxable_billpkgnum' => $billpkgnum );
+      # gather up existing tax links (we need their billpkgtaxlocationnums)
+      my @tax_links = qsearch('cust_bill_pkg_tax_location', \%hash),
+                      qsearch('cust_bill_pkg_tax_rate_location', \%hash);
+
+      foreach ( @tax_links ) {
+        $tax_links{$_->billpkgnum} ||= {};
+        $tax_links{$_->billpkgnum}{$_->taxable_billpkgnum} = $_;
+      }
+    }
+
+    foreach my $taxline ( @$arrayref_or_error ) {
+
+      my $amount = $taxline->setup;
+
+      # find equivalent tax line item on the existing invoice
+      my $tax_item = qsearchs('cust_bill_pkg', {
+          'invnum'    => $invnum,
+          'pkgnum'    => 0,
+          'itemdesc'  => $taxline->desc,
+      });
+      if (!$tax_item) {
+        # or should we just exit if this happens?
+        $cust_credit->set('amount', 
+          sprintf('%.2f', $cust_credit->get('amount') - $amount)
+        );
+        my $error = $cust_credit->replace;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return "error correcting credit for missing tax line: $error";
+        }
+      }
+
+      # but in the new era, we no longer have the problem of uniquely
+      # identifying the tax_Xlocation record.  The billpkgnums of the 
+      # tax and the taxed item are known.
+      foreach my $new_loc
+        ( @{ $taxline->get('cust_bill_pkg_tax_location') },
+          @{ $taxline->get('cust_bill_pkg_tax_rate_location') } )
+      {
+        # the existing tax_Xlocation object
+        my $old_loc =
+          $tax_links{$tax_item->billpkgnum}{$new_loc->taxable_cust_bill_pkg->billpkgnum};
+
+        next if !$old_loc; # apply the leftover amount nonspecifically
+
+        #support partial credits: use $amount if smaller
+        # (so just distribute to the first location?   perhaps should
+        #  do so evenly...)
+        my $loc_amount = min( $amount, $new_loc->amount);
+
+        $amount -= $loc_amount;
+
+        $cust_credit_bill{$invnum} += $loc_amount;
+        push @{ $cust_credit_bill_pkg{$invnum} },
+          new FS::cust_credit_bill_pkg {
+            'billpkgnum'                => $tax_item->billpkgnum,
+            'amount'                    => $loc_amount,
+            'setuprecur'                => 'setup',
+            'billpkgtaxlocationnum'     => $old_loc->billpkgtaxlocationnum,
+            'billpkgtaxratelocationnum' => $old_loc->billpkgtaxratelocationnum,
+          };
+
+      } #foreach my $new_loc
+
+      # we still have to deal with the possibility that the tax links don't
+      # cover the whole amount of tax because of an incomplete upgrade...
+      if ($amount > 0.005) {
+        $cust_credit_bill{$invnum} += $amount;
+        push @{ $cust_credit_bill_pkg{$invnum} },
+          new FS::cust_credit_bill_pkg {
+            'billpkgnum' => $tax_item->billpkgnum,
+            'amount'     => sprintf('%.2f', $amount),
+            'setuprecur' => 'setup',
+          };
+
+      } # if $amount > 0
+
+      #unapply any payments applied to the tax
+      foreach my $cust_bill_pay_pkg
+        ( $tax_item->cust_bill_pay_pkg('setup') )
+      {
+        $error = $cust_bill_pay_pkg->delete;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return "Error unapplying payment: $error";
+        }
+        $unapplied_payments{$invnum}{$cust_bill_pay_pkg->billpaynum}
+          += $cust_bill_pay_pkg->amount;
+      }
+    } #foreach $taxline
+
+    # if we unapplied any payments from line items, also unapply that 
+    # amount from the invoice
+    foreach my $billpaynum (keys %{$unapplied_payments{$invnum}}) {
+      my $cust_bill_pay = FS::cust_bill_pay->by_key($billpaynum)
+        or die "broken payment application $billpaynum";
+      my @subapps = $cust_bill_pay->lineitem_applications;
+      $error = $cust_bill_pay->delete; # can't replace
+
+      my $new_cust_bill_pay = FS::cust_bill_pay->new({
+          $cust_bill_pay->hash,
+          billpaynum => '',
+          amount => sprintf('%.2f', 
+              $cust_bill_pay->amount 
+              - $unapplied_payments{$invnum}{$billpaynum}),
+      });
+
+      if ( $new_cust_bill_pay->amount > 0 ) {
+        $error ||= $new_cust_bill_pay->insert;
+        # Also reapply it to everything it was applied to before.
+        # Note that we've already deleted cust_bill_pay_pkg records for the
+        # items we're crediting, so they aren't on this list.
+        foreach my $cust_bill_pay_pkg (@subapps) {
+          $cust_bill_pay_pkg->billpaypkgnum('');
+          $cust_bill_pay_pkg->billpaynum($new_cust_bill_pay->billpaynum);
+          $error ||= $cust_bill_pay_pkg->insert;
+        }
+      }
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error unapplying payment: $error";
+      }
+    }
+    #insert cust_credit_bill
+
+    my $cust_credit_bill = new FS::cust_credit_bill {
+      'crednum' => $cust_credit->crednum,
+      'invnum'  => $invnum,
+      'amount'  => sprintf('%.2f', $cust_credit_bill{$invnum}),
+    };
+    $error = $cust_credit_bill->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error applying credit of $cust_credit_bill{$invnum} ".
+             " to invoice $invnum: $error";
+    }
+
+    #and then insert cust_credit_bill_pkg for each cust_bill_pkg
+    foreach my $cust_credit_bill_pkg ( @{$cust_credit_bill_pkg{$invnum}} ) {
+      $cust_credit_bill_pkg->creditbillnum( $cust_credit_bill->creditbillnum );
+      $error = $cust_credit_bill_pkg->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error applying credit to line item: $error";
+      }
+    }
+
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+=back
+
+=head1 SUBROUTINES
+
+=over 4
+
+=item process_batch_import
+
+=cut
+
+use List::Util qw( min );
+use FS::cust_bill;
+use FS::cust_credit_bill;
+sub process_batch_import {
+  my $job = shift;
+
+  my $opt = { 'table'   => 'cust_credit',
+              'params'  => [ '_date', 'credbatch' ],
+              'formats' => { 'simple' =>
+                               [ 'custnum', 'amount', 'reasonnum', 'invnum' ],
+                           },
+              'default_csv' => 1,
+              'postinsert_callback' => sub {
+                my $cust_credit = shift; #my ($cust_credit, $param ) = @_;
+
+                if ( $cust_credit->invnum ) {
+
+                  my $cust_bill = qsearchs('cust_bill', { invnum=>$cust_credit->invnum } );
+                  my $amount = min( $cust_credit->credited, $cust_bill->owed );
+    
+                  my $cust_credit_bill = new FS::cust_credit_bill ( {
+                    'crednum' => $cust_credit->crednum,
+                    'invnum'  => $cust_bill->invnum,
+                    'amount'  => $amount,
+                  } );
+                  my $error = $cust_credit_bill->insert;
+                  return '' unless $error;
+
+                }
+
+                #apply_payments_and_credits ?
+                $cust_credit->cust_main->apply_credits;
+
+                return '';
+
+              },
+            };
+
+  FS::Record::process_batch_import( $job, $opt, @_ );
+
+}
+
 =back
 
 =head1 BUGS
 
 The delete method.  The replace method.
 
 =back
 
 =head1 BUGS
 
 The delete method.  The replace method.
 
-B<credited> and B<credited_sql> should probably be called B<unapplied> and
-B<unapplied_sql>.
+B<credited> and B<credited_sql> are now called B<unapplied> and
+B<unapplied_sql>.  The old method names should start to give warnings.
 
 =head1 SEE ALSO
 
 
 =head1 SEE ALSO