and still create credit source records on 4.x+, #42729
[freeside.git] / FS / FS / cust_credit.pm
index dde847a..aebf259 100644 (file)
@@ -3,13 +3,12 @@ use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::reason_Mixin
              FS::Record );
 
 use strict;
              FS::Record );
 
 use strict;
-use vars qw( $conf $unsuspendauto $me $DEBUG
+use vars qw( $conf $me $DEBUG
              $otaker_upgrade_kludge $ignore_empty_reasonnum
            );
 use List::Util qw( min );
 use Date::Format;
 use FS::UID qw( dbh );
              $otaker_upgrade_kludge $ignore_empty_reasonnum
            );
 use List::Util qw( min );
 use Date::Format;
 use FS::UID qw( dbh );
-use FS::Misc qw(send_email);
 use FS::Record qw( qsearch qsearchs dbdef );
 use FS::CurrentUser;
 use FS::cust_pkg;
 use FS::Record qw( qsearch qsearchs dbdef );
 use FS::CurrentUser;
 use FS::cust_pkg;
@@ -35,13 +34,11 @@ $ignore_empty_reasonnum = 0;
 $FS::UID::callback{'FS::cust_credit'} = sub { 
 
   $conf = new FS::Conf;
 $FS::UID::callback{'FS::cust_credit'} = sub { 
 
   $conf = new FS::Conf;
-  $unsuspendauto = $conf->exists('unsuspendauto');
 
 };
 
 our %reasontype_map = ( 'referral_credit_type' => 'Referral Credit',
                         'cancel_credit_type'   => 'Cancellation Credit',
 
 };
 
 our %reasontype_map = ( 'referral_credit_type' => 'Referral Credit',
                         'cancel_credit_type'   => 'Cancellation Credit',
-                        'signup_credit_type'   => 'Self-Service Credit',
                       );
 
 =head1 NAME
                       );
 
 =head1 NAME
@@ -132,11 +129,27 @@ sub cust_unlinked_msg {
   ' (cust_credit.crednum '. $self->crednum. ')';
 }
 
   ' (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 {
@@ -156,16 +169,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', '');
@@ -189,16 +209,8 @@ sub insert {
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
-  #false laziness w/ cust_pay::insert
-  if ( $unsuspendauto && $old_balance && $cust_main->balance <= 0 ) {
-    my @errors = $cust_main->unsuspend;
-    #return 
-    # side-fx with nested transactions?  upstack rolls back?
-    warn "WARNING:Errors unsuspending customer ". $cust_main->custnum. ": ".
-         join(' / ', @errors)
-      if @errors;
-  }
-  #eslaf
+  # possibly trigger package unsuspend, doesn't abort transaction on failure
+  $self->unsuspend_balance if $old_balance;
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
@@ -254,35 +266,6 @@ sub delete {
     return $error;
   }
 
     return $error;
   }
 
-  if ( !$opt{void} and $conf->config('deletecredits') ne '' ) {
-
-    my $cust_main = $self->cust_main;
-
-    my $error = send_email(
-      'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
-                                 #invoice_from??? well as good as any
-      'to'      => $conf->config('deletecredits'),
-      'subject' => 'FREESIDE NOTIFICATION: Credit deleted',
-      'body'    => [
-        "This is an automatic message from your Freeside installation\n",
-        "informing you that the following credit has been deleted:\n",
-        "\n",
-        'crednum: '. $self->crednum. "\n",
-        'custnum: '. $self->custnum.
-          " (". $cust_main->last. ", ". $cust_main->first. ")\n",
-        'amount: $'. sprintf("%.2f", $self->amount). "\n",
-        'date: '. time2str("%a %b %e %T %Y", $self->_date). "\n",
-        'reason: '. $self->reason. "\n",
-      ],
-    );
-
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "can't send credit deletion notification: $error";
-    }
-
-  }
-
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   '';
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   '';
@@ -332,6 +315,7 @@ sub check {
     || $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')
     || $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')
+    || $self->ut_foreign_keyn('commission_invnum', 'cust_bill', 'invnum')
   ;
   return $error if $error;
 
   ;
   return $error if $error;
 
@@ -359,13 +343,18 @@ adds a record of the voided credit to the cust_credit_void table.
 
 =cut
 
 
 =cut
 
-# yes, false laziness with cust_pay and cust_bill
-# but frankly I don't have time to fix it now
-
 sub void {
   my $self = shift;
   my $reason = shift;
 
 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{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -380,14 +369,14 @@ sub void {
   my $cust_credit_void = new FS::cust_credit_void ( {
       map { $_ => $self->get($_) } $self->fields
     } );
   my $cust_credit_void = new FS::cust_credit_void ( {
       map { $_ => $self->get($_) } $self->fields
     } );
-  $cust_credit_void->set('void_reason', $reason);
+  $cust_credit_void->set('void_reasonnum', $reason->reasonnum) if $reason;
   my $error = $cust_credit_void->insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   }
 
   my $error = $cust_credit_void->insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   }
 
-  $error = $self->delete(void => 1); # suppress deletecredits warning
+  $error = $self->delete();
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -530,7 +519,7 @@ sub _upgrade_data {  # class method
   $class->_upgrade_otaker(%opts);
 
   if ( !FS::upgrade_journal->is_done('cust_credit__tax_link')
   $class->_upgrade_otaker(%opts);
 
   if ( !FS::upgrade_journal->is_done('cust_credit__tax_link')
-      and !$conf->exists('enable_taxproducts') ) {
+      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({
     # RT#25458: fix credit line item applications that should refer to a 
     # specific tax allocation
     my @cust_credit_bill_pkg = qsearch({
@@ -677,7 +666,128 @@ sub credited_sql {
   unapplied_sql();
 }
 
   unapplied_sql();
 }
 
-=item credit_lineitems
+=item calculate_tax_adjustment PARAMS
+
+Calculate the amount of tax that needs to be credited as part of a lineitem
+credit.
+
+PARAMS must include:
+
+- billpkgnums: arrayref identifying the line items to credit
+- setuprecurs: arrayref of 'setup' or 'recur', indicating which part of
+  the lineitem charge is being credited
+- amounts: arrayref of the amounts to credit on each line item
+- custnum: the customer all of these invoices belong to, for error checking
+
+Returns a hash containing:
+- subtotal: the total non-tax amount to be credited (the sum of the 'amounts')
+- taxtotal: the total tax amount to be credited
+- taxlines: an arrayref of hashrefs for each tax line to be credited, each with:
+  - table: "cust_bill_pkg_tax_location" or "cust_bill_pkg_tax_rate_location"
+  - num: the key within that table
+  - credit: the credit amount to apply to that line
+
+=cut
+
+sub calculate_tax_adjustment {
+  my ($class, %arg) = @_;
+
+  my $error;
+  my @taxlines;
+  my $subtotal = 0;
+  my $taxtotal = 0;
+
+  my (%cust_bill_pkg, %cust_bill);
+
+  for (my $i = 0; ; $i++) {
+    my $billpkgnum = $arg{billpkgnums}[$i]
+      or last;
+    my $setuprecur = $arg{setuprecurs}[$i];
+    my $amount = $arg{amounts}[$i];
+    next if $amount == 0;
+    $subtotal += $amount;
+    my $cust_bill_pkg = $cust_bill_pkg{$billpkgnum}
+                    ||= FS::cust_bill_pkg->by_key($billpkgnum)
+      or die "lineitem #$billpkgnum not found\n";
+
+    my $invnum = $cust_bill_pkg->invnum;
+    $cust_bill{ $invnum } ||= FS::cust_bill->by_key($invnum);
+    $cust_bill{ $invnum}->custnum == $arg{custnum}
+      or die "lineitem #$billpkgnum not found\n";
+
+    # tax_Xlocation records don't distinguish setup and recur, so calculate
+    # the fraction of setup+recur (after deducting credits) that's setup. This
+    # will also be the fraction of tax (after deducting credits) that's tax on
+    # setup.
+    my ($setup, $recur);
+    $setup = $cust_bill_pkg->get('setup') || 0;
+    if ($setup) {
+      $setup -= $cust_bill_pkg->credited('', '', setuprecur => 'setup') || 0;
+    }
+    $recur = $cust_bill_pkg->get('recur') || 0;
+    if ($recur) {
+      $recur -= $cust_bill_pkg->credited('', '', setuprecur => 'recur') || 0;
+    }
+    my $setup_ratio = $setup / ($setup + $recur);
+
+    # Calculate the fraction of tax to credit: it's the fraction of this charge
+    # (either setup or recur) that's being credited.
+    my $charged = ($setuprecur eq 'setup') ? $setup : $recur;
+    next if $charged == 0; # shouldn't happen, but still...
+
+    if ($charged < $amount) {
+      $error = "invoice #$invnum: tried to credit $amount, but only $charged was charged";
+      last;
+    }
+    my $credit_ratio = $amount / $charged;
+
+    # gather taxes that apply to the selected item
+    foreach my $table (
+      qw(cust_bill_pkg_tax_location cust_bill_pkg_tax_rate_location)
+    ) {
+      foreach my $tax_link (
+        qsearch($table, { taxable_billpkgnum => $billpkgnum })
+      ) {
+        my $tax_amount = $tax_link->amount;
+        # deduct existing credits applied to the tax, for the same reason as
+        # above
+        foreach ($tax_link->cust_credit_bill_pkg) {
+          $tax_amount -= $_->amount;
+        }
+        # split tax amount based on setuprecur
+        # (this method ensures that, if you credit both setup and recur tax,
+        # it always equals the entire tax despite any rounding)
+        my $setup_tax = sprintf('%.2f', $tax_amount * $setup_ratio);
+        if ( $setuprecur eq 'setup' ) {
+          $tax_amount = $setup_tax;
+        } else {
+          $tax_amount = $tax_amount - $setup_tax;
+        }
+        my $tax_credit = sprintf('%.2f', $tax_amount * $credit_ratio);
+        my $pkey = $tax_link->get($tax_link->primary_key);
+        push @taxlines, {
+          table   => $table,
+          num     => $pkey,
+          credit  => $tax_credit,
+        };
+        $taxtotal += $tax_credit;
+
+      } #foreach cust_bill_pkg_tax_(rate_)?location
+    }
+  } # foreach $billpkgnum
+
+  return (
+    subtotal => sprintf('%.2f', $subtotal),
+    taxtotal => sprintf('%.2f', $taxtotal),
+    taxlines => \@taxlines,
+  );
+}
+
+=item credit_lineitems OPTIONS
+
+Creates a credit to a group of line items, with a specified amount applied
+to each. This will also calculate the tax adjustments for those amounts and
+credit the appropriate tax line items.
 
 Example:
 
 
 Example:
 
@@ -688,6 +798,7 @@ Example:
     'setuprecurs'       => \@setuprecurs,
     'amounts'           => \@amounts,
     'apply'             => 1, #0 leaves the credit unapplied
     'setuprecurs'       => \@setuprecurs,
     'amounts'           => \@amounts,
     'apply'             => 1, #0 leaves the credit unapplied
+    'set_source'        => 1, #creates credit source records for the line items
 
     #the credit
     map { $_ => scalar($cgi->param($_)) }
 
     #the credit
     map { $_ => scalar($cgi->param($_)) }
@@ -696,6 +807,16 @@ Example:
 
   );
 
 
   );
 
+C<billpkgnums>, C<setuprecurs>, C<amounts> are required and are parallel
+arrays. Each one indicates an amount of credit to be applied to either the
+setup or recur portion of a (non-tax) line item.
+
+C<custnum>, C<_date>, C<reasonnum>, and C<addlinfo> will be set on the
+credit before it's inserted.
+
+C<amount> is the total amount. If unspecified, the credit will be the sum
+of the per-line-item amounts and their tax adjustments.
+
 =cut
 
 #maybe i should just be an insert with extra args instead of a class method
 =cut
 
 #maybe i should just be an insert with extra args instead of a class method
@@ -735,10 +856,20 @@ sub credit_lineitems {
 
   my $error = '';
 
 
   my $error = '';
 
+  # first, determine the tax adjustments
+  my %tax_adjust = $class->calculate_tax_adjustment(%arg);
+  # and determine the amount automatically if it wasn't specified
+  if ( !exists( $arg{amount} ) ) {
+    $arg{amount} = sprintf('%.2f', $tax_adjust{subtotal} + $tax_adjust{taxtotal});
+  }
+
+  my $set_source = $arg{set_source};
+
+  # create the credit
   my $cust_credit = new FS::cust_credit ( {
     map { $_ => $arg{$_} }
       #fields('cust_credit')
   my $cust_credit = new FS::cust_credit ( {
     map { $_ => $arg{$_} }
       #fields('cust_credit')
-      qw( custnum _date amount reasonnum addlinfo ), #pkgnum eventnum
+      qw( custnum _date amount reason reasonnum addlinfo ), #pkgnum eventnum
   } );
   $error = $cust_credit->insert;
   if ( $error ) {
   } );
   $error = $cust_credit->insert;
   if ( $error ) {
@@ -756,8 +887,52 @@ sub credit_lineitems {
   my %cust_credit_bill = ();
   my %cust_bill_pkg = ();
   my %cust_credit_bill_pkg = ();
   my %cust_credit_bill = ();
   my %cust_bill_pkg = ();
   my %cust_credit_bill_pkg = ();
-  my %taxlisthash = ();
   my %unapplied_payments = (); #invoice numbers, and then billpaynums
   my %unapplied_payments = (); #invoice numbers, and then billpaynums
+  my %currency;
+
+  # little private function to unapply payments from a cust_bill_pkg until
+  # there's a specified amount of unpaid balance on it.
+  # it's a separate sub because we do it for both tax and nontax items. it's
+  # private because it needs access to some local data structures.
+  my $unapply_sub = sub {
+    my ($cust_bill_pkg, $setuprecur, $need_to_unapply) = @_;
+
+    my $invnum = $cust_bill_pkg->invnum;
+
+    $need_to_unapply -= $cust_bill_pkg->owed($setuprecur);
+    next if $need_to_unapply < 0.005;
+
+    my $error;
+    # then unapply payments one at a time (partially if need be) until the
+    # unpaid balance = the credit amount.
+    foreach my $cust_bill_pay_pkg (
+      $cust_bill_pkg->cust_bill_pay_pkg($setuprecur)
+    ) {
+      my $this_amount = $cust_bill_pay_pkg->amount;
+      if ( $this_amount > $need_to_unapply ) {
+        # unapply the needed amount
+        $cust_bill_pay_pkg->set('amount',
+          sprintf('%.2f', $this_amount - $need_to_unapply));
+        $error = $cust_bill_pay_pkg->replace;
+        $unapplied_payments{$invnum}{$cust_bill_pay_pkg->billpaynum} += $need_to_unapply;
+        last; # and we're done
+
+      } else {
+        # unapply it all
+        $error = $cust_bill_pay_pkg->delete;
+        $unapplied_payments{$invnum}{$cust_bill_pay_pkg->billpaynum} += $this_amount;
+
+        $need_to_unapply -= $this_amount;
+      }
+
+    } # foreach $cust_bill_pay_pkg
+
+    # return an error if we somehow still have leftover $need_to_unapply?
+
+    return $error;
+  };
+
+
   foreach my $billpkgnum ( @{$arg{billpkgnums}} ) {
     my $setuprecur = shift @{$arg{setuprecurs}};
     my $amount = shift @{$arg{amounts}};
   foreach my $billpkgnum ( @{$arg{billpkgnums}} ) {
     my $setuprecur = shift @{$arg{setuprecurs}};
     my $amount = shift @{$arg{amounts}};
@@ -771,167 +946,96 @@ sub credit_lineitems {
   
     my $invnum = $cust_bill_pkg->invnum;
 
   
     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;
 
     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 {
     $cust_credit_bill{$invnum} += $amount;
     push @{ $cust_credit_bill_pkg{$invnum} },
       new FS::cust_credit_bill_pkg {
-        'billpkgnum' => $cust_bill_pkg->billpkgnum,
+        'billpkgnum' => $billpkgnum,
         'amount'     => sprintf('%.2f',$amount),
         'setuprecur' => $setuprecur,
         'sdate'      => $cust_bill_pkg->sdate,
         'edate'      => $cust_bill_pkg->edate,
       };
 
         '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
+    # unapply payments if necessary
+    $error = &{$unapply_sub}($cust_bill_pkg, $setuprecur, $amount);
+
+    if ( $set_source ) {
+      $currency{$invnum} ||= $cust_bill_pkg->cust_bill->currency;
+      my $source = FS::cust_credit_source_bill_pkg->new({
+        'crednum'     => $cust_credit->crednum,
+        'billpkgnum'  => $billpkgnum,
+        'amount'      => $amount,
+        'currency'    => $currency{invnum},
+      });
+      $error ||= $source->insert;
+    }
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error unapplying payment: $error";
+    }
   }
 
   }
 
-  ###
-  # now loop through %cust_credit_bill and insert those
-  ###
+  # do the same for taxes
+  foreach my $tax_credit ( @{ $tax_adjust{taxlines} } ) {
+    my $table = $tax_credit->{table};
+    my $tax_link = "FS::$table"->by_key( $tax_credit->{num} )
+      or die "tried to credit $table #$tax_credit->{num} but it doesn't exist";
 
 
-  # (hack to prevent cust_credit_bill_pkg insertion)
-  local($FS::cust_bill_ApplicationCommon::skip_apply_to_lineitems_hack) = 1;
+    my $billpkgnum = $tax_link->billpkgnum;
+    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;
+    push @{$cust_bill_pkg{$invnum}}, $cust_bill_pkg;
 
 
-  foreach my $invnum ( sort { $a <=> $b } keys %cust_credit_bill ) {
+    my $amount = $tax_credit->{credit};
+    $cust_credit_bill{$invnum} += $amount;
 
 
-    my $arrayref_or_error =
-      $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
-      );
+    # create a credit application record to the tax line item, earmarked
+    # to the specific cust_bill_pkg_Xlocation
+    push @{ $cust_credit_bill_pkg{$invnum} },
+      new FS::cust_credit_bill_pkg {
+        'billpkgnum' => $billpkgnum,
+        'amount'     => sprintf('%.2f', $amount),
+        'setuprecur' => 'setup',
+        $tax_link->primary_key, $tax_credit->{num}
+      };
 
 
-    unless ( ref( $arrayref_or_error ) ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "Error calculating taxes: $arrayref_or_error";
-    }
-    
-    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} = $_;
-      }
+    $error = &{$unapply_sub}($cust_bill_pkg, 'setup', $amount);
+
+    # I guess it's correct to do this for taxes also?
+    if ( $set_source ) {
+      $currency{$invnum} ||= $cust_bill_pkg->cust_bill->currency;
+      my $source = FS::cust_credit_source_bill_pkg->new({
+        'crednum'     => $cust_credit->crednum,
+        'billpkgnum'  => $billpkgnum,
+        'amount'      => $amount,
+        'currency'    => $currency{invnum},
+      });
+      $error ||= $source->insert;
     }
 
     }
 
-    foreach my $taxline ( @$arrayref_or_error ) {
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error unapplying payment: $error";
+    }
+  }
 
 
-      my $amount = $taxline->setup;
+  ###
+  # now loop through %cust_credit_bill and insert those
+  ###
 
 
-      # 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";
-        }
-      }
+  # (hack to prevent cust_credit_bill_pkg insertion)
+  local($FS::cust_bill_ApplicationCommon::skip_apply_to_lineitems_hack) = 1;
 
 
-      # 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
+  foreach my $invnum ( sort { $a <=> $b } keys %cust_credit_bill ) {
 
     # if we unapplied any payments from line items, also unapply that 
     # amount from the invoice
 
     # if we unapplied any payments from line items, also unapply that 
     # amount from the invoice
@@ -996,6 +1100,58 @@ sub credit_lineitems {
 
 }
 
 
 }
 
+### refund_to_unapply/unapply_refund false laziness with FS::cust_pay
+
+=item refund_to_unapply
+
+Returns L<FS::cust_credit_refund> objects that will be deleted by L</unapply_refund>
+(all currently applied refunds that aren't closed.)
+Returns empty list if credit itself is closed.
+
+=cut
+
+sub refund_to_unapply {
+  my $self = shift;
+  return () if $self->closed;
+  qsearch({
+    'table'   => 'cust_credit_refund',
+    'hashref' => { 'crednum' => $self->crednum },
+    'addl_from' => 'LEFT JOIN cust_refund USING (refundnum)',
+    'extra_sql' => "AND cust_refund.closed IS NULL AND cust_refund.source_paynum IS NULL",
+  });
+}
+
+=item unapply_refund
+
+Deletes all objects returned by L</refund_to_unapply>.
+
+=cut
+
+sub unapply_refund {
+  my $self = shift;
+
+  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;
+
+  foreach my $cust_credit_refund ($self->refund_to_unapply) {
+    my $error = $cust_credit_refund->delete;
+    if ($error) {
+      dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  dbh->commit or die dbh->errstr if $oldAutoCommit;
+  return '';
+}
+
 =back
 
 =head1 SUBROUTINES
 =back
 
 =head1 SUBROUTINES
@@ -1012,12 +1168,47 @@ use FS::cust_credit_bill;
 sub process_batch_import {
   my $job = shift;
 
 sub process_batch_import {
   my $job = shift;
 
+  # some false laziness with FS::cust_pay::process_batch_import
+  my $hashcb = sub {
+    my %hash = @_;
+    my $custnum = $hash{'custnum'};
+    my $agent_custid = $hash{'agent_custid'};
+    # translate agent_custid into regular custnum
+    if ($custnum && $agent_custid) {
+      die "can't specify both custnum and agent_custid\n";
+    } elsif ($agent_custid) {
+      # here is the agent virtualization
+      my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
+      my %search;
+      $search{'agent_custid'} = $agent_custid
+        if $agent_custid;
+      $search{'custnum'} = $custnum
+        if $custnum;
+      my $cust_main = qsearchs({
+        'table'     => 'cust_main',
+        'hashref'   => \%search,
+        'extra_sql' => $extra_sql,
+      });
+      die "can't find customer with" .
+        ($custnum  ? " custnum $custnum" : '') .
+        ($agent_custid ? " agent_custid $agent_custid" : '') . "\n"
+        unless $cust_main;
+      die "mismatched customer number\n"
+        if $custnum && ($custnum ne $cust_main->custnum);
+      $custnum = $cust_main->custnum;
+    }
+    $hash{'custnum'} = $custnum;
+    delete($hash{'agent_custid'});
+    return %hash;
+  };
+
   my $opt = { 'table'   => 'cust_credit',
               'params'  => [ '_date', 'credbatch' ],
               'formats' => { 'simple' =>
   my $opt = { 'table'   => 'cust_credit',
               'params'  => [ '_date', 'credbatch' ],
               'formats' => { 'simple' =>
-                               [ 'custnum', 'amount', 'reasonnum', 'invnum' ],
+                               [ 'custnum', 'amount', 'reasonnum', 'invnum', 'agent_custid' ],
                            },
               'default_csv' => 1,
                            },
               'default_csv' => 1,
+              'format_hash_callbacks' => { 'simple' => $hashcb },
               'postinsert_callback' => sub {
                 my $cust_credit = shift; #my ($cust_credit, $param ) = @_;
 
               'postinsert_callback' => sub {
                 my $cust_credit = shift; #my ($cust_credit, $param ) = @_;