webpay support #4103
[freeside.git] / FS / FS / cust_main.pm
index 25f894a..6b64388 100644 (file)
@@ -9,7 +9,7 @@ use Safe;
 use Carp;
 use Exporter;
 use Scalar::Util qw( blessed );
-use Time::Local qw(timelocal timelocal_nocheck);
+use Time::Local qw(timelocal);
 use Data::Dumper;
 use Tie::IxHash;
 use Digest::MD5 qw(md5_base64);
@@ -28,6 +28,7 @@ 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_pay;
 use FS::cust_pay_pending;
 use FS::cust_pay_void;
@@ -2302,9 +2303,7 @@ sub bill {
   ###
 
   my( $total_setup, $total_recur, $postal_charge ) = ( 0, 0, 0 );
-  my %tax;
   my %taxlisthash;
-  my %taxname;
   my @precommit_hooks = ();
 
   my @cust_pkgs = qsearch('cust_pkg', { 'custnum' => $self->custnum } );
@@ -2386,30 +2385,54 @@ sub bill {
   }
 
   warn "having a look at the taxes we found...\n" if $DEBUG > 2;
+
+  # keys are tax names (as printed on invoices / itemdesc )
+  # values are listrefs of taxlisthash keys (internal identifiers)
+  my %taxname = ();
+
+  # keys are taxlisthash keys (internal identifiers)
+  # values are (cumulative) amounts
+  my %tax = ();
+
+  # keys are taxlisthash keys (internal identifiers)
+  # values are listrefs of cust_bill_pkg_tax_location hashrefs
+  my %tax_location = ();
+
   foreach my $tax ( keys %taxlisthash ) {
     my $tax_object = shift @{ $taxlisthash{$tax} };
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
-    my $listref_or_error =
+    my $hashref_or_error =
       $tax_object->taxline( $taxlisthash{$tax},
                             'custnum'      => $self->custnum,
                             'invoice_time' => $invoice_time
                           );
-    unless (ref($listref_or_error)) {
+    unless ( ref($hashref_or_error) ) {
       $dbh->rollback if $oldAutoCommit;
-      return $listref_or_error;
+      return $hashref_or_error;
     }
     unshift @{ $taxlisthash{$tax} }, $tax_object;
 
-    warn "adding ". $listref_or_error->[1].
-         " as ". $listref_or_error->[0]. "\n"
-      if $DEBUG > 2;
-    $tax{ $tax } += $listref_or_error->[1];
-    if ( $taxname{ $listref_or_error->[0] } ) {
-      push @{ $taxname{ $listref_or_error->[0] } }, $tax;
-    }else{
-      $taxname{ $listref_or_error->[0] } = [ $tax ];
+    my $name   = $hashref_or_error->{'name'};
+    my $amount = $hashref_or_error->{'amount'};
+
+    #warn "adding $amount as $name\n";
+    $taxname{ $name } ||= [];
+    push @{ $taxname{ $name } }, $tax;
+
+    $tax{ $tax } += $amount;
+
+    $tax_location{ $tax } ||= [];
+    if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
+      push @{ $tax_location{ $tax }  },
+        {
+          'taxnum'      => $tax_object->taxnum, 
+          'taxtype'     => ref($tax_object),
+          'pkgnum'      => $tax_object->get('pkgnum'),
+          'locationnum' => $tax_object->get('locationnum'),
+          'amount'      => sprintf('%.2f', $amount ),
+        };
     }
-  
+
   }
 
   #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
@@ -2475,11 +2498,15 @@ sub bill {
   foreach my $taxname ( keys %taxname ) {
     my $tax = 0;
     my %seen = ();
+    my @cust_bill_pkg_tax_location = ();
     warn "adding $taxname\n" if $DEBUG > 1;
     foreach my $taxitem ( @{ $taxname{$taxname} } ) {
-      $tax += $tax{$taxitem} unless $seen{$taxitem};
-      $seen{$taxitem} = 1;
+      next if $seen{$taxitem}++;
       warn "adding $tax{$taxitem}\n" if $DEBUG > 1;
+      $tax += $tax{$taxitem};
+      push @cust_bill_pkg_tax_location,
+        map { new FS::cust_bill_pkg_tax_location $_ }
+            @{ $tax_location{ $taxitem } };
     }
     next unless $tax;
 
@@ -2493,6 +2520,7 @@ sub bill {
       'sdate'    => '',
       'edate'    => '',
       'itemdesc' => $taxname,
+      'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
     };
 
   }
@@ -2634,36 +2662,19 @@ sub _make_lines {
       if ( $@ );
 
     if ( $increment_next_bill ) {
-  
-      #change this bit to use Date::Manip? CAREFUL with timezones (see
-      # mailing list archive)
-      my ($sec,$min,$hour,$mday,$mon,$year) =
-        (localtime($sdate) )[0,1,2,3,4,5];
 
-      #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
+      my $next_bill = $part_pkg->add_freq($sdate);
+      return "unparsable frequency: ". $part_pkg->freq
+        if $next_bill == -1;
+  
+      #pro-rating magic - if $recur_prog fiddled $sdate, want to use that
       # only for figuring next bill date, nothing else, so, reset $sdate again
       # here
       $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
       #no need, its in $hash{last_bill}# my $last_bill = $cust_pkg->last_bill;
       $cust_pkg->last_bill($sdate);
 
-      if ( $part_pkg->freq =~ /^\d+$/ ) {
-        $mon += $part_pkg->freq;
-        until ( $mon < 12 ) { $mon -= 12; $year++; }
-      } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) {
-        my $weeks = $1;
-        $mday += $weeks * 7;
-      } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) {
-        my $days = $1;
-        $mday += $days;
-      } elsif ( $part_pkg->freq =~ /^(\d+)h$/ ) {
-        my $hours = $1;
-        $hour += $hours;
-      } else {
-        return "unparsable frequency: ". $part_pkg->freq;
-      }
-      $cust_pkg->setfield('bill',
-        timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
+      $cust_pkg->setfield('bill', $next_bill );
 
     }
 
@@ -2766,71 +2777,86 @@ sub _handle_taxes {
   my %cust_bill_pkg = ();
   my %taxes = ();
     
-  my $prefix = 
-    ( $conf->exists('tax-ship_address') && length($self->ship_last) )
-    ? 'ship_'
-    : '';
-
   my @classes;
   #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
   push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
   push @classes, 'setup' if $cust_bill_pkg->setup;
   push @classes, 'recur' if $cust_bill_pkg->recur;
 
-  if ( $conf->exists('enable_taxproducts')
-       && (scalar($part_pkg->part_pkg_taxoverride) || $part_pkg->has_taxproduct)
-       && ( $self->tax !~ /Y/i && $self->payby ne 'COMP' )
-     )
-  { 
+  if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
 
-    foreach my $class (@classes) {
-      my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $prefix );
-      return $err_or_ref unless ref($err_or_ref);
-      $taxes{$class} = $err_or_ref;
-    }
+    if ( $conf->exists('enable_taxproducts')
+         && ( scalar($part_pkg->part_pkg_taxoverride)
+              || $part_pkg->has_taxproduct
+            )
+       )
+    {
 
-    unless (exists $taxes{''}) {
-      my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $prefix );
-      return $err_or_ref unless ref($err_or_ref);
-      $taxes{''} = $err_or_ref;
-    }
+      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
+        return "fatal: Can't (yet) use tax-pkg_address with taxproducts";
+      }
 
-  } elsif ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
+      foreach my $class (@classes) {
+        my $err_or_ref = $self->_gather_taxes( $part_pkg, $class );
+        return $err_or_ref unless ref($err_or_ref);
+        $taxes{$class} = $err_or_ref;
+      }
 
-    my %taxhash = map { $_ => $self->get("$prefix$_") }
-                      qw( state county country );
+      unless (exists $taxes{''}) {
+        my $err_or_ref = $self->_gather_taxes( $part_pkg, '' );
+        return $err_or_ref unless ref($err_or_ref);
+        $taxes{''} = $err_or_ref;
+      }
 
-    $taxhash{'taxclass'} = $part_pkg->taxclass;
+    } else {
 
-    my @taxes = qsearch( 'cust_main_county', \%taxhash );
+      my @loc_keys = qw( state county country );
+      my %taxhash;
+      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
+        my $cust_location = $cust_pkg->cust_location;
+        %taxhash = map { $_ => $cust_location->$_()    } @loc_keys;
+      } else {
+        my $prefix = 
+          ( $conf->exists('tax-ship_address') && length($self->ship_last) )
+          ? 'ship_'
+          : '';
+        %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys;
+      }
 
-    unless ( @taxes ) {
-      $taxhash{'taxclass'} = '';
-      @taxes =  qsearch( 'cust_main_county', \%taxhash );
-    }
+      $taxhash{'taxclass'} = $part_pkg->taxclass;
 
-    #one more try at a whole-country tax rate
-    unless ( @taxes ) {
-      $taxhash{$_} = '' foreach qw( state county );
-      @taxes =  qsearch( 'cust_main_county', \%taxhash );
-    }
+      my @taxes = qsearch( 'cust_main_county', \%taxhash );
 
-    $taxes{''} = [ @taxes ];
-    $taxes{'setup'} = [ @taxes ];
-    $taxes{'recur'} = [ @taxes ];
-    $taxes{$_} = [ @taxes ] foreach (@classes);
-
-    # maybe eliminate this entirely, along with all the 0% records
-    unless ( @taxes ) {
-      return
-        "fatal: can't find tax rate for state/county/country/taxclass ".
-        join('/', ( map $self->get("$prefix$_"),
-                        qw(state county country)
-                  ),
-                  $part_pkg->taxclass ). "\n";
-    }
+      my %taxhash_elim = %taxhash;
+
+      my @elim = qw( taxclass county state );
+      while ( !scalar(@taxes) && scalar(@elim) ) {
+        $taxhash_elim{ shift(@elim) } = '';
+        @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
+      }
 
-  } #if $conf->exists('enable_taxproducts') ...
+      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
+        foreach (@taxes) {
+          $_->set('pkgnum',      $cust_pkg->pkgnum );
+          $_->set('locationnum', $cust_pkg->locationnum );
+        }
+      }
+
+      $taxes{''} = [ @taxes ];
+      $taxes{'setup'} = [ @taxes ];
+      $taxes{'recur'} = [ @taxes ];
+      $taxes{$_} = [ @taxes ] foreach (@classes);
+
+      # maybe eliminate this entirely, along with all the 0% records
+      unless ( @taxes ) {
+        return
+          "fatal: can't find tax rate for state/county/country/taxclass ".
+          join('/', map $taxhash{$_}, qw(state county country taxclass) );
+      }
+
+    } #if $conf->exists('enable_taxproducts') ...
+
+  }
  
   my @display = ();
   if ( $conf->exists('separate_usage') ) {
@@ -2856,7 +2882,12 @@ sub _handle_taxes {
     my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
 
     foreach my $tax ( @taxes ) {
-      my $taxname = ref( $tax ). ' '. $tax->taxnum;
+
+      my $taxname = ref( $tax ). ' taxnum'. $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{
@@ -2872,7 +2903,6 @@ sub _gather_taxes {
   my $self = shift;
   my $part_pkg = shift;
   my $class = shift;
-  my $prefix = shift;
 
   my @taxes = ();
   my $geocode = $self->geocode('cch');
@@ -2897,17 +2927,6 @@ sub _gather_taxes {
                   })
     if scalar(@taxclassnums);
 
-  # maybe eliminate this entirely, along with all the 0% records
-  unless ( @taxes ) {
-    return 
-      "fatal: can't find tax rate for zip/taxproduct/pkgpart ".
-      join('/', ( map $self->get("$prefix$_"),
-                      qw(zip)
-                ),
-                $part_pkg->taxproduct_description,
-                $part_pkg->pkgpart ). "\n";
-  }
-
   warn "Found taxes ".
        join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n" 
    if $DEBUG;
@@ -3349,15 +3368,23 @@ sub retry_realtime {
 
 }
 
-=item realtime_bop METHOD AMOUNT [ OPTION => VALUE ... ]
+=item realtime_collect [ OPTION => VALUE ... ]
 
 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
-via a Business::OnlinePayment realtime gateway.  See
-L<http://420.am/business-onlinepayment> for supported gateways.
+via a Business::OnlinePayment or Business::OnlineThirdPartyPayment realtime
+gateway.  See L<http://420.am/business-onlinepayment> and 
+L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
 
-Available methods are: I<CC>, I<ECHECK> and I<LEC>
+On failure returns an error message.
 
-Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
+Returns false or a hashref upon success.  The hashref contains keys popup_url reference, and collectitems.  The first is a URL to which a browser should be redirected for completion of collection.  The second is a reference id for the transaction suitable for the end user.  The collectitems is a reference to a list of name value pairs suitable for assigning to a html form and posted to popup_url.
+
+Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
+
+I<method> is one of: I<CC>, I<ECHECK> and I<LEC>.  If none is specified
+then it is deduced from the customer record.
+
+If no I<amount> is specified, then the customer balance is used.
 
 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
 I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
@@ -3377,130 +3404,209 @@ resulting paynum, if any.
 
 I<payunique> is a unique identifier for this payment.
 
-(moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
+I<session_id> is a session identifier associated with this payment.
+
+I<depend_jobnum> allows payment capture to unlock export jobs
 
 =cut
 
-sub realtime_bop {
-  my( $self, $method, $amount, %options ) = @_;
+sub realtime_collect {
+  my( $self, %options ) = @_;
+
   if ( $DEBUG ) {
-    warn "$me realtime_bop: $method $amount\n";
+    warn "$me realtime_collect:\n";
     warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
-  $options{'description'} ||= 'Internet services';
+  $options{amount} = $self->balance unless exists( $options{amount} );
+  $options{method} = FS::payby->payby2bop($self->payby)
+    unless exists( $options{method} );
 
-  return $self->fake_bop($method, $amount, %options) if $options{'fake'};
+  return $self->realtime_bop({%options});
 
-  eval "use Business::OnlinePayment";  
-  die $@ if $@;
+}
+
+=item realtime_bop { [ ARG => VALUE ... ] }
+
+Runs a realtime credit card, ACH (electronic check) or phone bill transaction
+via a Business::OnlinePayment realtime gateway.  See
+L<http://420.am/business-onlinepayment> for supported gateways.
+
+Required arguments in the hashref are I<method>, and I<amount>
+
+Available methods are: I<CC>, I<ECHECK> and I<LEC>
+
+Available optional arguments are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
+
+The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
+I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
+if set, will override the value from the customer record.
+
+I<description> is a free-text field passed to the gateway.  It defaults to
+"Internet services".
+
+If an I<invnum> is specified, this payment (if successful) is applied to the
+specified invoice.  If you don't specify an I<invnum> you might want to
+call the B<apply_payments> method.
+
+I<quiet> can be set true to surpress email decline notices.
 
-  my $payinfo = exists($options{'payinfo'})
-                  ? $options{'payinfo'}
-                  : $self->payinfo;
+I<paynum_ref> can be set to a scalar reference.  It will be filled in with the
+resulting paynum, if any.
+
+I<payunique> is a unique identifier for this payment.
+
+I<session_id> is a session identifier associated with this payment.
+
+I<depend_jobnum> allows payment capture to unlock export jobs
+
+(moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
+
+=cut
+
+# some helper routines
+sub _payment_gateway {
+  my ($self, $options) = @_;
+
+  $options->{payment_gateway} = $self->agent->payment_gateway( %$options )
+    unless exists($options->{payment_gateway});
+
+  $options->{payment_gateway};
+}
 
-  my %method2payby = (
-    'CC'     => 'CARD',
-    'ECHECK' => 'CHEK',
-    'LEC'    => 'LECB',
+sub _bop_auth {
+  my ($self, $options) = @_;
+
+  (
+    'login'    => $options->{payment_gateway}->gateway_username,
+    'password' => $options->{payment_gateway}->gateway_password,
   );
+}
 
-  ###
-  # check for banned credit card/ACH
-  ###
+sub _bop_options {
+  my ($self, $options) = @_;
 
-  my $ban = qsearchs('banned_pay', {
-    'payby'   => $method2payby{$method},
-    'payinfo' => md5_base64($payinfo),
-  } );
-  return "Banned credit card" if $ban;
+  $options->{payment_gateway}->gatewaynum
+    ? $options->{payment_gateway}->options
+    : @{ $options->{payment_gateway}->get('options') };
+}
 
-  ###
-  # select a gateway
-  ###
+sub _bop_defaults {
+  my ($self, $options) = @_;
 
-  my $taxclass = '';
-  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 }
-      grep { $_ }
-      map  { $_->cust_pkg }
-      $cust_bill->cust_bill_pkg;
-    unless ( grep { $taxclasses[0] ne $_ } @taxclasses ) { #unless there are
-                                                           #different taxclasses
-      $taxclass = $taxclasses[0];
-    }
-  }
+  $options->{description} ||= 'Internet services';
+  $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} );
+  $options->{invnum} ||= '';
+  $options->{payname} = $self->payname unless exists( $options->{payname} );
+}
+
+sub _bop_content {
+  my ($self, $options) = @_;
+  my %content = ();
+
+  $content{address} = exists($options->{'address1'})
+                        ? $options->{'address1'}
+                        : $self->address1;
+  my $address2 = exists($options->{'address2'})
+                   ? $options->{'address2'}
+                   : $self->address2;
+  $content{address} .= ", ". $address2 if length($address2);
+
+  my $payip = exists($options->{'payip'}) ? $options->{'payip'} : $self->payip;
+  $content{customer_ip} = $payip if length($payip);
+
+  $content{invoice_number} = $options->{'invnum'}
+    if exists($options->{'invnum'}) && length($options->{'invnum'});
 
-  #look for an agent gateway override first
-  my $cardtype;
-  if ( $method eq 'CC' ) {
-    $cardtype = cardtype($payinfo);
-  } elsif ( $method eq 'ECHECK' ) {
-    $cardtype = 'ACH';
+  $content{email_customer} = 
+    (    $conf->exists('business-onlinepayment-email_customer')
+      || $conf->exists('business-onlinepayment-email-override') );
+      
+  $content{payfirst} = $self->getfield('first');
+  $content{paylast} = $self->getfield('last');
+
+  $content{account_name} = "$content{payfirst} $content{paylast}"
+    if $options->{method} eq 'ECHECK';
+
+  $content{name} = $options->{payname};
+  $content{name} = $content{account_name} if exists($content{account_name});
+
+  $content{city} = exists($options->{city})
+                     ? $options->{city}
+                     : $self->city;
+  $content{state} = exists($options->{state})
+                      ? $options->{state}
+                      : $self->state;
+  $content{zip} = exists($options->{zip})
+                    ? $options->{'zip'}
+                    : $self->zip;
+  $content{country} = exists($options->{country})
+                        ? $options->{country}
+                        : $self->country;
+  $content{referer} = 'http://cleanwhisker.420.am/'; #XXX fix referer :/
+  $content{phone} = $self->daytime || $self->night;
+
+  (%content);
+}
+
+my %bop_method2payby = (
+  'CC'     => 'CARD',
+  'ECHECK' => 'CHEK',
+  'LEC'    => 'LECB',
+);
+
+sub realtime_bop {
+  my $self = shift;
+
+  my %options = ();
+  if (ref($_[0]) eq 'HASH') {
+    %options = %{$_[0]};
   } else {
-    $cardtype = $method;
+    my ( $method, $amount ) = ( shift, shift );
+    %options = @_;
+    $options{method} = $method;
+    $options{amount} = $amount;
+  }
+  
+  if ( $DEBUG ) {
+    warn "$me realtime_bop: $options{method} $options{amount}\n";
+    warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
-  my $override =
-       qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => $cardtype,
-                                           taxclass => $taxclass,       } )
-    || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => '',
-                                           taxclass => $taxclass,       } )
-    || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => $cardtype,
-                                           taxclass => '',              } )
-    || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                           cardtype => '',
-                                           taxclass => '',              } );
+  return $self->fake_bop(%options) if $options{'fake'};
 
-  my $payment_gateway = '';
-  my( $processor, $login, $password, $action, @bop_options );
-  if ( $override ) { #use a payment gateway override
+  $self->_bop_defaults(\%options);
 
-    $payment_gateway = $override->payment_gateway;
+  ###
+  # select a gateway
+  ###
 
-    $processor   = $payment_gateway->gateway_module;
-    $login       = $payment_gateway->gateway_username;
-    $password    = $payment_gateway->gateway_password;
-    $action      = $payment_gateway->gateway_action;
-    @bop_options = $payment_gateway->options;
+  my $payment_gateway =  $self->_payment_gateway( \%options );
+  my $namespace = $payment_gateway->gateway_namespace;
 
-  } else { #use the standard settings from the config
+  eval "use $namespace";  
+  die $@ if $@;
 
-    ( $processor, $login, $password, $action, @bop_options ) =
-      $self->default_payment_gateway($method);
+  ###
+  # check for banned credit card/ACH
+  ###
 
-  }
+  my $ban = qsearchs('banned_pay', {
+    'payby'   => $bop_method2payby{$options{method}},
+    'payinfo' => md5_base64($options{payinfo}),
+  } );
+  return "Banned credit card" if $ban;
 
   ###
   # massage data
   ###
 
-  my $address = exists($options{'address1'})
-                    ? $options{'address1'}
-                    : $self->address1;
-  my $address2 = exists($options{'address2'})
-                    ? $options{'address2'}
-                    : $self->address2;
-  $address .= ", ". $address2 if length($address2);
-
-  my $o_payname = exists($options{'payname'})
-                    ? $options{'payname'}
-                    : $self->payname;
-  my($payname, $payfirst, $paylast);
-  if ( $o_payname && $method ne 'ECHECK' ) {
-    ($payname = $o_payname) =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
-      or return "Illegal payname $payname";
-    ($payfirst, $paylast) = ($1, $2);
-  } else {
-    $payfirst = $self->getfield('first');
-    $paylast = $self->getfield('last');
-    $payname =  "$payfirst $paylast";
+  my (%bop_content) = $self->_bop_content(\%options);
+
+  if ( $options{method} ne 'ECHECK' ) {
+    $options{payname} =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
+      or return "Illegal payname $options{payname}";
+    ($bop_content{payfirst}, $bop_content{paylast}) = ($1, $2);
   }
 
   my @invoicing_list = $self->invoicing_list_emailonly;
@@ -3514,25 +3620,11 @@ sub realtime_bop {
               ? $conf->config('business-onlinepayment-email-override')
               : $invoicing_list[0];
 
-  my %content = ();
-
-  my $payip = exists($options{'payip'})
-                ? $options{'payip'}
-                : $self->payip;
-  $content{customer_ip} = $payip
-    if length($payip);
-
-  $content{invoice_number} = $options{'invnum'}
-    if exists($options{'invnum'}) && length($options{'invnum'});
-
-  $content{email_customer} = 
-    (    $conf->exists('business-onlinepayment-email_customer')
-      || $conf->exists('business-onlinepayment-email-override') );
-      
   my $paydate = '';
-  if ( $method eq 'CC' ) { 
+  my %content = ();
+  if ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'CC' ) {
 
-    $content{card_number} = $payinfo;
+    $content{card_number} = $options{payinfo};
     $paydate = exists($options{'paydate'})
                     ? $options{'paydate'}
                     : $self->paydate;
@@ -3564,25 +3656,24 @@ sub realtime_bop {
     $content{recurring_billing} = 'YES'
       if qsearch('cust_pay', { 'custnum' => $self->custnum,
                                'payby'   => 'CARD',
-                               'payinfo' => $payinfo,
+                               'payinfo' => $options{payinfo},
                              } )
       || qsearch('cust_pay', { 'custnum' => $self->custnum,
                                'payby'   => 'CARD',
-                               'paymask' => $self->mask_payinfo('CARD', $payinfo),
+                               'paymask' => $self->mask_payinfo('CARD', $options{payinfo}),
                              } );
 
 
-  } elsif ( $method eq 'ECHECK' ) {
+  } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'ECHECK' ){
     ( $content{account_number}, $content{routing_code} ) =
-      split('@', $payinfo);
-    $content{bank_name} = $o_payname;
+      split('@', $options{payinfo});
+    $content{bank_name} = $options{payname};
     $content{bank_state} = exists($options{'paystate'})
                              ? $options{'paystate'}
                              : $self->getfield('paystate');
     $content{account_type} = exists($options{'paytype'})
                                ? uc($options{'paytype'}) || 'CHECKING'
                                : uc($self->getfield('paytype')) || 'CHECKING';
-    $content{account_name} = $payname;
     $content{customer_org} = $self->company ? 'B' : 'I';
     $content{state_id}       = exists($options{'stateid'})
                                  ? $options{'stateid'}
@@ -3593,8 +3684,12 @@ sub realtime_bop {
     $content{customer_ssn} = exists($options{'ss'})
                                ? $options{'ss'}
                                : $self->ss;
-  } elsif ( $method eq 'LEC' ) {
-    $content{phone} = $payinfo;
+  } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'LEC' ) {
+    $content{phone} = $options{payinfo};
+  } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) {
+    #move along
+  } else {
+    #die an evil death
   }
 
   ###
@@ -3611,9 +3706,9 @@ sub realtime_bop {
   #double-form-submission prevention is taken care of in cust_pay_pending::check
 
   #check the balance
-  return "The customer's balance has changed; $method transaction aborted."
+  return "The customer's balance has changed; $options{method} transaction aborted."
     if $self->balance < $balance;
-    #&& $self->balance < $amount; #might as well anyway?
+    #&& $self->balance < $options{amount}; #might as well anyway?
 
   #also check and make sure there aren't *other* pending payments for this cust
 
@@ -3623,7 +3718,7 @@ sub realtime_bop {
   });
   return "A payment is already being processed for this customer (".
          join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
-         "); $method transaction aborted."
+         "); $options{method} transaction aborted."
     if scalar(@pending);
 
   #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
@@ -3631,50 +3726,39 @@ sub realtime_bop {
   my $cust_pay_pending = new FS::cust_pay_pending {
     'custnum'    => $self->custnum,
     #'invnum'     => $options{'invnum'},
-    'paid'       => $amount,
+    'paid'       => $options{amount},
     '_date'      => '',
-    'payby'      => $method2payby{$method},
-    'payinfo'    => $payinfo,
+    'payby'      => $bop_method2payby{$options{method}},
+    'payinfo'    => $options{payinfo},
     'paydate'    => $paydate,
     'status'     => 'new',
-    'gatewaynum' => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
+    '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});
   my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
   return $cpp_new_err if $cpp_new_err;
 
-  my( $action1, $action2 ) = split(/\s*\,\s*/, $action );
+  my( $action1, $action2 ) =
+    split( /\s*\,\s*/, $payment_gateway->gateway_action );
+
+  my $transaction = new $namespace( $payment_gateway->gateway_module,
+                                    $self->_bop_options(\%options),
+                                  );
 
-  my $transaction = new Business::OnlinePayment( $processor, @bop_options );
   $transaction->content(
-    'type'           => $method,
-    'login'          => $login,
-    'password'       => $password,
+    'type'           => $options{method},
+    $self->_bop_auth(\%options),          
     'action'         => $action1,
     'description'    => $options{'description'},
-    'amount'         => $amount,
+    'amount'         => $options{amount},
     #'invoice_number' => $options{'invnum'},
     'customer_id'    => $self->custnum,
-    'last_name'      => $paylast,
-    'first_name'     => $payfirst,
-    'name'           => $payname,
-    'address'        => $address,
-    'city'           => ( exists($options{'city'})
-                            ? $options{'city'}
-                            : $self->city          ),
-    'state'          => ( exists($options{'state'})
-                            ? $options{'state'}
-                            : $self->state          ),
-    'zip'            => ( exists($options{'zip'})
-                            ? $options{'zip'}
-                            : $self->zip          ),
-    'country'        => ( exists($options{'country'})
-                            ? $options{'country'}
-                            : $self->country          ),
-    'referer'        => 'http://cleanwhisker.420.am/',
+    %bop_content,
+    'reference'      => $cust_pay_pending->paypendingnum, #for now
     'email'          => $email,
-    'phone'          => $self->daytime || $self->night,
     %content, #after
   );
 
@@ -3698,7 +3782,12 @@ sub realtime_bop {
     }
   }
 
-  if ( $transaction->is_success() && $action2 ) {
+  if ( $transaction->is_success() && $namespace eq 'Business::OnlineThirdPartyPayment' ) {
+
+    return { reference => $cust_pay_pending->paypendingnum,
+             map { $_ => $transaction->$_ } qw ( popup_url collectitems ) };
+
+  } elsif ( $transaction->is_success() && $action2 ) {
 
     $cust_pay_pending->status('authorized');
     my $cpp_authorized_err = $cust_pay_pending->replace;
@@ -3710,16 +3799,17 @@ sub realtime_bop {
                    : '';
 
     my $capture =
-      new Business::OnlinePayment( $processor, @bop_options );
+      new Business::OnlinePayment( $payment_gateway->gateway_module,
+                                   $self->_bop_options(\%options),
+                                 );
 
     my %capture = (
       %content,
-      type           => $method,
+      type           => $options{method},
       action         => $action2,
-      login          => $login,
-      password       => $password,
+      $self->_bop_auth(\%options),          
       order_number   => $ordernum,
-      amount         => $amount,
+      amount         => $options{amount},
       authorization  => $auth,
       description    => $options{'description'},
     );
@@ -3745,10 +3835,6 @@ sub realtime_bop {
 
   }
 
-  $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
-  my $cpp_captured_err = $cust_pay_pending->replace;
-  return $cpp_captured_err if $cpp_captured_err;
-
   ###
   # remove paycvv after initial transaction
   ###
@@ -3757,7 +3843,7 @@ sub realtime_bop {
   # correctly
   if ( defined $self->dbdef_table->column('paycvv')
        && length($self->paycvv)
-       && ! grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save')
+       && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save')
   ) {
     my $error = $self->remove_cvv;
     if ( $error ) {
@@ -3769,14 +3855,114 @@ sub realtime_bop {
   # result handling
   ###
 
+  $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
+
+}
+
+=item fake_bop
+
+=cut
+
+sub fake_bop {
+  my $self = shift;
+
+  my %options = ();
+  if (ref($_[0]) eq 'HASH') {
+    %options = %{$_[0]};
+  } else {
+    my ( $method, $amount ) = ( shift, shift );
+    %options = @_;
+    $options{method} = $method;
+    $options{amount} = $amount;
+  }
+  
+  if ( $options{'fake_failure'} ) {
+     return "Error: No error; test failure requested with fake_failure";
+  }
+
+  #my $paybatch = '';
+  #if ( $payment_gateway->gatewaynum ) { # agent override
+  #  $paybatch = $payment_gateway->gatewaynum. '-';
+  #}
+  #
+  #$paybatch .= "$processor:". $transaction->authorization;
+  #
+  #$paybatch .= ':'. $transaction->order_number
+  #  if $transaction->can('order_number')
+  #  && length($transaction->order_number);
+
+  my $paybatch = 'FakeProcessor:54:32';
+
+  my $cust_pay = new FS::cust_pay ( {
+     'custnum'  => $self->custnum,
+     'invnum'   => $options{'invnum'},
+     'paid'     => $options{amount},
+     '_date'    => '',
+     'payby'    => $bop_method2payby{$options{method}},
+     #'payinfo'  => $payinfo,
+     'payinfo'  => '4111111111111111',
+     'paybatch' => $paybatch,
+     #'paydate'  => $paydate,
+     'paydate'  => '2012-05-01',
+  } );
+  $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
+
+  my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
+
+  if ( $error ) {
+    $cust_pay->invnum(''); #try again with no specific invnum
+    my $error2 = $cust_pay->insert( $options{'manual'} ?
+                                    ( 'manual' => 1 ) : ()
+                                  );
+    if ( $error2 ) {
+      # gah, even with transactions.
+      my $e = 'WARNING: Card/ACH debited but database not updated - '.
+              "error inserting (fake!) payment: $error2".
+              " (previously tried insert with invnum #$options{'invnum'}" .
+              ": $error )";
+      warn $e;
+      return $e;
+    }
+  }
+
+  if ( $options{'paynum_ref'} ) {
+    ${ $options{'paynum_ref'} } = $cust_pay->paynum;
+  }
+
+  return ''; #no error
+
+}
+
+
+# item _realtime_bop_result CUST_PAY_PENDING, BOP_OBJECT [ OPTION => VALUE ... ]
+# 
+# Wraps up processing of a realtime credit card, ACH (electronic check) or
+# phone bill transaction.
+
+sub _realtime_bop_result {
+  my( $self, $cust_pay_pending, $transaction, %options ) = @_;
+  if ( $DEBUG ) {
+    warn "$me _realtime_bop_result: pending transaction ".
+      $cust_pay_pending->paypendingnum. "\n";
+    warn "  $_ => $options{$_}\n" foreach keys %options;
+  }
+
+  my $payment_gateway = $options{payment_gateway}
+    or return "no payment gateway in arguments to _realtime_bop_result";
+
+  $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
+  my $cpp_captured_err = $cust_pay_pending->replace;
+  return $cpp_captured_err if $cpp_captured_err;
+
   if ( $transaction->is_success() ) {
 
     my $paybatch = '';
-    if ( $payment_gateway ) { # agent override
+    if ( $payment_gateway->gatewaynum ) { # agent override
       $paybatch = $payment_gateway->gatewaynum. '-';
     }
 
-    $paybatch .= "$processor:". $transaction->authorization;
+    $paybatch .= $payment_gateway->gateway_module. ":".
+      $transaction->authorization;
 
     $paybatch .= ':'. $transaction->order_number
       if $transaction->can('order_number')
@@ -3785,12 +3971,12 @@ sub realtime_bop {
     my $cust_pay = new FS::cust_pay ( {
        'custnum'  => $self->custnum,
        'invnum'   => $options{'invnum'},
-       'paid'     => $amount,
+       'paid'     => $cust_pay_pending->paid,
        '_date'    => '',
-       'payby'    => $method2payby{$method},
-       'payinfo'  => $payinfo,
+       'payby'    => $cust_pay_pending->payby,
+       #'payinfo'  => $payinfo,
        'paybatch' => $paybatch,
-       'paydate'  => $paydate,
+       'paydate'  => $cust_pay_pending->paydate,
     } );
     #doesn't hurt to know, even though the dup check is in cust_pay_pending now
     $cust_pay->payunique( $options{payunique} )
@@ -3812,8 +3998,9 @@ sub realtime_bop {
       if ( $error2 ) {
         # gah.  but at least we have a record of the state we had to abort in
         # from cust_pay_pending now.
-        my $e = "WARNING: $method captured but payment not recorded - ".
-                "error inserting payment ($processor): $error2".
+        my $e = "WARNING: $options{method} captured but payment not recorded -".
+                " error inserting payment (". $payment_gateway->gateway_module.
+                "): $error2".
                 " (previously tried insert with invnum #$options{'invnum'}" .
                 ": $error ) - pending payment saved as paypendingnum ".
                 $cust_pay_pending->paypendingnum. "\n";
@@ -3822,18 +4009,44 @@ sub realtime_bop {
       }
     }
 
+    my $jobnum = $cust_pay_pending->jobnum;
+    if ( $jobnum ) {
+       my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } );
+      
+       unless ( $placeholder ) {
+         $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+         my $e = "WARNING: $options{method} captured but job $jobnum not ".
+             "found for paypendingnum ". $cust_pay_pending->paypendingnum. "\n";
+         warn $e;
+         return $e;
+       }
+
+       $error = $placeholder->delete;
+
+       if ( $error ) {
+         $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+         my $e = "WARNING: $options{method} captured but could not delete ".
+              "job $jobnum for paypendingnum ".
+              $cust_pay_pending->paypendingnum. ": $error\n";
+         warn $e;
+         return $e;
+       }
+
+    }
+    
     if ( $options{'paynum_ref'} ) {
       ${ $options{'paynum_ref'} } = $cust_pay->paynum;
     }
 
     $cust_pay_pending->status('done');
     $cust_pay_pending->statustext('captured');
+    $cust_pay_pending->paynum($cust_pay->paynum);
     my $cpp_done_err = $cust_pay_pending->replace;
 
     if ( $cpp_done_err ) {
 
       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
-      my $e = "WARNING: $method captured but payment not recorded - ".
+      my $e = "WARNING: $options{method} captured but payment not recorded - ".
               "error updating status for paypendingnum ".
               $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
       warn $e;
@@ -3848,8 +4061,26 @@ sub realtime_bop {
 
   } else {
 
-    my $perror = "$processor error: ". $transaction->error_message;
+    my $perror = $payment_gateway->gateway_module. " error: ".
+      $transaction->error_message;
 
+    my $jobnum = $cust_pay_pending->jobnum;
+    if ( $jobnum ) {
+       my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } );
+      
+       if ( $placeholder ) {
+         my $error = $placeholder->depended_delete;
+         $error ||= $placeholder->delete;
+         warn "error removing provisioning jobs after declined paypendingnum ".
+           $cust_pay_pending->paypendingnum. "\n";
+       } else {
+         my $e = "error finding job $jobnum for declined paypendingnum ".
+              $cust_pay_pending->paypendingnum. "\n";
+         warn $e;
+       }
+
+    }
+    
     unless ( $transaction->error_message ) {
 
       my $t_response;
@@ -3870,10 +4101,12 @@ sub realtime_bop {
                       };
       } else {
         $t_response .=
-          "No additional debugging information available for $processor";
+          "No additional debugging information available for ".
+            $payment_gateway->gateway_module;
       }
 
-      $perror .= "No error_message returned from $processor -- ".
+      $perror .= "No error_message returned from ".
+                   $payment_gateway->gateway_module. " -- ".
                  ( ref($t_response) ? Dumper($t_response) : $t_response );
 
     }
@@ -3910,8 +4143,8 @@ sub realtime_bop {
     $cust_pay_pending->statustext("declined: $perror");
     my $cpp_done_err = $cust_pay_pending->replace;
     if ( $cpp_done_err ) {
-      my $e = "WARNING: $method declined but pending payment not resolved - ".
-              "error updating status for paypendingnum ".
+      my $e = "WARNING: $options{method} declined but pending payment not ".
+              "resolved - error updating status for paypendingnum ".
               $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
       warn $e;
       $perror = "$e ($perror)";
@@ -3922,77 +4155,126 @@ sub realtime_bop {
 
 }
 
-=item fake_bop
+=item realtime_botpp_capture CUST_PAY_PENDING [ OPTION => VALUE ... ]
 
-=cut
+Verifies successful third party processing of a realtime credit card,
+ACH (electronic check) or phone bill transaction via a
+Business::OnlineThirdPartyPayment realtime gateway.  See
+L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
 
-sub fake_bop {
-  my( $self, $method, $amount, %options ) = @_;
+Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
 
-  if ( $options{'fake_failure'} ) {
-     return "Error: No error; test failure requested with fake_failure";
-  }
+The additional options I<payname>, I<city>, I<state>,
+I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
+if set, will override the value from the customer record.
 
-  my %method2payby = (
-    'CC'     => 'CARD',
-    'ECHECK' => 'CHEK',
-    'LEC'    => 'LECB',
-  );
+I<description> is a free-text field passed to the gateway.  It defaults to
+"Internet services".
 
-  #my $paybatch = '';
-  #if ( $payment_gateway ) { # agent override
-  #  $paybatch = $payment_gateway->gatewaynum. '-';
-  #}
-  #
-  #$paybatch .= "$processor:". $transaction->authorization;
-  #
-  #$paybatch .= ':'. $transaction->order_number
-  #  if $transaction->can('order_number')
-  #  && length($transaction->order_number);
+If an I<invnum> is specified, this payment (if successful) is applied to the
+specified invoice.  If you don't specify an I<invnum> you might want to
+call the B<apply_payments> method.
 
-  my $paybatch = 'FakeProcessor:54:32';
+I<quiet> can be set true to surpress email decline notices.
 
-  my $cust_pay = new FS::cust_pay ( {
-     'custnum'  => $self->custnum,
-     'invnum'   => $options{'invnum'},
-     'paid'     => $amount,
-     '_date'    => '',
-     'payby'    => $method2payby{$method},
-     #'payinfo'  => $payinfo,
-     'payinfo'  => '4111111111111111',
-     'paybatch' => $paybatch,
-     #'paydate'  => $paydate,
-     'paydate'  => '2012-05-01',
-  } );
-  $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
+I<paynum_ref> can be set to a scalar reference.  It will be filled in with the
+resulting paynum, if any.
 
-  my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
+I<payunique> is a unique identifier for this payment.
 
-  if ( $error ) {
-    $cust_pay->invnum(''); #try again with no specific invnum
-    my $error2 = $cust_pay->insert( $options{'manual'} ?
-                                    ( 'manual' => 1 ) : ()
-                                  );
-    if ( $error2 ) {
-      # gah, even with transactions.
-      my $e = 'WARNING: Card/ACH debited but database not updated - '.
-              "error inserting (fake!) payment: $error2".
-              " (previously tried insert with invnum #$options{'invnum'}" .
-              ": $error )";
-      warn $e;
-      return $e;
-    }
+Returns a hashref containing elements bill_error (which will be undefined
+upon success) and session_id of any associated session.
+
+=cut
+
+sub realtime_botpp_capture {
+  my( $self, $cust_pay_pending, %options ) = @_;
+  if ( $DEBUG ) {
+    warn "$me realtime_botpp_capture: pending transaction $cust_pay_pending\n";
+    warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
-  if ( $options{'paynum_ref'} ) {
-    ${ $options{'paynum_ref'} } = $cust_pay->paynum;
+  eval "use Business::OnlineThirdPartyPayment";  
+  die $@ if $@;
+
+  ###
+  # select the gateway
+  ###
+
+  my $method = FS::payby->payby2bop($cust_pay_pending->payby);
+
+  my $payment_gateway = $cust_pay_pending->gatewaynum
+    ? qsearchs( 'payment_gateway',
+                { gatewaynum => $cust_pay_pending->gatewaynum }
+              )
+    : $self->agent->payment_gateway( 'method' => $method,
+                                     # 'invnum'  => $cust_pay_pending->invnum,
+                                     # 'payinfo' => $cust_pay_pending->payinfo,
+                                   );
+
+  $options{payment_gateway} = $payment_gateway; # for the helper subs
+
+  ###
+  # massage data
+  ###
+
+  my @invoicing_list = $self->invoicing_list_emailonly;
+  if ( $conf->exists('emailinvoiceautoalways')
+       || $conf->exists('emailinvoiceauto') && ! @invoicing_list
+       || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
+    push @invoicing_list, $self->all_emails;
   }
 
-  return ''; #no error
+  my $email = ($conf->exists('business-onlinepayment-email-override'))
+              ? $conf->config('business-onlinepayment-email-override')
+              : $invoicing_list[0];
+
+  my %content = ();
+
+  $content{email_customer} = 
+    (    $conf->exists('business-onlinepayment-email_customer')
+      || $conf->exists('business-onlinepayment-email-override') );
+      
+  ###
+  # run transaction(s)
+  ###
+
+  my $transaction =
+    new Business::OnlineThirdPartyPayment( $payment_gateway->gateway_module,
+                                           $self->_bop_options(\%options),
+                                         );
+
+  $transaction->reference({ %options }); 
+
+  $transaction->content(
+    'type'           => $method,
+    $self->_bop_auth(\%options),
+    'action'         => 'Post Authorization',
+    'description'    => $options{'description'},
+    'amount'         => $cust_pay_pending->paid,
+    #'invoice_number' => $options{'invnum'},
+    'customer_id'    => $self->custnum,
+    'referer'        => 'http://cleanwhisker.420.am/',
+    'reference'      => $cust_pay_pending->paypendingnum,
+    'email'          => $email,
+    'phone'          => $self->daytime || $self->night,
+    %content, #after
+    # plus whatever is required for bogus capture avoidance
+  );
+
+  $transaction->submit();
+
+  my $error =
+    $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
+
+  {
+    bill_error => $error,
+    session_id => $cust_pay_pending->session_id,
+  }
 
 }
 
-=item default_payment_gateway
+=item default_payment_gateway DEPRECATED -- use agent->payment_gateway
 
 =cut
 
@@ -4002,6 +4284,8 @@ sub default_payment_gateway {
   die "Real-time processing not enabled\n"
     unless $conf->exists('business-onlinepayment');
 
+  warn "default_payment_gateway deprecated -- use agent->payment_gateway\n";
+
   #load up config
   my $bop_config = 'business-onlinepayment';
   $bop_config .= '-ach'
@@ -4074,15 +4358,22 @@ gateway is attempted.
 #some false laziness w/realtime_bop, not enough to make it worth merging
 #but some useful small subs should be pulled out
 sub realtime_refund_bop {
-  my( $self, $method, %options ) = @_;
+  my $self = shift;
+
+  my %options = ();
+  if (ref($_[0]) ne 'HASH') {
+    %options = %{$_[0]};
+  } else {
+    my $method = shift;
+    %options = @_;
+    $options{method} = $method;
+  }
+
   if ( $DEBUG ) {
-    warn "$me realtime_refund_bop: $method refund\n";
+    warn "$me realtime_refund_bop: $options{method} refund\n";
     warn "  $_ => $options{$_}\n" foreach keys %options;
   }
 
-  eval "use Business::OnlinePayment";  
-  die $@ if $@;
-
   ###
   # look up the original payment and optionally a gateway for that payment
   ###
@@ -4090,7 +4381,7 @@ sub realtime_refund_bop {
   my $cust_pay = '';
   my $amount = $options{'amount'};
 
-  my( $processor, $login, $password, @bop_options ) ;
+  my( $processor, $login, $password, @bop_options, $namespace ) ;
   my( $auth, $order_number ) = ( '', '', '' );
 
   if ( $options{'paynum'} ) {
@@ -4116,13 +4407,22 @@ sub realtime_refund_bop {
       $processor   = $payment_gateway->gateway_module;
       $login       = $payment_gateway->gateway_username;
       $password    = $payment_gateway->gateway_password;
+      $namespace   = $payment_gateway->gateway_namespace;
       @bop_options = $payment_gateway->options;
 
     } else { #try the default gateway
 
-      my( $conf_processor, $unused_action );
-      ( $conf_processor, $login, $password, $unused_action, @bop_options ) =
-        $self->default_payment_gateway($method);
+      my $conf_processor;
+      my $payment_gateway =
+        $self->agent->payment_gateway('method' => $options{method});
+
+      ( $conf_processor, $login, $password, $namespace ) =
+        map { my $method = "gateway_$_"; $payment_gateway->$method }
+          qw( module username password namespace );
+
+      @bop_options = $payment_gateway->gatewaynum
+                       ? $payment_gateway->options
+                       : @{ $payment_gateway->get('options') };
 
       return "processor of payment $options{'paynum'} $processor does not".
              " match default processor $conf_processor"
@@ -4133,51 +4433,32 @@ sub realtime_refund_bop {
 
   } else { # didn't specify a paynum, so look for agent gateway overrides
            # like a normal transaction 
-
-    my $cardtype;
-    if ( $method eq 'CC' ) {
-      $cardtype = cardtype($self->payinfo);
-    } elsif ( $method eq 'ECHECK' ) {
-      $cardtype = 'ACH';
-    } else {
-      $cardtype = $method;
-    }
-    my $override =
-           qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                               cardtype => $cardtype,
-                                               taxclass => '',              } )
-        || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
-                                               cardtype => '',
-                                               taxclass => '',              } );
-
-    if ( $override ) { #use a payment gateway override
  
-      my $payment_gateway = $override->payment_gateway;
+    my $payment_gateway =
+      $self->agent->payment_gateway( 'method'  => $options{method},
+                                     #'payinfo' => $payinfo,
+                                   );
+    my( $processor, $login, $password, $namespace ) =
+      map { my $method = "gateway_$_"; $payment_gateway->$method }
+        qw( module username password namespace );
 
-      $processor   = $payment_gateway->gateway_module;
-      $login       = $payment_gateway->gateway_username;
-      $password    = $payment_gateway->gateway_password;
-      #$action      = $payment_gateway->gateway_action;
-      @bop_options = $payment_gateway->options;
-
-    } else { #use the standard settings from the config
-
-      my $unused_action;
-      ( $processor, $login, $password, $unused_action, @bop_options ) =
-        $self->default_payment_gateway($method);
-
-    }
+    my @bop_options = $payment_gateway->gatewaynum
+                        ? $payment_gateway->options
+                        : @{ $payment_gateway->get('options') };
 
   }
   return "neither amount nor paynum specified" unless $amount;
 
+  eval "use $namespace";  
+  die $@ if $@;
+
   my %content = (
-    'type'           => $method,
+    'type'           => $options{method},
     'login'          => $login,
     'password'       => $password,
     'order_number'   => $order_number,
     'amount'         => $amount,
-    'referer'        => 'http://cleanwhisker.420.am/',
+    'referer'        => 'http://cleanwhisker.420.am/', #XXX fix referer :/
   );
   $content{authorization} = $auth
     if length($auth); #echeck/ACH transactions have an order # but no auth
@@ -4222,7 +4503,7 @@ sub realtime_refund_bop {
   $address .= ", ". $self->address2 if $self->address2;
 
   my($payname, $payfirst, $paylast);
-  if ( $self->payname && $method ne 'ECHECK' ) {
+  if ( $self->payname && $options{method} ne 'ECHECK' ) {
     $payname = $self->payname;
     $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
       or return "Illegal payname $payname";
@@ -4251,7 +4532,7 @@ sub realtime_refund_bop {
     if length($payip);
 
   my $payinfo = '';
-  if ( $method eq 'CC' ) {
+  if ( $options{method} eq 'CC' ) {
 
     if ( $cust_pay ) {
       $content{card_number} = $payinfo = $cust_pay->payinfo;
@@ -4265,7 +4546,7 @@ sub realtime_refund_bop {
       $content{expiration} = "$2/$1";
     }
 
-  } elsif ( $method eq 'ECHECK' ) {
+  } elsif ( $options{method} eq 'ECHECK' ) {
 
     if ( $cust_pay ) {
       $payinfo = $cust_pay->payinfo;
@@ -4278,7 +4559,7 @@ sub realtime_refund_bop {
     $content{account_name} = $payname;
     $content{customer_org} = $self->company ? 'B' : 'I';
     $content{customer_ssn} = $self->ss;
-  } elsif ( $method eq 'LEC' ) {
+  } elsif ( $options{method} eq 'LEC' ) {
     $content{phone} = $payinfo = $self->payinfo;
   }
 
@@ -4306,12 +4587,6 @@ sub realtime_refund_bop {
   return "$processor error: ". $refund->error_message
     unless $refund->is_success();
 
-  my %method2payby = (
-    'CC'     => 'CARD',
-    'ECHECK' => 'CHEK',
-    'LEC'    => 'LECB',
-  );
-
   my $paybatch = "$processor:". $refund->authorization;
   $paybatch .= ':'. $refund->order_number
     if $refund->can('order_number') && $refund->order_number;
@@ -4329,7 +4604,7 @@ sub realtime_refund_bop {
     'paynum'   => $options{'paynum'},
     'refund'   => $amount,
     '_date'    => '',
-    'payby'    => $method2payby{$method},
+    'payby'    => $bop_method2payby{$options{method}},
     'payinfo'  => $payinfo,
     'paybatch' => $paybatch,
     'reason'   => $options{'reason'} || 'card or ACH refund',
@@ -5144,14 +5419,16 @@ the error, otherwise returns false.
 
 sub charge {
   my $self = shift;
-  my ( $amount, $quantity, $pkg, $comment, $taxclass, $additional, $classnum );
-  my ( $taxproduct, $override );
+  my ( $amount, $quantity, $pkg, $comment, $classnum, $additional );
+  my ( $setuptax, $taxclass );   #internal taxes
+  my ( $taxproduct, $override ); #vendor (CCH) taxes
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
                                            : '$'. sprintf("%.2f",$amount);
+    $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
     $additional = $_[0]->{additional};
@@ -5162,6 +5439,7 @@ sub charge {
     $quantity   = 1;
     $pkg        = @_ ? shift : 'One-time charge';
     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
+    $setuptax   = '';
     $taxclass   = @_ ? shift : '';
     $additional = [];
   }
@@ -5184,6 +5462,7 @@ sub charge {
     'freq'          => 0,
     'disabled'      => 'Y',
     'classnum'      => $classnum ? $classnum : '',
+    'setuptax'      => $setuptax,
     'taxclass'      => $taxclass,
     'taxproductnum' => $taxproduct,
   } );
@@ -5330,6 +5609,41 @@ sub cust_pay_batch {
     qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
 }
 
+=item cust_pay_pending
+
+Returns all pending payments (see L<FS::cust_pay_pending>) for this customer
+(without status "done").
+
+=cut
+
+sub cust_pay_pending {
+  my $self = shift;
+  return $self->num_cust_pay_pending unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_pay_pending', {
+                                   'custnum' => $self->custnum,
+                                   'status'  => { op=>'!=', value=>'done' },
+                                 },
+           );
+}
+
+=item num_cust_pay_pending
+
+Returns the number of pending payments (see L<FS::cust_pay_pending>) for this
+customer (without status "done").  Also called automatically when the
+cust_pay_pending method is used in a scalar context.
+
+=cut
+
+sub num_cust_pay_pending {
+  my $self = shift;
+  my $sql = " SELECT COUNT(*) FROM cust_pay_pending ".
+            "   WHERE custnum = ? AND status != 'done' ";
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute($self->custnum) or die $sth->errstr;
+  $sth->fetchrow_arrayref->[0];
+}
+
 =item cust_refund
 
 Returns all the refunds (see L<FS::cust_refund>) for this customer.
@@ -5600,22 +5914,24 @@ sub tickets {
   my $num = $conf->config('cust_main-max_tickets') || 10;
   my @tickets = ();
 
-  unless ( $conf->config('ticket_system-custom_priority_field') ) {
+  if ( $conf->config('ticket_system') ) {
+    unless ( $conf->config('ticket_system-custom_priority_field') ) {
 
-    @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
+      @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
 
-  } else {
+    } else {
 
-    foreach my $priority (
-      $conf->config('ticket_system-custom_priority_field-values'), ''
-    ) {
-      last if scalar(@tickets) >= $num;
-      push @tickets, 
-        @{ FS::TicketSystem->customer_tickets( $self->custnum,
-                                               $num - scalar(@tickets),
-                                               $priority,
-                                             )
-         };
+      foreach my $priority (
+        $conf->config('ticket_system-custom_priority_field-values'), ''
+      ) {
+        last if scalar(@tickets) >= $num;
+        push @tickets, 
+          @{ FS::TicketSystem->customer_tickets( $self->custnum,
+                                                 $num - scalar(@tickets),
+                                                 $priority,
+                                               )
+           };
+      }
     }
   }
   (@tickets);
@@ -7006,9 +7322,13 @@ sub _agent_plandata {
                AND peo_agentnum.optionname = 'agentnum'
                AND peo_agentnum.optionvalue }. $regexp. q{ '(^|,)}. $agentnum. q{(,|$)'
              )
-        LEFT JOIN part_event_option AS peo_cust_bill_age
-          ON ( part_event.eventpart = peo_cust_bill_age.eventpart
-               AND peo_cust_bill_age.optionname = 'cust_bill_age'
+        LEFT JOIN part_event_condition
+          ON ( part_event.eventpart = part_event_condition.eventpart
+               AND part_event_condition.conditionname = 'cust_bill_age'
+             )
+        LEFT JOIN part_event_condition_option
+          ON ( part_event_condition.eventconditionnum = part_event_condition_option.eventconditionnum
+               AND part_event_condition_option.optionname = 'age'
              )
       },
       #'hashref'   => { 'optionname' => $option },
@@ -7020,9 +7340,9 @@ sub _agent_plandata {
         " AND peo_agentnum.optionname = 'agentnum' ".
         " AND ( agentnum IS NULL OR agentnum = $agentnum ) ".
         " ORDER BY
-           CASE WHEN peo_cust_bill_age.optionname != 'cust_bill_age'
+           CASE WHEN part_event_condition_option.optionname IS NULL
            THEN -1
-          ELSE ". FS::part_event::Condition->age2seconds_sql('peo_cust_bill_age.optionvalue').
+          ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue').
         " END
           , part_event.weight".
         " LIMIT 1"