check for need for postal fee before charging the customer, not after
[freeside.git] / FS / FS / cust_main.pm
index 2bad5ec..1bbf92d 100644 (file)
@@ -23,12 +23,14 @@ use FS::UID qw( getotaker dbh driver_name );
 use FS::Record qw( qsearchs qsearch dbdef );
 use FS::Misc qw( generate_email send_email generate_ps do_print );
 use FS::Msgcat qw(gettext);
+use FS::payby;
 use FS::cust_pkg;
 use FS::cust_svc;
 use FS::cust_bill;
 use FS::cust_bill_pkg;
 use FS::cust_bill_pkg_display;
 use FS::cust_bill_pkg_tax_location;
+use FS::cust_bill_pkg_tax_rate_location;
 use FS::cust_pay;
 use FS::cust_pay_pending;
 use FS::cust_pay_void;
@@ -39,6 +41,7 @@ use FS::part_referral;
 use FS::cust_main_county;
 use FS::cust_location;
 use FS::tax_rate;
+use FS::tax_rate_location;
 use FS::cust_tax_location;
 use FS::part_pkg_taxrate;
 use FS::agent;
@@ -75,6 +78,8 @@ $skip_fuzzyfiles = 0;
 $ignore_expired_card = 0;
 
 @encrypted_fields = ('payinfo', 'paycvv');
+sub nohistory_fields { ('paycvv'); }
+
 @paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
 
 #ask FS::UID to run this stuff for us later
@@ -392,7 +397,7 @@ sub insert {
   my $dbh = dbh;
 
   my $prepay_identifier = '';
-  my( $amount, $seconds ) = ( 0, 0 );
+  my( $amount, $seconds, $upbytes, $downbytes, $totalbytes ) = (0, 0, 0, 0, 0);
   my $payby = '';
   if ( $self->payby eq 'PREPAY' ) {
 
@@ -403,7 +408,13 @@ sub insert {
     warn "  looking up prepaid card $prepay_identifier\n"
       if $DEBUG > 1;
 
-    my $error = $self->get_prepay($prepay_identifier, \$amount, \$seconds);
+    my $error = $self->get_prepay( $prepay_identifier,
+                                   'amount_ref'     => \$amount,
+                                   'seconds_ref'    => \$seconds,
+                                   'upbytes_ref'    => \$upbytes,
+                                   'downbytes_ref'  => \$downbytes,
+                                   'totalbytes_ref' => \$totalbytes,
+                                 );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       #return "error applying prepaid card (transaction rolled back): $error";
@@ -465,7 +476,13 @@ sub insert {
   warn "  ordering packages\n"
     if $DEBUG > 1;
 
-  $error = $self->order_pkgs($cust_pkgs, \$seconds, %options);
+  $error = $self->order_pkgs( $cust_pkgs,
+                              %options,
+                              'seconds_ref'    => \$seconds,
+                              'upbytes_ref'    => \$upbytes,
+                              'downbytes_ref'  => \$downbytes,
+                              'totalbytes_ref' => \$totalbytes,
+                            );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -475,6 +492,10 @@ sub insert {
     $dbh->rollback if $oldAutoCommit;
     return "No svc_acct record to apply pre-paid time";
   }
+  if ( $upbytes || $downbytes || $totalbytes ) {
+    $dbh->rollback if $oldAutoCommit;
+    return "No svc_acct record to apply pre-paid data";
+  }
 
   if ( $amount ) {
     warn "  inserting initial $payby payment of $amount\n"
@@ -688,6 +709,14 @@ jobs will have a dependancy on the supplied job (they will not run until the
 specific job completes).  This can be used to defer provisioning until some
 action completes (such as running the customer's credit card successfully).
 
+=item ticket_subject
+
+Optional subject for a ticket created and attached to this customer
+
+=item ticket_subject
+
+Optional queue name for ticket additions
+
 =back
 
 =cut
@@ -701,13 +730,15 @@ sub order_pkg {
     if $DEBUG;
 
   my $cust_pkg = $opt->{'cust_pkg'};
-  my $seconds  = $opt->{'seconds'};
   my $svcs     = $opt->{'svcs'} || [];
 
   my %svc_options = ();
   $svc_options{'depend_jobnum'} = $opt->{'depend_jobnum'}
     if exists($opt->{'depend_jobnum'}) && $opt->{'depend_jobnum'};
 
+  my %insert_params = map { $opt->{$_} ? ( $_ => $opt->{$_} ) : () }
+                          qw( ticket_subject ticket_queue );
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -731,7 +762,7 @@ sub order_pkg {
 
   $cust_pkg->custnum( $self->custnum );
 
-  my $error = $cust_pkg->insert;
+  my $error = $cust_pkg->insert( %insert_params );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return "inserting cust_pkg (transaction rolled back): $error";
@@ -745,9 +776,12 @@ sub order_pkg {
       $error = $new_cust_svc->replace($old_cust_svc);
     } else {
       $svc_something->pkgnum( $cust_pkg->pkgnum );
-      if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) {
-        $svc_something->seconds( $svc_something->seconds + $$seconds );
-        $$seconds = 0;
+      if ( $svc_something->isa('FS::svc_acct') ) {
+        foreach ( grep { $opt->{$_.'_ref'} && ${ $opt->{$_.'_ref'} } }
+                       qw( seconds upbytes downbytes totalbytes )      ) {
+          $svc_something->$_( $svc_something->$_() + ${ $opt->{$_.'_ref'} } );
+          ${ $opt->{$_.'_ref'} } = 0;
+        }
       }
       $error = $svc_something->insert(%svc_options);
     }
@@ -762,7 +796,8 @@ sub order_pkg {
 
 }
 
-=item order_pkgs HASHREF, [ SECONDSREF, [ , OPTION => VALUE ... ] ]
+#deprecated #=item order_pkgs HASHREF [ , SECONDSREF ] [ , OPTION => VALUE ... ]
+=item order_pkgs HASHREF [ , OPTION => VALUE ... ]
 
 Like the insert method on an existing record, this method orders multiple
 packages and included services atomicaly.  Pass a Tie::RefHash data structure
@@ -776,12 +811,13 @@ example:
     $cust_pkg => [ $svc_acct ],
     ...
   );
-  $cust_main->order_pkgs( \%hash, \'0', 'noexport'=>1 );
+  $cust_main->order_pkgs( \%hash, 'noexport'=>1 );
 
 Services can be new, in which case they are inserted, or existing unaudited
 services, in which case they are linked to the newly-created package.
 
-Currently available options are: I<depend_jobnum> and I<noexport>.
+Currently available options are: I<depend_jobnum>, I<noexport>, I<seconds_ref>,
+I<upbytes_ref>, I<downbytes_ref>, and I<totalbytes_ref>.
 
 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
 on the supplied jobnum (they will not run until the specific job completes).
@@ -794,13 +830,18 @@ the B<reexport> method for each cust_pkg object.  Using the B<reexport> method
 on the cust_main object is not recommended, as existing services will also be
 reexported.)
 
+If I<seconds_ref>, I<upbytes_ref>, I<downbytes_ref>, or I<totalbytes_ref> is
+provided, the scalars (provided by references) will be incremented by the
+values of the prepaid card.`
+
 =cut
 
 sub order_pkgs {
   my $self = shift;
   my $cust_pkgs = shift;
-  my $seconds = shift;
+  my $seconds_ref = ref($_[0]) ? shift : ''; #deprecated
   my %options = @_;
+  $seconds_ref ||= $options{'seconds_ref'};
 
   warn "$me order_pkgs called with options ".
        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
@@ -821,11 +862,14 @@ sub order_pkgs {
 
   foreach my $cust_pkg ( keys %$cust_pkgs ) {
 
-    my $error = $self->order_pkg( 'cust_pkg'      => $cust_pkg,
-                                  'svcs'          => $cust_pkgs->{$cust_pkg},
-                                  'seconds'       => $seconds,
-                                  'depend_jobnum' => $options{'depend_jobnum'},
-                                );
+    my $error = $self->order_pkg(
+      'cust_pkg'     => $cust_pkg,
+      'svcs'         => $cust_pkgs->{$cust_pkg},
+      'seconds_ref'  => $seconds_ref,
+      map { $_ => $options{$_} } qw( upbytes_ref downbytes_ref totalbytes_ref
+                                     depend_jobnum
+                                   )
+    );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
@@ -844,13 +888,14 @@ L<FS::prepay_credit>), specified either by I<identifier> or as an
 FS::prepay_credit object.  If there is an error, returns the error, otherwise
 returns false.
 
-Optionally, four scalar references can be passed as well.  They will have their
-values filled in with the amount, number of seconds, and number of upload and
-download bytes applied by this prepaid
-card.
+Optionally, five scalar references can be passed as well.  They will have their
+values filled in with the amount, number of seconds, and number of upload,
+download, and total bytes applied by this prepaid card.
 
 =cut
 
+#the ref bullshit here should be refactored like get_prepay.  MyAccount.pm is
+#the only place that uses these args
 sub recharge_prepay { 
   my( $self, $prepay_credit, $amountref, $secondsref, 
       $upbytesref, $downbytesref, $totalbytesref ) = @_;
@@ -868,8 +913,13 @@ sub recharge_prepay {
 
   my( $amount, $seconds, $upbytes, $downbytes, $totalbytes) = ( 0, 0, 0, 0, 0 );
 
-  my $error = $self->get_prepay($prepay_credit, \$amount,
-                                \$seconds, \$upbytes, \$downbytes, \$totalbytes)
+  my $error = $self->get_prepay( $prepay_credit,
+                                 'amount_ref'     => \$amount,
+                                 'seconds_ref'    => \$seconds,
+                                 'upbytes_ref'    => \$upbytes,
+                                 'downbytes_ref'  => \$downbytes,
+                                 'totalbytes_ref' => \$totalbytes,
+                               )
            || $self->increment_seconds($seconds)
            || $self->increment_upbytes($upbytes)
            || $self->increment_downbytes($downbytes)
@@ -896,13 +946,13 @@ sub recharge_prepay {
 
 }
 
-=item get_prepay IDENTIFIER | PREPAY_CREDIT_OBJ , AMOUNTREF, SECONDSREF
+=item get_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , OPTION => VALUE ... ]
 
 Looks up and deletes a prepaid card (see L<FS::prepay_credit>),
 specified either by I<identifier> or as an FS::prepay_credit object.
 
-References to I<amount> and I<seconds> scalars should be passed as arguments
-and will be incremented by the values of the prepaid card.
+Available options are: I<amount_ref>, I<seconds_ref>, I<upbytes_ref>, I<downbytes_ref>, and I<totalbytes_ref>.  The scalars (provided by references) will be
+incremented by the values of the prepaid card.
 
 If the prepaid card specifies an I<agentnum> (see L<FS::agent>), it is used to
 check or set this customer's I<agentnum>.
@@ -913,8 +963,7 @@ If there is an error, returns the error, otherwise returns false.
 
 
 sub get_prepay {
-  my( $self, $prepay_credit, $amountref, $secondsref,
-      $upref, $downref, $totalref) = @_;
+  my( $self, $prepay_credit, %opt ) = @_;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -959,11 +1008,8 @@ sub get_prepay {
     return "removing prepay_credit (transaction rolled back): $error";
   }
 
-  $$amountref  += $prepay_credit->amount;
-  $$secondsref += $prepay_credit->seconds;
-  $$upref      += $prepay_credit->upbytes;
-  $$downref    += $prepay_credit->downbytes;
-  $$totalref   += $prepay_credit->totalbytes;
+  ${ $opt{$_.'_ref'} } += $prepay_credit->$_()
+    for grep $opt{$_.'_ref'}, qw( amount seconds upbytes downbytes totalbytes );
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
@@ -1741,7 +1787,7 @@ sub check {
     $self->payname($1);
   }
 
-  foreach my $flag (qw( tax spool_cdr squelch_cdr )) {
+  foreach my $flag (qw( tax spool_cdr squelch_cdr archived )) {
     $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
     $self->$flag($1);
   }
@@ -1778,7 +1824,7 @@ sub has_ship_address {
   scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
 }
 
-=item all_pkgs
+=item all_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 Returns all packages (see L<FS::cust_pkg>) for this customer.
 
@@ -1786,14 +1832,15 @@ Returns all packages (see L<FS::cust_pkg>) for this customer.
 
 sub all_pkgs {
   my $self = shift;
+  my $extra_qsearch = ref($_[0]) ? shift : {};
 
-  return $self->num_pkgs unless wantarray;
+  return $self->num_pkgs unless wantarray || keys(%$extra_qsearch);
 
   my @cust_pkg = ();
   if ( $self->{'_pkgnum'} ) {
     @cust_pkg = values %{ $self->{'_pkgnum'}->cache };
   } else {
-    @cust_pkg = qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
+    @cust_pkg = $self->_cust_pkg($extra_qsearch);
   }
 
   sort sort_packages @cust_pkg;
@@ -1820,7 +1867,7 @@ sub cust_location {
   qsearch('cust_location', { 'custnum' => $self->custnum } );
 }
 
-=item ncancelled_pkgs
+=item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
 
@@ -1828,6 +1875,7 @@ Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
 
 sub ncancelled_pkgs {
   my $self = shift;
+  my $extra_qsearch = ref($_[0]) ? shift : {};
 
   return $self->num_ncancelled_pkgs unless wantarray;
 
@@ -1846,33 +1894,56 @@ sub ncancelled_pkgs {
          $self->custnum. "\n"
       if $DEBUG > 1;
 
-    @cust_pkg =
-      qsearch( 'cust_pkg', {
-                             'custnum' => $self->custnum,
-                             'cancel'  => '',
-                           });
-    push @cust_pkg,
-      qsearch( 'cust_pkg', {
-                             'custnum' => $self->custnum,
-                             'cancel'  => 0,
-                           });
+    $extra_qsearch->{'extra_sql'} .= ' AND ( cancel IS NULL OR cancel = 0 ) ';
+
+    @cust_pkg = $self->_cust_pkg($extra_qsearch);
+
   }
 
   sort sort_packages @cust_pkg;
 
 }
 
+sub _cust_pkg {
+  my $self = shift;
+  my $extra_qsearch = ref($_[0]) ? shift : {};
+
+  $extra_qsearch->{'select'} ||= '*';
+  $extra_qsearch->{'select'} .=
+   ',( SELECT COUNT(*) FROM cust_svc WHERE cust_pkg.pkgnum = cust_svc.pkgnum )
+     AS _num_cust_svc';
+
+  map {
+        $_->{'_num_cust_svc'} = $_->get('_num_cust_svc');
+        $_;
+      }
+  qsearch({
+    %$extra_qsearch,
+    'table'   => 'cust_pkg',
+    'hashref' => { 'custnum' => $self->custnum },
+  });
+
+}
+
 # This should be generalized to use config options to determine order.
 sub sort_packages {
-  if ( $a->get('cancel') and $b->get('cancel') ) {
-    $a->pkgnum <=> $b->pkgnum;
-  } elsif ( $a->get('cancel') or $b->get('cancel') ) {
+  
+  if ( $a->get('cancel') xor $b->get('cancel') ) {
     return -1 if $b->get('cancel');
     return  1 if $a->get('cancel');
+    #shouldn't get here...
     return 0;
   } else {
-    $a->pkgnum <=> $b->pkgnum;
+    my $a_num_cust_svc = $a->num_cust_svc;
+    my $b_num_cust_svc = $b->num_cust_svc;
+    return 0  if !$a_num_cust_svc && !$b_num_cust_svc;
+    return -1 if  $a_num_cust_svc && !$b_num_cust_svc;
+    return 1  if !$a_num_cust_svc &&  $b_num_cust_svc;
+    my @a_cust_svc = $a->cust_svc;
+    my @b_cust_svc = $b->cust_svc;
+    $a_cust_svc[0]->svc_x->label cmp $b_cust_svc[0]->svc_x->label;
   }
+
 }
 
 =item suspended_pkgs
@@ -2167,18 +2238,36 @@ Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in opt
 sub bill_and_collect {
   my( $self, %options ) = @_;
 
-  ###
-  # cancel packages
-  ###
-
   #$options{actual_time} not $options{time} because freeside-daily -d is for
   #pre-printing invoices
-  my @cancel_pkgs = grep { $_->expire && $_->expire <= $options{actual_time} }
+  $self->cancel_expired_pkgs( $options{actual_time} );
+  $self->suspend_adjourned_pkgs( $options{actual_time} );
+
+  my $error = $self->bill( %options );
+  warn "Error billing, custnum ". $self->custnum. ": $error" if $error;
+
+  $self->apply_payments_and_credits;
+
+  unless ( $conf->exists('cancelled_cust-noevents')
+           && ! $self->num_ncancelled_pkgs
+  ) {
+
+    $error = $self->collect( %options );
+    warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
+
+  }
+
+}
+
+sub cancel_expired_pkgs {
+  my ( $self, $time ) = @_;
+
+  my @cancel_pkgs = grep { $_->expire && $_->expire <= $time }
                          $self->ncancelled_pkgs;
 
   foreach my $cust_pkg ( @cancel_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
-    my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum,
+    my $error = $cust_pkg->cancel($cpr ? ( 'reason'        => $cpr->reasonnum,
                                            'reason_otaker' => $cpr->otaker
                                          )
                                        : ()
@@ -2188,20 +2277,19 @@ sub bill_and_collect {
       if $error;
   }
 
-  ###
-  # suspend packages
-  ###
+}
+
+sub suspend_adjourned_pkgs {
+  my ( $self, $time ) = @_;
 
-  #$options{actual_time} not $options{time} because freeside-daily -d is for
-  #pre-printing invoices
   my @susp_pkgs = 
     grep { ! $_->susp
            && (    (    $_->part_pkg->is_prepaid
                      && $_->bill
-                     && $_->bill < $options{actual_time}
+                     && $_->bill < $time
                    )
                 || (    $_->adjourn
-                    && $_->adjourn <= $options{actual_time}
+                    && $_->adjourn <= $time
                   )
               )
          }
@@ -2221,18 +2309,6 @@ sub bill_and_collect {
       if $error;
   }
 
-  ###
-  # bill and collect
-  ###
-
-  my $error = $self->bill( %options );
-  warn "Error billing, custnum ". $self->custnum. ": $error" if $error;
-
-  $self->apply_payments_and_credits;
-
-  $error = $self->collect( %options );
-  warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
-
 }
 
 =item bill OPTIONS
@@ -2353,18 +2429,18 @@ sub bill {
     return '';
   }
 
-  my $postal_pkg = $self->charge_postal_fee();
-  if ( $postal_pkg && !ref( $postal_pkg ) ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "can't charge postal invoice fee for customer ".
-      $self->custnum. ": $postal_pkg";
-  }
-  if ( $postal_pkg &&
-       ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
+  if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
          !$conf->exists('postal_invoice-recurring_only')
-       )
      )
   {
+
+    my $postal_pkg = $self->charge_postal_fee();
+    if ( $postal_pkg && !ref( $postal_pkg ) ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't charge postal invoice fee for customer ".
+        $self->custnum. ": $postal_pkg";
+    }
+
     foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
       my $error =
         $self->_make_lines( 'part_pkg'            => $part_pkg,
@@ -2398,9 +2474,14 @@ sub bill {
   # values are listrefs of cust_bill_pkg_tax_location hashrefs
   my %tax_location = ();
 
+  # keys are taxlisthash keys (internal identifiers)
+  # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs
+  my %tax_rate_location = ();
+
   foreach my $tax ( keys %taxlisthash ) {
     my $tax_object = shift @{ $taxlisthash{$tax} };
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
+    warn " ". join('/', @{ $taxlisthash{$tax} } ). "\n" if $DEBUG > 2;
     my $hashref_or_error =
       $tax_object->taxline( $taxlisthash{$tax},
                             'custnum'      => $self->custnum,
@@ -2433,72 +2514,40 @@ sub bill {
         };
     }
 
+    $tax_rate_location{ $tax } ||= [];
+    if ( ref($tax_object) eq 'FS::tax_rate' ) {
+      my $taxratelocationnum =
+        $tax_object->tax_rate_location->taxratelocationnum;
+      push @{ $tax_rate_location{ $tax }  },
+        {
+          'taxnum'             => $tax_object->taxnum, 
+          'taxtype'            => ref($tax_object),
+          'amount'             => sprintf('%.2f', $amount ),
+          'locationtaxid'      => $tax_object->location,
+          'taxratelocationnum' => $taxratelocationnum,
+        };
+    }
+
   }
 
   #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
   my %packagemap = map { $_->pkgnum => $_ } @cust_bill_pkg;
   foreach my $tax ( keys %taxlisthash ) {
     foreach ( @{ $taxlisthash{$tax} }[1 ... scalar(@{ $taxlisthash{$tax} })] ) {
-      next unless ref($_) eq 'FS::cust_bill_pkg'; # shouldn't happen
+      next unless ref($_) eq 'FS::cust_bill_pkg';
 
       push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, 
         splice( @{ $_->_cust_tax_exempt_pkg } );
     }
   }
 
-  #some taxes are taxed
-  my %totlisthash;
-  
-  warn "finding taxed taxes...\n" if $DEBUG > 2;
-  foreach my $tax ( keys %taxlisthash ) {
-    my $tax_object = shift @{ $taxlisthash{$tax} };
-    warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
-      if $DEBUG > 2;
-    next unless $tax_object->can('tax_on_tax');
-
-    foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
-      my $totname = ref( $tot ). ' '. $tot->taxnum;
-
-      warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
-        if $DEBUG > 2;
-      next unless exists( $taxlisthash{ $totname } ); # only increase
-                                                      # existing taxes
-      warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
-      if ( exists( $totlisthash{ $totname } ) ) {
-        push @{ $totlisthash{ $totname  } }, $tax{ $tax };
-      }else{
-        $totlisthash{ $totname } = [ $tot, $tax{ $tax } ];
-      }
-    }
-  }
-
-  warn "having a look at taxed taxes...\n" if $DEBUG > 2;
-  foreach my $tax ( keys %totlisthash ) {
-    my $tax_object = shift @{ $totlisthash{$tax} };
-    warn "found previously found taxed tax ". $tax_object->taxname. "\n"
-      if $DEBUG > 2;
-    my $listref_or_error =
-      $tax_object->taxline( $totlisthash{$tax},
-                            'custnum'      => $self->custnum,
-                            'invoice_time' => $invoice_time
-                          );
-    unless (ref($listref_or_error)) {
-      $dbh->rollback if $oldAutoCommit;
-      return $listref_or_error;
-    }
-
-    warn "adding taxed tax amount ". $listref_or_error->[1].
-         " as ". $tax_object->taxname. "\n"
-      if $DEBUG;
-    $tax{ $tax } += $listref_or_error->[1];
-  }
-  
   #consolidate and create tax line items
   warn "consolidating and generating...\n" if $DEBUG > 2;
   foreach my $taxname ( keys %taxname ) {
     my $tax = 0;
     my %seen = ();
     my @cust_bill_pkg_tax_location = ();
+    my @cust_bill_pkg_tax_rate_location = ();
     warn "adding $taxname\n" if $DEBUG > 1;
     foreach my $taxitem ( @{ $taxname{$taxname} } ) {
       next if $seen{$taxitem}++;
@@ -2507,6 +2556,9 @@ sub bill {
       push @cust_bill_pkg_tax_location,
         map { new FS::cust_bill_pkg_tax_location $_ }
             @{ $tax_location{ $taxitem } };
+      push @cust_bill_pkg_tax_rate_location,
+        map { new FS::cust_bill_pkg_tax_rate_location $_ }
+            @{ $tax_rate_location{ $taxitem } };
     }
     next unless $tax;
 
@@ -2521,6 +2573,7 @@ sub bill {
       'edate'    => '',
       'itemdesc' => $taxname,
       'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
+      'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
     };
 
   }
@@ -2575,7 +2628,7 @@ sub _make_lines {
   my $total_recur = $params{recur} or die "no recur accumulator specified";
   my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified";
   my $time = $params{'time'} or die "no time specified";
-  my (%options) = %{$params{options}};  #hmmm  only for 'resetup'
+  my (%options) = %{$params{options}};
 
   my $dbh = dbh;
   my $real_pkgpart = $cust_pkg->pkgpart;
@@ -2754,7 +2807,7 @@ sub _make_lines {
       ###
 
       my $error = 
-        $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg);
+        $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time});
       return $error if $error;
 
       push @$cust_bill_pkgs, $cust_bill_pkg;
@@ -2773,6 +2826,7 @@ sub _handle_taxes {
   my $taxlisthash = shift;
   my $cust_bill_pkg = shift;
   my $cust_pkg = shift;
+  my $invoice_time = shift;
 
   my %cust_bill_pkg = ();
   my %taxes = ();
@@ -2881,19 +2935,51 @@ sub _handle_taxes {
     my @taxes = @{ $taxes{$key} || [] };
     my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
 
+    my %localtaxlisthash = ();
     foreach my $tax ( @taxes ) {
 
-      my $taxname = ref( $tax ). ' taxnum'. $tax->taxnum;
+      my $taxname = ref( $tax ). ' '. $tax->taxnum;
 #      $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
 #                  ' locationnum'. $cust_pkg->locationnum
 #        if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
 
-      if ( exists( $taxlisthash->{ $taxname } ) ) {
-        push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
-      }else{
-        $taxlisthash->{ $taxname } = [ $tax, $tax_cust_bill_pkg ];
+      $taxlisthash->{ $taxname } ||= [ $tax ];
+      push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
+
+      $localtaxlisthash{ $taxname } ||= [ $tax ];
+      push @{ $localtaxlisthash{ $taxname  } }, $tax_cust_bill_pkg;
+
+    }
+
+    warn "finding taxed taxes...\n" if $DEBUG > 2;
+    foreach my $tax ( keys %localtaxlisthash ) {
+      my $tax_object = shift @{ $localtaxlisthash{$tax} };
+      warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
+        if $DEBUG > 2;
+      next unless $tax_object->can('tax_on_tax');
+
+      foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
+        my $totname = ref( $tot ). ' '. $tot->taxnum;
+
+        warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
+          if $DEBUG > 2;
+        next unless exists( $localtaxlisthash{ $totname } ); # only increase
+                                                             # existing taxes
+        warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
+        my $hashref_or_error = 
+          $tax_object->taxline( $localtaxlisthash{$tax},
+                                'custnum'      => $self->custnum,
+                                'invoice_time' => $invoice_time,
+                              );
+        return $hashref_or_error
+          unless ref($hashref_or_error);
+        
+        $taxlisthash->{ $totname } ||= [ $tot ];
+        push @{ $taxlisthash->{ $totname  } }, $hashref_or_error->{amount};
+
       }
     }
+
   }
 
   '';
@@ -3444,24 +3530,35 @@ sub realtime_bop {
   return "Banned credit card" if $ban;
 
   ###
-  # select a gateway
+  # set taxclass and trans_is_recur based on invnum if there is one
   ###
 
   my $taxclass = '';
+  my $trans_is_recur = 0;
   if ( $options{'invnum'} ) {
+
     my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
     die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
-    my @taxclasses =
-      map  { $_->part_pkg->taxclass }
+
+    my @part_pkg =
+      map  { $_->part_pkg }
       grep { $_ }
       map  { $_->cust_pkg }
       $cust_bill->cust_bill_pkg;
-    unless ( grep { $taxclasses[0] ne $_ } @taxclasses ) { #unless there are
-                                                           #different taxclasses
-      $taxclass = $taxclasses[0];
-    }
+
+    my @taxclasses = map $_->taxclass, @part_pkg;
+    $taxclass = $taxclasses[0]
+      unless grep { $taxclasses[0] ne $_ } @taxclasses; #unless there are
+                                                        #different taxclasses
+    $trans_is_recur = 1
+      if grep { $_->freq ne '0' } @part_pkg;
+
   }
 
+  ###
+  # select a gateway
+  ###
+
   #look for an agent gateway override first
   my $cardtype;
   if ( $method eq 'CC' ) {
@@ -3589,16 +3686,15 @@ sub realtime_bop {
                            : $self->payissue;
     $content{issue_number} = $payissue if $payissue;
 
-    $content{recurring_billing} = 'YES'
-      if qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'payinfo' => $payinfo,
-                             } )
-      || qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'paymask' => $self->mask_payinfo('CARD', $payinfo),
-                             } );
-
+    if ( $self->_bop_recurring_billing( 'payinfo'        => $payinfo,
+                                        'trans_is_recur' => $trans_is_recur,
+                                      )
+       )
+    {
+      $content{recurring_billing} = 'YES';
+      $content{acct_code} = 'rebill'
+        if $conf->exists('credit_card-recurring_billing_acct_code');
+    }
 
   } elsif ( $method eq 'ECHECK' ) {
     ( $content{account_number}, $content{routing_code} ) =
@@ -3657,15 +3753,16 @@ sub realtime_bop {
   #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
 
   my $cust_pay_pending = new FS::cust_pay_pending {
-    'custnum'    => $self->custnum,
-    #'invnum'     => $options{'invnum'},
-    'paid'       => $amount,
-    '_date'      => '',
-    'payby'      => $method2payby{$method},
-    'payinfo'    => $payinfo,
-    'paydate'    => $paydate,
-    'status'     => 'new',
-    'gatewaynum' => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
+    'custnum'           => $self->custnum,
+    #'invnum'            => $options{'invnum'},
+    'paid'              => $amount,
+    '_date'             => '',
+    'payby'             => $method2payby{$method},
+    'payinfo'           => $payinfo,
+    'paydate'           => $paydate,
+    'recurring_billing' => $content{recurring_billing},
+    'status'            => 'new',
+    'gatewaynum'        => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
   };
   $cust_pay_pending->payunique( $options{payunique} )
     if defined($options{payunique}) && length($options{payunique});
@@ -3951,6 +4048,34 @@ sub realtime_bop {
 
 }
 
+sub _bop_recurring_billing {
+  my( $self, %opt ) = @_;
+
+  my $method = $conf->config('credit_card-recurring_billing_flag');
+
+  if ( $method eq 'transaction_is_recur' ) {
+
+    return 1 if $opt{'trans_is_recur'};
+
+  } else {
+
+    my %hash = ( 'custnum' => $self->custnum,
+                 'payby'   => 'CARD',
+               );
+
+    return 1 
+      if qsearch('cust_pay', { %hash, 'payinfo' => $opt{'payinfo'} } )
+      || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo('CARD',
+                                                               $opt{'payinfo'} )
+                             } );
+
+  }
+
+  return 0;
+
+}
+
+
 =item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
 
 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
@@ -4503,6 +4628,27 @@ sub _new_realtime_bop {
   $self->_bop_defaults(\%options);
 
   ###
+  # set trans_is_recur based on invnum if there is one
+  ###
+
+  my $trans_is_recur = 0;
+  if ( $options{'invnum'} ) {
+
+    my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
+    die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
+
+    my @part_pkg =
+      map  { $_->part_pkg }
+      grep { $_ }
+      map  { $_->cust_pkg }
+      $cust_bill->cust_bill_pkg;
+
+    $trans_is_recur = 1
+      if grep { $_->freq ne '0' } @part_pkg;
+
+  }
+
+  ###
   # select a gateway
   ###
 
@@ -4578,16 +4724,15 @@ sub _new_realtime_bop {
                            : $self->payissue;
     $content{issue_number} = $payissue if $payissue;
 
-    $content{recurring_billing} = 'YES'
-      if qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'payinfo' => $options{payinfo},
-                             } )
-      || qsearch('cust_pay', { 'custnum' => $self->custnum,
-                               'payby'   => 'CARD',
-                               'paymask' => $self->mask_payinfo('CARD', $options{payinfo}),
-                             } );
-
+    if ( $self->_bop_recurring_billing( 'payinfo'        => $options{'payinfo'},
+                                        'trans_is_recur' => $trans_is_recur,
+                                      )
+       )
+    {
+      $content{recurring_billing} = 'YES';
+      $content{acct_code} = 'rebill'
+        if $conf->exists('credit_card-recurring_billing_acct_code');
+    }
 
   } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'ECHECK' ){
     ( $content{account_number}, $content{routing_code} ) =
@@ -4649,17 +4794,18 @@ sub _new_realtime_bop {
   #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
 
   my $cust_pay_pending = new FS::cust_pay_pending {
-    'custnum'    => $self->custnum,
-    #'invnum'     => $options{'invnum'},
-    'paid'       => $options{amount},
-    '_date'      => '',
-    'payby'      => $bop_method2payby{$options{method}},
-    'payinfo'    => $options{payinfo},
-    'paydate'    => $paydate,
-    'status'     => 'new',
-    'gatewaynum' => $payment_gateway->gatewaynum || '',
-    'session_id' => $options{session_id} || '',
-    'jobnum'     => $options{depend_jobnum} || '',
+    'custnum'           => $self->custnum,
+    #'invnum'            => $options{'invnum'},
+    'paid'              => $options{amount},
+    '_date'             => '',
+    'payby'             => $bop_method2payby{$options{method}},
+    'payinfo'           => $options{payinfo},
+    'paydate'           => $paydate,
+    'recurring_billing' => $content{recurring_billing},
+    'status'            => 'new',
+    'gatewaynum'        => $payment_gateway->gatewaynum || '',
+    'session_id'        => $options{session_id} || '',
+    'jobnum'            => $options{depend_jobnum} || '',
   };
   $cust_pay_pending->payunique( $options{payunique} )
     if defined($options{payunique}) && length($options{payunique});
@@ -6032,6 +6178,86 @@ sub in_transit_payments {
   sprintf( "%.2f", $in_transit_payments );
 }
 
+=item payment_info
+
+Returns a hash of useful information for making a payment.
+
+=over 4
+
+=item balance
+
+Current balance.
+
+=item payby
+
+'CARD' (credit card - automatic), 'DCRD' (credit card - on-demand),
+'CHEK' (electronic check - automatic), 'DCHK' (electronic check - on-demand),
+'LECB' (Phone bill billing), 'BILL' (billing), or 'COMP' (free).
+
+=back
+
+For credit card transactions:
+
+=over 4
+
+=item card_type 1
+
+=item payname
+
+Exact name on card
+
+=back
+
+For electronic check transactions:
+
+=over 4
+
+=item stateid_state
+
+=back
+
+=cut
+
+sub payment_info {
+  my $self = shift;
+
+  my %return = ();
+
+  $return{balance} = $self->balance;
+
+  $return{payname} = $self->payname
+                     || ( $self->first. ' '. $self->get('last') );
+
+  $return{$_} = $self->get($_) for qw(address1 address2 city state zip);
+
+  $return{payby} = $self->payby;
+  $return{stateid_state} = $self->stateid_state;
+
+  if ( $self->payby =~ /^(CARD|DCRD)$/ ) {
+    $return{card_type} = cardtype($self->payinfo);
+    $return{payinfo} = $self->paymask;
+
+    @return{'month', 'year'} = $self->paydate_monthyear;
+
+  }
+
+  if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
+    my ($payinfo1, $payinfo2) = split '@', $self->paymask;
+    $return{payinfo1} = $payinfo1;
+    $return{payinfo2} = $payinfo2;
+    $return{paytype}  = $self->paytype;
+    $return{paystate} = $self->paystate;
+
+  }
+
+  #doubleclick protection
+  my $_date = time;
+  $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32;
+
+  %return;
+
+}
+
 =item paydate_monthyear
 
 Returns a two-element list consisting of the month and year of this customer's
@@ -6530,7 +6756,7 @@ Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
 
 sub cust_pay_batch {
   my $self = shift;
-  sort { $a->_date <=> $b->_date }
+  sort { $a->paybatchnum <=> $b->paybatchnum }
     qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
 }
 
@@ -8293,6 +8519,15 @@ sub queued_bill {
       );
 }
 
+sub _upgrade_data { #class method
+  my ($class, %opts) = @_;
+
+  my $sql = 'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL';
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute or die $sth->errstr;
+
+}
+
 =back
 
 =head1 BUGS