fix cust_paydate_within event condition vs. cust_payby, #23741
[freeside.git] / FS / FS / cust_main.pm
index 684031c..be02f9c 100644 (file)
@@ -1,42 +1,47 @@
 package FS::cust_main;
 package FS::cust_main;
+use base qw( FS::cust_main::Packages
+             FS::cust_main::Status
+             FS::cust_main::NationalID
+             FS::cust_main::Billing
+             FS::cust_main::Billing_Realtime
+             FS::cust_main::Billing_Batch
+             FS::cust_main::Billing_Discount
+             FS::cust_main::Billing_ThirdParty
+             FS::cust_main::Location
+             FS::cust_main::Credit_Limit
+             FS::cust_main::Merge
+             FS::cust_main::API
+             FS::otaker_Mixin FS::cust_main_Mixin
+             FS::geocode_Mixin FS::Quotable_Mixin FS::Sales_Mixin
+             FS::o2m_Common
+             FS::Record
+           );
 
 require 5.006;
 use strict;
 
 require 5.006;
 use strict;
-use base qw( FS::otaker_Mixin FS::payinfo_Mixin FS::Record );
-use vars qw( @EXPORT_OK $DEBUG $me $conf
-             @encrypted_fields
-             $import $ignore_expired_card
-             $skip_fuzzyfiles @fuzzyfields
-             @paytypes
-           );
-use vars qw( $realtime_bop_decline_quiet ); #ugh
-use Safe;
 use Carp;
 use Carp;
-use Exporter;
 use Scalar::Util qw( blessed );
 use Scalar::Util qw( blessed );
-use List::Util qw( min );
 use Time::Local qw(timelocal);
 use Data::Dumper;
 use Tie::IxHash;
 use Time::Local qw(timelocal);
 use Data::Dumper;
 use Tie::IxHash;
-use Digest::MD5 qw(md5_base64);
 use Date::Format;
 #use Date::Manip;
 use Date::Format;
 #use Date::Manip;
-use File::Temp qw( tempfile );
-use String::Approx qw(amatch);
+use File::Temp; #qw( tempfile );
 use Business::CreditCard 0.28;
 use Business::CreditCard 0.28;
-use Locale::Country;
-use FS::UID qw( getotaker dbh driver_name );
+use List::Util qw(min);
+use FS::UID qw( dbh driver_name );
 use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
 use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
-use FS::Misc qw( generate_email send_email generate_ps do_print );
+use FS::Cursor;
+use FS::Misc qw( generate_ps do_print money_pretty card_types );
 use FS::Msgcat qw(gettext);
 use FS::Msgcat qw(gettext);
+use FS::CurrentUser;
+use FS::TicketSystem;
 use FS::payby;
 use FS::cust_pkg;
 use FS::cust_svc;
 use FS::cust_bill;
 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_bill_void;
+use FS::legacy_cust_bill;
 use FS::cust_pay;
 use FS::cust_pay_pending;
 use FS::cust_pay_void;
 use FS::cust_pay;
 use FS::cust_pay_pending;
 use FS::cust_pay_void;
@@ -47,54 +52,58 @@ use FS::part_referral;
 use FS::cust_main_county;
 use FS::cust_location;
 use FS::cust_class;
 use FS::cust_main_county;
 use FS::cust_location;
 use FS::cust_class;
+use FS::tax_status;
 use FS::cust_main_exemption;
 use FS::cust_tax_adjustment;
 use FS::cust_main_exemption;
 use FS::cust_tax_adjustment;
-use FS::tax_rate;
-use FS::tax_rate_location;
 use FS::cust_tax_location;
 use FS::cust_tax_location;
-use FS::part_pkg_taxrate;
-use FS::agent;
+use FS::agent_currency;
 use FS::cust_main_invoice;
 use FS::cust_main_invoice;
-use FS::cust_credit_bill;
-use FS::cust_bill_pay;
+use FS::cust_tag;
 use FS::prepay_credit;
 use FS::queue;
 use FS::part_pkg;
 use FS::prepay_credit;
 use FS::queue;
 use FS::part_pkg;
-use FS::part_event;
-use FS::part_event_condition;
+use FS::part_export;
 #use FS::cust_event;
 use FS::type_pkgs;
 use FS::payment_gateway;
 use FS::agent_payment_gateway;
 use FS::banned_pay;
 #use FS::cust_event;
 use FS::type_pkgs;
 use FS::payment_gateway;
 use FS::agent_payment_gateway;
 use FS::banned_pay;
-use FS::TicketSystem;
-
-@EXPORT_OK = qw( smart_search );
-
-$realtime_bop_decline_quiet = 0;
+use FS::cust_main_note;
+use FS::cust_attachment;
+use FS::cust_contact;
+use FS::Locales;
+use FS::upgrade_journal;
+use FS::sales;
+use FS::cust_payby;
+use FS::contact;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
 # 3 is even more information including possibly sensitive data
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
 # 3 is even more information including possibly sensitive data
-$DEBUG = 0;
-$me = '[FS::cust_main]';
+our $DEBUG = 0;
+our $me = '[FS::cust_main]';
 
 
-$import = 0;
-$ignore_expired_card = 0;
+our $import = 0;
+our $ignore_expired_card = 0;
+our $ignore_banned_card = 0;
+our $ignore_invalid_card = 0;
 
 
-$skip_fuzzyfiles = 0;
-@fuzzyfields = ( 'first', 'last', 'company', 'address1' );
+our $skip_fuzzyfiles = 0;
 
 
-@encrypted_fields = ('payinfo', 'paycvv');
-sub nohistory_fields { ('paycvv'); }
+our $ucfirst_nowarn = 0;
 
 
-@paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
+#this info is in cust_payby as of 4.x
+#this and the fields themselves can be removed in 5.x
+our @encrypted_fields = ('payinfo', 'paycvv');
+sub nohistory_fields { ('payinfo', 'paycvv'); }
 
 
+our $conf;
 #ask FS::UID to run this stuff for us later
 #$FS::UID::callback{'FS::cust_main'} = sub { 
 install_callback FS::UID sub { 
   $conf = new FS::Conf;
   #yes, need it for stuff below (prolly should be cached)
 #ask FS::UID to run this stuff for us later
 #$FS::UID::callback{'FS::cust_main'} = sub { 
 install_callback FS::UID sub { 
   $conf = new FS::Conf;
   #yes, need it for stuff below (prolly should be cached)
+  $ignore_invalid_card = $conf->exists('allow_invalid_cards');
 };
 
 sub _cache {
 };
 
 sub _cache {
@@ -178,28 +187,6 @@ Cocial security number (optional)
 
 (optional)
 
 
 (optional)
 
-=item address1
-
-=item address2
-
-(optional)
-
-=item city
-
-=item county
-
-(optional, see L<FS::cust_main_county>)
-
-=item state
-
-(see L<FS::cust_main_county>)
-
-=item zip
-
-=item country
-
-(see L<FS::cust_main_county>)
-
 =item daytime
 
 phone (optional)
 =item daytime
 
 phone (optional)
@@ -212,49 +199,7 @@ phone (optional)
 
 phone (optional)
 
 
 phone (optional)
 
-=item ship_first
-
-Shipping first name
-
-=item ship_last
-
-Shipping last name
-
-=item ship_company
-
-(optional)
-
-=item ship_address1
-
-=item ship_address2
-
-(optional)
-
-=item ship_city
-
-=item ship_county
-
-(optional, see L<FS::cust_main_county>)
-
-=item ship_state
-
-(see L<FS::cust_main_county>)
-
-=item ship_zip
-
-=item ship_country
-
-(see L<FS::cust_main_county>)
-
-=item ship_daytime
-
-phone (optional)
-
-=item ship_night
-
-phone (optional)
-
-=item ship_fax
+=item mobile
 
 phone (optional)
 
 
 phone (optional)
 
@@ -326,6 +271,18 @@ A suggestion to events (see L<FS::part_bill_event">) to delay until this unix ti
 
 Discourage individual CDR printing, empty or `Y'
 
 
 Discourage individual CDR printing, empty or `Y'
 
+=item edit_subject
+
+Allow self-service editing of ticket subjects, empty or 'Y'
+
+=item calling_list_exempt
+
+Do not call, empty or 'Y'
+
+=item invoice_ship_address
+
+Display ship_address ("Service address") on invoices for this customer, empty or 'Y'
+
 =back
 
 =head1 METHODS
 =back
 
 =head1 METHODS
@@ -348,6 +305,12 @@ sub table { 'cust_main'; }
 Adds this customer to the database.  If there is an error, returns the error,
 otherwise returns false.
 
 Adds this customer to the database.  If there is an error, returns the error,
 otherwise returns false.
 
+Usually the customer's location will not yet exist in the database, and
+the C<bill_location> and C<ship_location> pseudo-fields must be set to 
+uninserted L<FS::cust_location> objects.  These will be inserted and linked
+(in both directions) to the new customer record.  If they're references 
+to the same object, they will become the same location.
+
 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
 are inserted atomicly, or the transaction is rolled back.  Passing an empty
 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
 are inserted atomicly, or the transaction is rolled back.  Passing an empty
@@ -362,16 +325,10 @@ a better explanation of this, but until then, here's an example:
   );
   $cust_main->insert( \%hash );
 
   );
   $cust_main->insert( \%hash );
 
-INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
-be set as the invoicing list (see L<"invoicing_list">).  Errors return as
-expected and rollback the entire transaction; it is not necessary to call 
-check_invoicing_list first.  The invoicing_list is set after the records in the
-CUST_PKG_HASHREF above are inserted, so it is now possible to set an
-invoicing_list destination to the newly-created svc_acct.  Here's an example:
+INVOICING_LIST_ARYREF: No longer supported.
 
 
-  $cust_main->insert( {}, [ $email, 'POST' ] );
-
-Currently available options are: I<depend_jobnum>, I<noexport> and I<tax_exemption>.
+Currently available options are: I<depend_jobnum>, I<noexport>,
+I<tax_exemption>, I<prospectnum>, I<contact> and I<contact_params>.
 
 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).
 
 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).
@@ -382,15 +339,33 @@ The I<noexport> option is deprecated.  If I<noexport> is set true, no
 provisioning jobs (exports) are scheduled.  (You can schedule them later with
 the B<reexport> method.)
 
 provisioning jobs (exports) are scheduled.  (You can schedule them later with
 the B<reexport> method.)
 
-The I<tax_exemption> option can be set to an arrayref of tax names.
-FS::cust_main_exemption records will be created and inserted.
+The I<tax_exemption> option can be set to an arrayref of tax names or a hashref
+of tax names and exemption numbers.  FS::cust_main_exemption records will be
+created and inserted.
+
+If I<prospectnum> is set, moves contacts and locations from that prospect.
+
+If I<contact> is set to an arrayref of FS::contact objects, those will be
+inserted.
+
+If I<contact_params> is set to a hashref of CGI parameters (and I<contact> is
+unset), inserts those new contacts with this new customer.  Handles CGI
+paramaters for an "m2" multiple entry field as passed by edit/cust_main.cgi
+
+If I<cust_payby_params> is set to a hashref o fCGI parameters, inserts those
+new stored payment records with this new customer.  Handles CGI parameters
+for an "m2" multiple entry field as passed by edit/cust_main.cgi
 
 =cut
 
 sub insert {
   my $self = shift;
   my $cust_pkgs = @_ ? shift : {};
 
 =cut
 
 sub insert {
   my $self = shift;
   my $cust_pkgs = @_ ? shift : {};
-  my $invoicing_list = @_ ? shift : '';
+  my $invoicing_list;
+  if ( $_[0] and ref($_[0]) eq 'ARRAY' ) {
+    warn "cust_main::insert using deprecated invoicing list argument";
+    $invoicing_list = shift;
+  }
   my %options = @_;
   warn "$me insert called with options ".
        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
   my %options = @_;
   warn "$me insert called with options ".
        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
@@ -412,7 +387,7 @@ sub insert {
   my $payby = '';
   if ( $self->payby eq 'PREPAY' ) {
 
   my $payby = '';
   if ( $self->payby eq 'PREPAY' ) {
 
-    $self->payby('BILL');
+    $self->payby(''); #'BILL');
     $prepay_identifier = $self->payinfo;
     $self->payinfo('');
 
     $prepay_identifier = $self->payinfo;
     $self->payinfo('');
 
@@ -434,14 +409,58 @@ sub insert {
 
     $payby = 'PREP' if $amount;
 
 
     $payby = 'PREP' if $amount;
 
-  } elsif ( $self->payby =~ /^(CASH|WEST|MCRD)$/ ) {
+  } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|MCHK|PPAL)$/ ) {
 
     $payby = $1;
 
     $payby = $1;
-    $self->payby('BILL');
+    $self->payby(''); #'BILL');
     $amount = $self->paid;
 
   }
 
     $amount = $self->paid;
 
   }
 
+  # insert locations
+  foreach my $l (qw(bill_location ship_location)) {
+
+    my $loc = delete $self->hashref->{$l} or next;
+
+    if ( !$loc->locationnum ) {
+      # warn the location that we're going to insert it with no custnum
+      $loc->set(custnum_pending => 1);
+      warn "  inserting $l\n"
+        if $DEBUG > 1;
+      my $error = $loc->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        my $label = $l eq 'ship_location' ? 'service' : 'billing';
+        return "$error (in $label location)";
+      }
+
+    } elsif ( $loc->prospectnum ) {
+
+      $loc->prospectnum('');
+      $loc->set(custnum_pending => 1);
+      my $error = $loc->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        my $label = $l eq 'ship_location' ? 'service' : 'billing';
+        return "$error (moving $label location)";
+      }
+
+    } elsif ( ($loc->custnum || 0) > 0 ) {
+      # then it somehow belongs to another customer--shouldn't happen
+      $dbh->rollback if $oldAutoCommit;
+      return "$l belongs to customer ".$loc->custnum;
+    }
+    # else it already belongs to this customer 
+    # (happens when ship_location is identical to bill_location)
+
+    $self->set($l.'num', $loc->locationnum);
+
+    if ( $self->get($l.'num') eq '' ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "$l not set";
+    }
+  }
+
   warn "  inserting $self\n"
     if $DEBUG > 1;
 
   warn "  inserting $self\n"
     if $DEBUG > 1;
 
@@ -450,51 +469,188 @@ sub insert {
   $self->auto_agent_custid()
     if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
 
   $self->auto_agent_custid()
     if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
 
-  my $error = $self->SUPER::insert;
+  my $error =  $self->check_payinfo_cardtype
+            || $self->SUPER::insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     #return "inserting cust_main record (transaction rolled back): $error";
     return $error;
   }
 
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     #return "inserting cust_main record (transaction rolled back): $error";
     return $error;
   }
 
-  warn "  setting invoicing list\n"
+  # now set cust_location.custnum
+  foreach my $l (qw(bill_location ship_location)) {
+    warn "  setting $l.custnum\n"
+      if $DEBUG > 1;
+    my $loc = $self->$l or next;
+    unless ( $loc->custnum ) {
+      $loc->set(custnum => $self->custnum);
+      $error ||= $loc->replace;
+    }
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "error setting $l custnum: $error";
+    }
+  }
+
+  warn "  setting customer tags\n"
     if $DEBUG > 1;
 
     if $DEBUG > 1;
 
-  if ( $invoicing_list ) {
-    $error = $self->check_invoicing_list( $invoicing_list );
+  foreach my $tagnum ( @{ $self->tagnum || [] } ) {
+    my $cust_tag = new FS::cust_tag { 'tagnum'  => $tagnum,
+                                      'custnum' => $self->custnum };
+    my $error = $cust_tag->insert;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      #return "checking invoicing_list (transaction rolled back): $error";
       return $error;
     }
       return $error;
     }
-    $self->invoicing_list( $invoicing_list );
   }
 
   }
 
-  warn "  setting cust_main_exemption\n"
-    if $DEBUG > 1;
+  my $prospectnum = delete $options{'prospectnum'};
+  if ( $prospectnum ) {
 
 
-  my $tax_exemption = delete $options{'tax_exemption'};
-  if ( $tax_exemption ) {
-    foreach my $taxname ( @$tax_exemption ) {
-      my $cust_main_exemption = new FS::cust_main_exemption {
+    warn "  moving contacts and locations from prospect $prospectnum\n"
+      if $DEBUG > 1;
+
+    my $prospect_main =
+      qsearchs('prospect_main', { 'prospectnum' => $prospectnum } );
+    unless ( $prospect_main ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Unknown prospectnum $prospectnum";
+    }
+    $prospect_main->custnum($self->custnum);
+    $prospect_main->disabled('Y');
+    my $error = $prospect_main->replace;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+    foreach my $prospect_contact ( $prospect_main->prospect_contact ) {
+      my $cust_contact = new FS::cust_contact {
         'custnum' => $self->custnum,
         'custnum' => $self->custnum,
-        'taxname' => $taxname,
+        map { $_ => $prospect_contact->$_() } qw( contactnum classnum comment )
       };
       };
-      my $error = $cust_main_exemption->insert;
+      my $error =  $cust_contact->insert
+                || $prospect_contact->delete;
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
-        return "inserting cust_main_exemption (transaction rolled back): $error";
+        return $error;
+      }
+    }
+
+    my @cust_location = $prospect_main->cust_location;
+    my @qual = $prospect_main->qual;
+
+    foreach my $r ( @cust_location, @qual ) {
+      $r->prospectnum('');
+      $r->custnum($self->custnum);
+      my $error = $r->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
       }
     }
       }
     }
+
   }
 
   }
 
-  if (    $conf->config('cust_main-skeleton_tables')
-       && $conf->config('cust_main-skeleton_custnum') ) {
+  warn "  setting contacts\n"
+    if $DEBUG > 1;
 
 
-    warn "  inserting skeleton records\n"
-      if $DEBUG > 1;
+  $invoicing_list ||= $options{'invoicing_list'};
+  if ( $invoicing_list ) {
+
+    $invoicing_list = [ $invoicing_list ] if !ref($invoicing_list);
+
+    my $email = '';
+    foreach my $dest (@$invoicing_list ) {
+      if ($dest eq 'POST') {
+        $self->set('postal_invoice', 'Y');
+      } else {
+
+        my $contact_email = qsearchs('contact_email', { emailaddress => $dest });
+        if ( $contact_email ) {
+          my $cust_contact = FS::cust_contact->new({
+              contactnum    => $contact_email->contactnum,
+              custnum       => $self->custnum,
+          });
+          $cust_contact->set('invoice_dest', 'Y');
+          my $error = $cust_contact->contactnum ?
+                        $cust_contact->replace : $cust_contact->insert;
+          if ( $error ) {
+            $dbh->rollback if $oldAutoCommit;
+            return "$error (linking to email address $dest)";
+          }
+
+        } else {
+          # this email address is not yet linked to any contact
+          $email .= ',' if length($email);
+          $email .= $dest;
+        }
+      }
+    }
+
+    my $contact = FS::contact->new({
+      'custnum'       => $self->get('custnum'),
+      'last'          => $self->get('last'),
+      'first'         => $self->get('first'),
+      'emailaddress'  => $email,
+      'invoice_dest'  => 'Y', # yes, you can set this via the contact
+    });
+    my $error = $contact->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+  }
+
+  if ( my $contact = delete $options{'contact'} ) {
+
+    foreach my $c ( @$contact ) {
+      $c->custnum($self->custnum);
+      my $error = $c->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+
+    }
+
+  } elsif ( my $contact_params = delete $options{'contact_params'} ) {
+
+    my $error = $self->process_o2m( 'table'  => 'contact',
+                                    'fields' => FS::contact->cgi_contact_fields,
+                                    'params' => $contact_params,
+                                  );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  warn "  setting cust_payby\n"
+    if $DEBUG > 1;
+
+  if ( $options{cust_payby} ) {
+
+    foreach my $cust_payby ( @{ $options{cust_payby} } ) {
+      $cust_payby->custnum($self->custnum);
+      my $error = $cust_payby->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
 
 
-    my $error = $self->start_copy_skel;
+  } elsif ( my $cust_payby_params = delete $options{'cust_payby_params'} ) {
+
+    my $error = $self->process_o2m(
+      'table'         => 'cust_payby',
+      'fields'        => FS::cust_payby->cgi_cust_payby_fields,
+      'params'        => $cust_payby_params,
+      'hash_callback' => \&FS::cust_payby::cgi_hash_callback,
+    );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
@@ -502,6 +658,29 @@ sub insert {
 
   }
 
 
   }
 
+  warn "  setting cust_main_exemption\n"
+    if $DEBUG > 1;
+
+  my $tax_exemption = delete $options{'tax_exemption'};
+  if ( $tax_exemption ) {
+
+    $tax_exemption = { map { $_ => '' } @$tax_exemption }
+      if ref($tax_exemption) eq 'ARRAY';
+
+    foreach my $taxname ( keys %$tax_exemption ) {
+      my $cust_main_exemption = new FS::cust_main_exemption {
+        'custnum'       => $self->custnum,
+        'taxname'       => $taxname,
+        'exempt_number' => $tax_exemption->{$taxname},
+      };
+      my $error = $cust_main_exemption->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "inserting cust_main_exemption (transaction rolled back): $error";
+      }
+    }
+  }
+
   warn "  ordering packages\n"
     if $DEBUG > 1;
 
   warn "  ordering packages\n"
     if $DEBUG > 1;
 
@@ -546,6 +725,59 @@ sub insert {
     }
   }
 
     }
   }
 
+  # FS::geocode_Mixin::after_insert or something?
+  if ( $conf->config('tax_district_method') and !$import ) {
+    # if anything non-empty, try to look it up
+    my $queue = new FS::queue {
+      'job'     => 'FS::geocode_Mixin::process_district_update',
+      'custnum' => $self->custnum,
+    };
+    my $error = $queue->insert( ref($self), $self->custnum );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "queueing tax district update: $error";
+    }
+  }
+
+  # cust_main exports!
+  warn "  exporting\n" if $DEBUG > 1;
+
+  my $export_args = $options{'export_args'} || [];
+
+  my @part_export =
+    map qsearch( 'part_export', {exportnum=>$_} ),
+      $conf->config('cust_main-exports'); #, $agentnum
+
+  foreach my $part_export ( @part_export ) {
+    my $error = $part_export->export_insert($self, @$export_args);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "exporting to ". $part_export->exporttype.
+             " (transaction rolled back): $error";
+    }
+  }
+
+  #foreach my $depend_jobnum ( @$depend_jobnums ) {
+  #    warn "[$me] inserting dependancies on supplied job $depend_jobnum\n"
+  #      if $DEBUG;
+  #    foreach my $jobnum ( @jobnums ) {
+  #      my $queue = qsearchs('queue', { 'jobnum' => $jobnum } );
+  #      warn "[$me] inserting dependancy for job $jobnum on $depend_jobnum\n"
+  #        if $DEBUG;
+  #      my $error = $queue->depend_insert($depend_jobnum);
+  #      if ( $error ) {
+  #        $dbh->rollback if $oldAutoCommit;
+  #        return "error queuing job dependancy: $error";
+  #      }
+  #    }
+  #  }
+  #
+  #}
+  #
+  #if ( exists $options{'jobnums'} ) {
+  #  push @{ $options{'jobnums'} }, @jobnums;
+  #}
+
   warn "  insert complete; committing transaction\n"
     if $DEBUG > 1;
 
   warn "  insert complete; committing transaction\n"
     if $DEBUG > 1;
 
@@ -583,392 +815,70 @@ sub auto_agent_custid {
 
 }
 
 
 }
 
-sub start_copy_skel {
-  my $self = shift;
-
-  #'mg_user_preference' => {},
-  #'mg_user_indicator_profile.user_indicator_profile_id' => { 'mg_profile_indicator.profile_indicator_id' => { 'mg_profile_details.profile_detail_id' }, },
-  #'mg_watchlist_header.watchlist_header_id' => { 'mg_watchlist_details.watchlist_details_id' },
-  #'mg_user_grid_header.grid_header_id' => { 'mg_user_grid_details.user_grid_details_id' },
-  #'mg_portfolio_header.portfolio_header_id' => { 'mg_portfolio_trades.portfolio_trades_id' => { 'mg_portfolio_trades_positions.portfolio_trades_positions_id' } },
-  my @tables = eval(join('\n',$conf->config('cust_main-skeleton_tables')));
-  die $@ if $@;
-
-  _copy_skel( 'cust_main',                                 #tablename
-              $conf->config('cust_main-skeleton_custnum'), #sourceid
-              $self->custnum,                              #destid
-              @tables,                                     #child tables
-            );
-}
+=item PACKAGE METHODS
 
 
-#recursive subroutine, not a method
-sub _copy_skel {
-  my( $table, $sourceid, $destid, %child_tables ) = @_;
+Documentation on customer package methods has been moved to
+L<FS::cust_main::Packages>.
 
 
-  my $primary_key;
-  if ( $table =~ /^(\w+)\.(\w+)$/ ) {
-    ( $table, $primary_key ) = ( $1, $2 );
-  } else {
-    my $dbdef_table = dbdef->table($table);
-    $primary_key = $dbdef_table->primary_key
-      or return "$table has no primary key".
-                " (or do you need to run dbdef-create?)";
-  }
-
-  warn "  _copy_skel: $table.$primary_key $sourceid to $destid for ".
-       join (', ', keys %child_tables). "\n"
-    if $DEBUG > 2;
+=item recharge_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , AMOUNTREF, SECONDSREF, UPBYTEREF, DOWNBYTEREF ]
 
 
-  foreach my $child_table_def ( keys %child_tables ) {
+Recharges this (existing) customer with the specified prepaid card (see
+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.
 
 
-    my $child_table;
-    my $child_pkey = '';
-    if ( $child_table_def =~ /^(\w+)\.(\w+)$/ ) {
-      ( $child_table, $child_pkey ) = ( $1, $2 );
-    } else {
-      $child_table = $child_table_def;
+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.
 
 
-      $child_pkey = dbdef->table($child_table)->primary_key;
-      #  or return "$table has no primary key".
-      #            " (or do you need to run dbdef-create?)\n";
-    }
+=cut
 
 
-    my $sequence = '';
-    if ( keys %{ $child_tables{$child_table_def} } ) {
+#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 ) = @_;
 
 
-      return "$child_table has no primary key".
-             " (run dbdef-create or try specifying it?)\n"
-        unless $child_pkey;
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
 
 
-      #false laziness w/Record::insert and only works on Pg
-      #refactor the proper last-inserted-id stuff out of Record::insert if this
-      # ever gets use for anything besides a quick kludge for one customer
-      my $default = dbdef->table($child_table)->column($child_pkey)->default;
-      $default =~ /^nextval\(\(?'"?([\w\.]+)"?'/i
-        or return "can't parse $child_table.$child_pkey default value ".
-                  " for sequence name: $default";
-      $sequence = $1;
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
 
 
-    }
-  
-    my @sel_columns = grep { $_ ne $primary_key }
-                           dbdef->table($child_table)->columns;
-    my $sel_columns = join(', ', @sel_columns );
-
-    my @ins_columns = grep { $_ ne $child_pkey } @sel_columns;
-    my $ins_columns = ' ( '. join(', ', $primary_key, @ins_columns ). ' ) ';
-    my $placeholders = ' ( ?, '. join(', ', map '?', @ins_columns ). ' ) ';
-
-    my $sel_st = "SELECT $sel_columns FROM $child_table".
-                 " WHERE $primary_key = $sourceid";
-    warn "    $sel_st\n"
-      if $DEBUG > 2;
-    my $sel_sth = dbh->prepare( $sel_st )
-      or return dbh->errstr;
-  
-    $sel_sth->execute or return $sel_sth->errstr;
-
-    while ( my $row = $sel_sth->fetchrow_hashref ) {
-
-      warn "    selected row: ".
-           join(', ', map { "$_=".$row->{$_} } keys %$row ). "\n"
-        if $DEBUG > 2;
-
-      my $statement =
-        "INSERT INTO $child_table $ins_columns VALUES $placeholders";
-      my $ins_sth =dbh->prepare($statement)
-          or return dbh->errstr;
-      my @param = ( $destid, map $row->{$_}, @ins_columns );
-      warn "    $statement: [ ". join(', ', @param). " ]\n"
-        if $DEBUG > 2;
-      $ins_sth->execute( @param )
-        or return $ins_sth->errstr;
-
-      #next unless keys %{ $child_tables{$child_table} };
-      next unless $sequence;
-      
-      #another section of that laziness
-      my $seq_sql = "SELECT currval('$sequence')";
-      my $seq_sth = dbh->prepare($seq_sql) or return dbh->errstr;
-      $seq_sth->execute or return $seq_sth->errstr;
-      my $insertid = $seq_sth->fetchrow_arrayref->[0];
-  
-      # don't drink soap!  recurse!  recurse!  okay!
-      my $error =
-        _copy_skel( $child_table_def,
-                    $row->{$child_pkey}, #sourceid
-                    $insertid, #destid
-                    %{ $child_tables{$child_table_def} },
-                  );
-      return $error if $error;
+  my( $amount, $seconds, $upbytes, $downbytes, $totalbytes) = ( 0, 0, 0, 0, 0 );
 
 
-    }
+  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)
+           || $self->increment_totalbytes($totalbytes)
+           || $self->insert_cust_pay_prepay( $amount,
+                                             ref($prepay_credit)
+                                               ? $prepay_credit->identifier
+                                               : $prepay_credit
+                                           );
 
 
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
   }
 
   }
 
-  return '';
-
-}
-
-=item order_pkg HASHREF | OPTION => VALUE ... 
-
-Orders a single package.
-
-Options may be passed as a list of key/value pairs or as a hash reference.
-Options are:
-
-=over 4
-
-=item cust_pkg
-
-FS::cust_pkg object
-
-=item cust_location
-
-Optional FS::cust_location object
-
-=item svcs
-
-Optional arryaref of FS::svc_* service objects.
-
-=item depend_jobnum
-
-If this option is set to a job queue jobnum (see L<FS::queue>), all provisioning
-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
-
-sub order_pkg {
-  my $self = shift;
-  my $opt = ref($_[0]) ? shift : { @_ };
-
-  warn "$me order_pkg called with options ".
-       join(', ', map { "$_: $opt->{$_}" } keys %$opt ). "\n"
-    if $DEBUG;
-
-  my $cust_pkg = $opt->{'cust_pkg'};
-  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';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  if ( $opt->{'cust_location'} &&
-       ( ! $cust_pkg->locationnum || $cust_pkg->locationnum == -1 ) ) {
-    my $error = $opt->{'cust_location'}->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "inserting cust_location (transaction rolled back): $error";
-    }
-    $cust_pkg->locationnum($opt->{'cust_location'}->locationnum);
-  }
-
-  $cust_pkg->custnum( $self->custnum );
-
-  my $error = $cust_pkg->insert( %insert_params );
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "inserting cust_pkg (transaction rolled back): $error";
-  }
-
-  foreach my $svc_something ( @{ $opt->{'svcs'} } ) {
-    if ( $svc_something->svcnum ) {
-      my $old_cust_svc = $svc_something->cust_svc;
-      my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
-      $new_cust_svc->pkgnum( $cust_pkg->pkgnum);
-      $error = $new_cust_svc->replace($old_cust_svc);
-    } else {
-      $svc_something->pkgnum( $cust_pkg->pkgnum );
-      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);
-    }
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "inserting svc_ (transaction rolled back): $error";
-    }
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  ''; #no error
-
-}
-
-#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
-to this method containing FS::cust_pkg and FS::svc_I<tablename> objects.
-There should be a better explanation of this, but until then, here's an
-example:
-
-  use Tie::RefHash;
-  tie %hash, 'Tie::RefHash'; #this part is important
-  %hash = (
-    $cust_pkg => [ $svc_acct ],
-    ...
-  );
-  $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>, 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).
-This can be used to defer provisioning until some action completes (such
-as running the customer's credit card successfully).
-
-The I<noexport> option is deprecated.  If I<noexport> is set true, no
-provisioning jobs (exports) are scheduled.  (You can schedule them later with
-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_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"
-    if $DEBUG;
-
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
-
-  foreach my $cust_pkg ( keys %$cust_pkgs ) {
-
-    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;
-    }
-
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  ''; #no error
-}
-
-=item recharge_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , AMOUNTREF, SECONDSREF, UPBYTEREF, DOWNBYTEREF ]
-
-Recharges this (existing) customer with the specified prepaid card (see
-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, 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 ) = @_;
-
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  my( $amount, $seconds, $upbytes, $downbytes, $totalbytes) = ( 0, 0, 0, 0, 0 );
-
-  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)
-           || $self->increment_totalbytes($totalbytes)
-           || $self->insert_cust_pay_prepay( $amount,
-                                             ref($prepay_credit)
-                                               ? $prepay_credit->identifier
-                                               : $prepay_credit
-                                           );
-
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
-  if ( defined($amountref)  ) { $$amountref  = $amount;  }
-  if ( defined($secondsref) ) { $$secondsref = $seconds; }
-  if ( defined($upbytesref) ) { $$upbytesref = $upbytes; }
-  if ( defined($downbytesref) ) { $$downbytesref = $downbytes; }
-  if ( defined($totalbytesref) ) { $$totalbytesref = $totalbytes; }
+  if ( defined($amountref)  ) { $$amountref  = $amount;  }
+  if ( defined($secondsref) ) { $$secondsref = $seconds; }
+  if ( defined($upbytesref) ) { $$upbytesref = $upbytes; }
+  if ( defined($downbytesref) ) { $$downbytesref = $downbytes; }
+  if ( defined($totalbytesref) ) { $$totalbytesref = $totalbytes; }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
@@ -1011,7 +921,7 @@ sub get_prepay {
 
     $prepay_credit = qsearchs(
       'prepay_credit',
 
     $prepay_credit = qsearchs(
       'prepay_credit',
-      { 'identifier' => $prepay_credit },
+      { 'identifier' => $identifier },
       '',
       'FOR UPDATE'
     );
       '',
       'FOR UPDATE'
     );
@@ -1193,48 +1103,7 @@ sub insert_cust_pay {
 
 }
 
 
 }
 
-=item reexport
-
-This method is deprecated.  See the I<depend_jobnum> option to the insert and
-order_pkgs methods for a better way to defer provisioning.
-
-Re-schedules all exports by calling the B<reexport> method of all associated
-packages (see L<FS::cust_pkg>).  If there is an error, returns the error;
-otherwise returns false.
-
-=cut
-
-sub reexport {
-  my $self = shift;
-
-  carp "WARNING: FS::cust_main::reexport is deprectated; ".
-       "use the depend_jobnum option to insert or order_pkgs to delay export";
-
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
-    my $error = $cust_pkg->reexport;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
-
-}
-
-=item delete NEW_CUSTNUM
+=item delete [ OPTION => VALUE ... ]
 
 This deletes the customer.  If there is an error, returns the error, otherwise
 returns false.
 
 This deletes the customer.  If there is an error, returns the error, otherwise
 returns false.
@@ -1244,18 +1113,20 @@ what you want when a customer cancels service; for that, cancel all of the
 customer's packages (see L</cancel>).
 
 If the customer has any uncancelled packages, you need to pass a new (valid)
 customer's packages (see L</cancel>).
 
 If the customer has any uncancelled packages, you need to pass a new (valid)
-customer number for those packages to be transferred to.  Cancelled packages
-will be deleted.  Did I mention that this is NOT what you want when a customer
-cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
+customer number for those packages to be transferred to, as the "new_customer"
+option.  Cancelled packages will be deleted.  Did I mention that this is NOT
+what you want when a customer cancels service and that you really should be
+looking at L<FS::cust_pkg/cancel>?  
 
 You can't delete a customer with invoices (see L<FS::cust_bill>),
 
 You can't delete a customer with invoices (see L<FS::cust_bill>),
-or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
-refunds (see L<FS::cust_refund>).
+statements (see L<FS::cust_statement>), credits (see L<FS::cust_credit>),
+payments (see L<FS::cust_pay>) or refunds (see L<FS::cust_refund>), unless you
+set the "delete_financials" option to a true value.
 
 =cut
 
 sub delete {
 
 =cut
 
 sub delete {
-  my $self = shift;
+  my( $self, %opt ) = @_;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -1268,26 +1139,47 @@ sub delete {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  if ( $self->cust_bill ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "Can't delete a customer with invoices";
+  if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) {
+     $dbh->rollback if $oldAutoCommit;
+     return "Can't delete a master agent customer";
   }
   }
-  if ( $self->cust_credit ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "Can't delete a customer with credits";
-  }
-  if ( $self->cust_pay ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "Can't delete a customer with payments";
+
+  #use FS::access_user
+  if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) {
+     $dbh->rollback if $oldAutoCommit;
+     return "Can't delete a master employee customer";
   }
   }
-  if ( $self->cust_refund ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "Can't delete a customer with refunds";
+
+  tie my %financial_tables, 'Tie::IxHash',
+    'cust_bill'      => 'invoices',
+    'cust_statement' => 'statements',
+    'cust_credit'    => 'credits',
+    'cust_pay'       => 'payments',
+    'cust_refund'    => 'refunds',
+  ;
+   
+  foreach my $table ( keys %financial_tables ) {
+
+    my @records = $self->$table();
+
+    if ( @records && ! $opt{'delete_financials'} ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Can't delete a customer with ". $financial_tables{$table};
+    }
+
+    foreach my $record ( @records ) {
+      my $error = $record->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error deleting ". $financial_tables{$table}. ": $error\n";
+      }
+    }
+
   }
 
   my @cust_pkg = $self->ncancelled_pkgs;
   if ( @cust_pkg ) {
   }
 
   my @cust_pkg = $self->ncancelled_pkgs;
   if ( @cust_pkg ) {
-    my $new_custnum = shift;
+    my $new_custnum = $opt{'new_custnum'};
     unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
       $dbh->rollback if $oldAutoCommit;
       return "Invalid new customer number: $new_custnum";
     unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
       $dbh->rollback if $oldAutoCommit;
       return "Invalid new customer number: $new_custnum";
@@ -1314,32 +1206,93 @@ sub delete {
     }
   }
 
     }
   }
 
-  foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
-    qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
-  ) {
-    my $error = $cust_main_invoice->delete;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
+  #cust_tax_adjustment in financials?
+  #cust_pay_pending?  ouch
+  foreach my $table (qw(
+    cust_main_invoice cust_main_exemption cust_tag cust_attachment contact
+    cust_payby cust_location cust_main_note cust_tax_adjustment
+    cust_pay_void cust_pay_batch queue cust_tax_exempt
+  )) {
+    foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
+      my $error = $record->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
     }
   }
 
     }
   }
 
-  foreach my $cust_main_exemption (
-    qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } )
-  ) {
-    my $error = $cust_main_exemption->delete;
-    if ( $error ) {
+  my $sth = $dbh->prepare(
+    'UPDATE cust_main SET referral_custnum = NULL WHERE referral_custnum = ?'
+  ) or do {
+    my $errstr = $dbh->errstr;
+    $dbh->rollback if $oldAutoCommit;
+    return $errstr;
+  };
+  $sth->execute($self->custnum) or do {
+    my $errstr = $sth->errstr;
+    $dbh->rollback if $oldAutoCommit;
+    return $errstr;
+  };
+
+  #tickets
+
+  my $ticket_dbh = '';
+  if ($conf->config('ticket_system') eq 'RT_Internal') {
+    $ticket_dbh = $dbh;
+  } elsif ($conf->config('ticket_system') eq 'RT_External') {
+    my ($datasrc, $user, $pass) = $conf->config('ticket_system-rt_external_datasrc');
+    $ticket_dbh = DBI->connect($datasrc, $user, $pass, { 'ChopBlanks' => 1 });
+      #or die "RT_External DBI->connect error: $DBI::errstr\n";
+  }
+
+  if ( $ticket_dbh ) {
+
+    my $ticket_sth = $ticket_dbh->prepare(
+      'DELETE FROM Links WHERE Target = ?'
+    ) or do {
+      my $errstr = $ticket_dbh->errstr;
       $dbh->rollback if $oldAutoCommit;
       $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }
+      return $errstr;
+    };
+    $ticket_sth->execute('freeside://freeside/cust_main/'.$self->custnum)
+      or do {
+        my $errstr = $ticket_sth->errstr;
+        $dbh->rollback if $oldAutoCommit;
+        return $errstr;
+      };
+
+    #check and see if the customer is the only link on the ticket, and
+    #if so, set the ticket to deleted status in RT?
+    #maybe someday, for now this will at least fix tickets not displaying
+
   }
 
   }
 
+  #delete the customer record
+
   my $error = $self->SUPER::delete;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   }
 
   my $error = $self->SUPER::delete;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   }
 
+  # cust_main exports!
+
+  #my $export_args = $options{'export_args'} || [];
+
+  my @part_export =
+    map qsearch( 'part_export', {exportnum=>$_} ),
+      $conf->config('cust_main-exports'); #, $agentnum
+
+  foreach my $part_export ( @part_export ) {
+    my $error = $part_export->export_delete( $self ); #, @$export_args);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "exporting to ". $part_export->exporttype.
+             " (transaction rolled back): $error";
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 
@@ -1347,21 +1300,31 @@ sub delete {
 
 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
 
 
 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
 
-
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
-INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
-be set as the invoicing list (see L<"invoicing_list">).  Errors return as
-expected and rollback the entire transaction; it is not necessary to call 
-check_invoicing_list first.  Here's an example:
+To change the customer's address, set the pseudo-fields C<bill_location> and
+C<ship_location>.  The address will still only change if at least one of the
+address fields differs from the existing values.
+
+INVOICING_LIST_ARYREF: If you pass an arrayref to this method, it will be
+set as the contact email address for a default contact with the same name as
+the customer.
 
 
-  $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
+Currently available options are: I<tax_exemption>, I<cust_payby_params>, 
+I<contact_params>, I<invoicing_list>.
 
 
-Currently available options are: I<tax_exemption>.
+The I<tax_exemption> option can be set to an arrayref of tax names or a hashref
+of tax names and exemption numbers.  FS::cust_main_exemption records will be
+deleted and inserted as appropriate.
 
 
-The I<tax_exemption> option can be set to an arrayref of tax names.
-FS::cust_main_exemption records will be deleted and inserted as appropriate.
+I<cust_payby_params> and I<contact_params> can be hashrefs of named parameter
+groups (describing the customer's payment methods and contacts, respectively)
+in the style supported by L<FS::o2m_Common/process_o2m>. See L<FS::cust_payby>
+and L<FS::contact> for the fields these can contain.
+
+I<invoicing_list> is a synonym for the INVOICING_LIST_ARYREF parameter, and
+should be used instead if possible.
 
 =cut
 
 
 =cut
 
@@ -1378,19 +1341,34 @@ sub replace {
     if $DEBUG;
 
   my $curuser = $FS::CurrentUser::CurrentUser;
     if $DEBUG;
 
   my $curuser = $FS::CurrentUser::CurrentUser;
-  if (    $self->payby eq 'COMP'
-       && $self->payby ne $old->payby
-       && ! $curuser->access_right('Complimentary customer')
-     )
-  {
-    return "You are not permitted to create complimentary accounts.";
-  }
+  return "You are not permitted to create complimentary accounts."
+    if $self->complimentary eq 'Y'
+    && $self->complimentary ne $old->complimentary
+    && ! $curuser->access_right('Complimentary customer');
 
   local($ignore_expired_card) = 1
     if $old->payby  =~ /^(CARD|DCRD)$/
     && $self->payby =~ /^(CARD|DCRD)$/
     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
 
 
   local($ignore_expired_card) = 1
     if $old->payby  =~ /^(CARD|DCRD)$/
     && $self->payby =~ /^(CARD|DCRD)$/
     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
 
+  local($ignore_banned_card) = 1
+    if (    $old->payby  =~ /^(CARD|DCRD)$/ && $self->payby =~ /^(CARD|DCRD)$/
+         || $old->payby  =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
+    && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
+
+  if (    $self->payby =~ /^(CARD|DCRD)$/
+       && $old->payinfo ne $self->payinfo
+       && $old->paymask ne $self->paymask )
+  {
+    my $error = $self->check_payinfo_cardtype;
+    return $error if $error;
+  }
+
+  return "Invoicing locale is required"
+    if $old->locale
+    && ! $self->locale
+    && $conf->exists('cust_main-require_locale');
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -1402,6 +1380,135 @@ sub replace {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  for my $l (qw(bill_location ship_location)) {
+    #my $old_loc = $old->$l;
+    my $new_loc = $self->$l or next;
+
+    # find the existing location if there is one
+    $new_loc->set('custnum' => $self->custnum);
+    my $error = $new_loc->find_or_insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+    $self->set($l.'num', $new_loc->locationnum);
+  } #for $l
+
+  my $invoicing_list;
+  if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
+    warn "cust_main::replace: using deprecated invoicing list argument";
+    $invoicing_list = shift @param;
+  }
+
+  my %options = @param;
+
+  $invoicing_list ||= $options{invoicing_list};
+
+  my @contacts = map { $_->contact } $self->cust_contact;
+  # find a contact that matches the customer's name
+  my ($implicit_contact) = grep { $_->first eq $old->get('first')
+                              and $_->last  eq $old->get('last') }
+                            @contacts;
+  $implicit_contact ||= FS::contact->new({
+      'custnum'       => $self->custnum,
+      'locationnum'   => $self->get('bill_locationnum'),
+  });
+
+  # for any of these that are already contact emails, link to the existing
+  # contact
+  if ( $invoicing_list ) {
+    my $email = '';
+
+    # kind of like process_m2m on these, except:
+    # - the other side is two tables in a join
+    # - and we might have to create new contact_emails
+    # - and possibly a new contact
+    # 
+    # Find existing invoice emails that aren't on the implicit contact.
+    # Any of these that are not on the new invoicing list will be removed.
+    my %old_email_cust_contact;
+    foreach my $cust_contact ($self->cust_contact) {
+      next if !$cust_contact->invoice_dest;
+      next if $cust_contact->contactnum == ($implicit_contact->contactnum || 0);
+
+      foreach my $contact_email ($cust_contact->contact->contact_email) {
+        $old_email_cust_contact{ $contact_email->emailaddress } = $cust_contact;
+      }
+    }
+
+    foreach my $dest (@$invoicing_list) {
+
+      if ($dest eq 'POST') {
+
+        $self->set('postal_invoice', 'Y');
+
+      } elsif ( exists($old_email_cust_contact{$dest}) ) {
+
+        delete $old_email_cust_contact{$dest}; # don't need to remove it, then
+
+      } else {
+
+        # See if it belongs to some other contact; if so, link it.
+        my $contact_email = qsearchs('contact_email', { emailaddress => $dest });
+        if ( $contact_email
+             and $contact_email->contactnum != ($implicit_contact->contactnum || 0) ) {
+          my $cust_contact = qsearchs('cust_contact', {
+              contactnum  => $contact_email->contactnum,
+              custnum     => $self->custnum,
+          }) || FS::cust_contact->new({
+              contactnum    => $contact_email->contactnum,
+              custnum       => $self->custnum,
+          });
+          $cust_contact->set('invoice_dest', 'Y');
+          my $error = $cust_contact->custcontactnum ?
+                        $cust_contact->replace : $cust_contact->insert;
+          if ( $error ) {
+            $dbh->rollback if $oldAutoCommit;
+            return "$error (linking to email address $dest)";
+          }
+
+        } else {
+          # This email address is not yet linked to any contact, so it will
+          # be added to the implicit contact.
+          $email .= ',' if length($email);
+          $email .= $dest;
+        }
+      }
+    }
+
+    foreach my $remove_dest (keys %old_email_cust_contact) {
+      my $cust_contact = $old_email_cust_contact{$remove_dest};
+      # These were not in the list of requested destinations, so take them off.
+      $cust_contact->set('invoice_dest', '');
+      my $error = $cust_contact->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "$error (unlinking email address $remove_dest)";
+      }
+    }
+
+    # make sure it keeps up with the changed customer name, if any
+    $implicit_contact->set('last', $self->get('last'));
+    $implicit_contact->set('first', $self->get('first'));
+    $implicit_contact->set('emailaddress', $email);
+    $implicit_contact->set('invoice_dest', 'Y');
+    $implicit_contact->set('custnum', $self->custnum);
+
+    my $error;
+    if ( $implicit_contact->contactnum ) {
+      $error = $implicit_contact->replace;
+    } elsif ( length($email) ) { # don't create a new contact if not needed
+      $error = $implicit_contact->insert;
+    }
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "$error (adding email address $email)";
+    }
+
+  }
+
+  # replace the customer record
   my $error = $self->SUPER::replace($old);
 
   if ( $error ) {
   my $error = $self->SUPER::replace($old);
 
   if ( $error ) {
@@ -1409,32 +1516,73 @@ sub replace {
     return $error;
   }
 
     return $error;
   }
 
-  if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
-    my $invoicing_list = shift @param;
-    $error = $self->check_invoicing_list( $invoicing_list );
+  # now move packages to the new service location
+  $self->set('ship_location', ''); #flush cache
+  if ( $old->ship_locationnum and # should only be null during upgrade...
+       $old->ship_locationnum != $self->ship_locationnum ) {
+    $error = $old->ship_location->move_to($self->ship_location);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+  # don't move packages based on the billing location, but 
+  # disable it if it's no longer in use
+  if ( $old->bill_locationnum and
+       $old->bill_locationnum != $self->bill_locationnum ) {
+    $error = $old->bill_location->disable_if_unused;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
-    $self->invoicing_list( $invoicing_list );
   }
 
   }
 
-  my %options = @param;
+  if ( $self->exists('tagnum') ) { #so we don't delete these on edit by accident
+
+    #this could be more efficient than deleting and re-inserting, if it matters
+    foreach my $cust_tag (qsearch('cust_tag', {'custnum'=>$self->custnum} )) {
+      my $error = $cust_tag->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+    foreach my $tagnum ( @{ $self->tagnum || [] } ) {
+      my $cust_tag = new FS::cust_tag { 'tagnum'  => $tagnum,
+                                        'custnum' => $self->custnum };
+      my $error = $cust_tag->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
+  }
 
   my $tax_exemption = delete $options{'tax_exemption'};
   if ( $tax_exemption ) {
 
 
   my $tax_exemption = delete $options{'tax_exemption'};
   if ( $tax_exemption ) {
 
+    $tax_exemption = { map { $_ => '' } @$tax_exemption }
+      if ref($tax_exemption) eq 'ARRAY';
+
     my %cust_main_exemption =
       map { $_->taxname => $_ }
           qsearch('cust_main_exemption', { 'custnum' => $old->custnum } );
 
     my %cust_main_exemption =
       map { $_->taxname => $_ }
           qsearch('cust_main_exemption', { 'custnum' => $old->custnum } );
 
-    foreach my $taxname ( @$tax_exemption ) {
+    foreach my $taxname ( keys %$tax_exemption ) {
 
 
-      next if delete $cust_main_exemption{$taxname};
+      if ( $cust_main_exemption{$taxname} && 
+           $cust_main_exemption{$taxname}->exempt_number eq $tax_exemption->{$taxname}
+         )
+      {
+        delete $cust_main_exemption{$taxname};
+        next;
+      }
 
       my $cust_main_exemption = new FS::cust_main_exemption {
 
       my $cust_main_exemption = new FS::cust_main_exemption {
-        'custnum' => $self->custnum,
-        'taxname' => $taxname,
+        'custnum'       => $self->custnum,
+        'taxname'       => $taxname,
+        'exempt_number' => $tax_exemption->{$taxname},
       };
       my $error = $cust_main_exemption->insert;
       if ( $error ) {
       };
       my $error = $cust_main_exemption->insert;
       if ( $error ) {
@@ -1453,14 +1601,37 @@ sub replace {
 
   }
 
 
   }
 
-  if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
-       grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
-    # card/check/lec info has changed, want to retry realtime_ invoice events
-    my $error = $self->retry_realtime;
+  if ( my $cust_payby_params = delete $options{'cust_payby_params'} ) {
+
+    my $error = $self->process_o2m(
+      'table'         => 'cust_payby',
+      'fields'        => FS::cust_payby->cgi_cust_payby_fields,
+      'params'        => $cust_payby_params,
+      'hash_callback' => \&FS::cust_payby::cgi_hash_callback,
+    );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+  }
+
+  if ( my $contact_params = delete $options{'contact_params'} ) {
+
+    # this can potentially replace contacts that were created by the
+    # invoicing list argument, but the UI shouldn't allow both of them
+    # to be specified
+
+    my $error = $self->process_o2m(
+      'table'         => 'contact',
+      'fields'        => FS::contact->cgi_contact_fields,
+      'params'        => $contact_params,
+    );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
+
   }
 
   unless ( $import || $skip_fuzzyfiles ) {
   }
 
   unless ( $import || $skip_fuzzyfiles ) {
@@ -1471,6 +1642,25 @@ sub replace {
     }
   }
 
     }
   }
 
+  # tax district update in cust_location
+
+  # cust_main exports!
+
+  my $export_args = $options{'export_args'} || [];
+
+  my @part_export =
+    map qsearch( 'part_export', {exportnum=>$_} ),
+      $conf->config('cust_main-exports'); #, $agentnum
+
+  foreach my $part_export ( @part_export ) {
+    my $error = $part_export->export_replace( $self, $old, @$export_args);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "exporting to ". $part_export->exporttype.
+             " (transaction rolled back): $error";
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
 
@@ -1482,6 +1672,7 @@ Used by insert & replace to update the fuzzy search cache
 
 =cut
 
 
 =cut
 
+use FS::cust_main::Search;
 sub queue_fuzzyfiles_update {
   my $self = shift;
 
 sub queue_fuzzyfiles_update {
   my $self = shift;
 
@@ -1496,16 +1687,27 @@ sub queue_fuzzyfiles_update {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
-  my $error = $queue->insert( map $self->getfield($_), @fuzzyfields );
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "queueing job (transaction rolled back): $error";
+  foreach my $field ( 'first', 'last', 'company', 'ship_company' ) {
+    my $queue = new FS::queue { 
+      'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
+    };
+    my @args = "cust_main.$field", $self->get($field);
+    my $error = $queue->insert( @args );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "queueing job (transaction rolled back): $error";
+    }
   }
 
   }
 
-  if ( $self->ship_last ) {
-    $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
-    $error = $queue->insert( map $self->getfield("ship_$_"), @fuzzyfields );
+  my @locations = ();
+  push @locations, $self->bill_location if $self->bill_locationnum;
+  push @locations, $self->ship_location if @locations && $self->has_ship_address;
+  foreach my $location (@locations) {
+    my $queue = new FS::queue { 
+      'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield'
+    };
+    my @args = 'cust_location.address1', $location->address1;
+    my $error = $queue->insert( @args );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "queueing job (transaction rolled back): $error";
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "queueing job (transaction rolled back): $error";
@@ -1536,35 +1738,67 @@ sub check {
     || $self->ut_number('agentnum')
     || $self->ut_textn('agent_custid')
     || $self->ut_number('refnum')
     || $self->ut_number('agentnum')
     || $self->ut_textn('agent_custid')
     || $self->ut_number('refnum')
+    || $self->ut_foreign_keyn('bill_locationnum', 'cust_location','locationnum')
+    || $self->ut_foreign_keyn('ship_locationnum', 'cust_location','locationnum')
     || $self->ut_foreign_keyn('classnum', 'cust_class', 'classnum')
     || $self->ut_foreign_keyn('classnum', 'cust_class', 'classnum')
+    || $self->ut_foreign_keyn('salesnum', 'sales', 'salesnum')
+    || $self->ut_foreign_keyn('taxstatusnum', 'tax_status', 'taxstatusnum')
     || $self->ut_textn('custbatch')
     || $self->ut_name('last')
     || $self->ut_name('first')
     || $self->ut_textn('custbatch')
     || $self->ut_name('last')
     || $self->ut_name('first')
-    || $self->ut_snumbern('birthdate')
     || $self->ut_snumbern('signupdate')
     || $self->ut_snumbern('signupdate')
+    || $self->ut_snumbern('birthdate')
+    || $self->ut_namen('spouse_last')
+    || $self->ut_namen('spouse_first')
+    || $self->ut_snumbern('spouse_birthdate')
+    || $self->ut_snumbern('anniversary_date')
     || $self->ut_textn('company')
     || $self->ut_textn('company')
-    || $self->ut_text('address1')
-    || $self->ut_textn('address2')
-    || $self->ut_text('city')
-    || $self->ut_textn('county')
-    || $self->ut_textn('state')
-    || $self->ut_country('country')
+    || $self->ut_textn('ship_company')
     || $self->ut_anything('comments')
     || $self->ut_numbern('referral_custnum')
     || $self->ut_textn('stateid')
     || $self->ut_textn('stateid_state')
     || $self->ut_textn('invoice_terms')
     || $self->ut_anything('comments')
     || $self->ut_numbern('referral_custnum')
     || $self->ut_textn('stateid')
     || $self->ut_textn('stateid_state')
     || $self->ut_textn('invoice_terms')
-    || $self->ut_alphan('geocode')
     || $self->ut_floatn('cdr_termination_percentage')
     || $self->ut_floatn('cdr_termination_percentage')
+    || $self->ut_floatn('credit_limit')
+    || $self->ut_numbern('billday')
+    || $self->ut_numbern('prorate_day')
+    || $self->ut_flag('edit_subject')
+    || $self->ut_flag('calling_list_exempt')
+    || $self->ut_flag('invoice_noemail')
+    || $self->ut_flag('message_noemail')
+    || $self->ut_enum('locale', [ '', FS::Locales->locales ])
+    || $self->ut_currencyn('currency')
+    || $self->ut_alphan('po_number')
+    || $self->ut_enum('complimentary', [ '', 'Y' ])
+    || $self->ut_flag('invoice_ship_address')
+    || $self->ut_flag('invoice_dest')
   ;
 
   ;
 
+  foreach (qw(company ship_company)) {
+    my $company = $self->get($_);
+    $company =~ s/^\s+//; 
+    $company =~ s/\s+$//; 
+    $company =~ s/\s+/ /g;
+    $self->set($_, $company);
+  }
+
   #barf.  need message catalogs.  i18n.  etc.
   $error .= "Please select an advertising source."
     if $error =~ /^Illegal or empty \(numeric\) refnum: /;
   return $error if $error;
 
   #barf.  need message catalogs.  i18n.  etc.
   $error .= "Please select an advertising source."
     if $error =~ /^Illegal or empty \(numeric\) refnum: /;
   return $error if $error;
 
-  return "Unknown agent"
-    unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
+  my $agent = qsearchs( 'agent', { 'agentnum' => $self->agentnum } )
+    or return "Unknown agent";
+
+  if ( $self->currency ) {
+    my $agent_currency = qsearchs( 'agent_currency', {
+      'agentnum' => $agent->agentnum,
+      'currency' => $self->currency,
+    })
+      or return "Agent ". $agent->agent.
+                " not permitted to offer ".  $self->currency. " invoicing";
+  }
 
   return "Unknown refnum"
     unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
 
   return "Unknown refnum"
     unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
@@ -1573,13 +1807,6 @@ sub check {
     unless ! $self->referral_custnum 
            || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
 
     unless ! $self->referral_custnum 
            || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
 
-  if ( $self->censustract ne '' ) {
-    $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
-      or return "Illegal census tract: ". $self->censustract;
-    
-    $self->censustract("$1.$2");
-  }
-
   if ( $self->ss eq '' ) {
     $self->ss('');
   } else {
   if ( $self->ss eq '' ) {
     $self->ss('');
   } else {
@@ -1590,33 +1817,24 @@ sub check {
     $self->ss("$1-$2-$3");
   }
 
     $self->ss("$1-$2-$3");
   }
 
+  #turn off invoice_ship_address if ship & bill are the same
+  if ($self->bill_locationnum eq $self->ship_locationnum) {
+    $self->invoice_ship_address('');
+  }
 
 
-# bad idea to disable, causes billing to fail because of no tax rates later
-#  unless ( $import ) {
-    unless ( qsearch('cust_main_county', {
-      'country' => $self->country,
-      'state'   => '',
-     } ) ) {
-      return "Unknown state/county/country: ".
-        $self->state. "/". $self->county. "/". $self->country
-        unless qsearch('cust_main_county',{
-          'state'   => $self->state,
-          'county'  => $self->county,
-          'country' => $self->country,
-        } );
-    }
-#  }
+  # cust_main_county verification now handled by cust_location check
 
   $error =
 
   $error =
-    $self->ut_phonen('daytime', $self->country)
-    || $self->ut_phonen('night', $self->country)
-    || $self->ut_phonen('fax', $self->country)
-    || $self->ut_zip('zip', $self->country)
+       $self->ut_phonen('daytime', $self->country)
+    || $self->ut_phonen('night',   $self->country)
+    || $self->ut_phonen('fax',     $self->country)
+    || $self->ut_phonen('mobile',  $self->country)
   ;
   return $error if $error;
 
   ;
   return $error if $error;
 
-  if ( $conf->exists('cust_main-require_phone')
-       && ! length($self->daytime) && ! length($self->night)
+  if ( $conf->exists('cust_main-require_phone', $self->agentnum)
+       && ! $import
+       && ! length($self->daytime) && ! length($self->night) && ! length($self->mobile)
      ) {
 
     my $daytime_label = FS::Msgcat::_gettext('daytime') =~ /^(daytime)?$/
      ) {
 
     my $daytime_label = FS::Msgcat::_gettext('daytime') =~ /^(daytime)?$/
@@ -1625,265 +1843,65 @@ sub check {
     my $night_label = FS::Msgcat::_gettext('night') =~ /^(night)?$/
                         ? 'Night Phone'
                         : FS::Msgcat::_gettext('night');
     my $night_label = FS::Msgcat::_gettext('night') =~ /^(night)?$/
                         ? 'Night Phone'
                         : FS::Msgcat::_gettext('night');
-  
-    return "$daytime_label or $night_label is required"
-  
-  }
-
-  if ( $self->has_ship_address
-       && scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
-                        $self->addr_fields )
-     )
-  {
-    my $error =
-      $self->ut_name('ship_last')
-      || $self->ut_name('ship_first')
-      || $self->ut_textn('ship_company')
-      || $self->ut_text('ship_address1')
-      || $self->ut_textn('ship_address2')
-      || $self->ut_text('ship_city')
-      || $self->ut_textn('ship_county')
-      || $self->ut_textn('ship_state')
-      || $self->ut_country('ship_country')
-    ;
-    return $error if $error;
-
-    #false laziness with above
-    unless ( qsearchs('cust_main_county', {
-      'country' => $self->ship_country,
-      'state'   => '',
-     } ) ) {
-      return "Unknown ship_state/ship_county/ship_country: ".
-        $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
-        unless qsearch('cust_main_county',{
-          'state'   => $self->ship_state,
-          'county'  => $self->ship_county,
-          'country' => $self->ship_country,
-        } );
-    }
-    #eofalse
-
-    $error =
-      $self->ut_phonen('ship_daytime', $self->ship_country)
-      || $self->ut_phonen('ship_night', $self->ship_country)
-      || $self->ut_phonen('ship_fax', $self->ship_country)
-      || $self->ut_zip('ship_zip', $self->ship_country)
-    ;
-    return $error if $error;
 
 
-    return "Unit # is required."
-      if $self->ship_address2 =~ /^\s*$/
-      && $conf->exists('cust_main-require_address2');
+    my $mobile_label = FS::Msgcat::_gettext('mobile') =~ /^(mobile)?$/
+                        ? 'Mobile Phone'
+                        : FS::Msgcat::_gettext('mobile');
 
 
-  } else { # ship_ info eq billing info, so don't store dup info in database
-
-    $self->setfield("ship_$_", '')
-      foreach $self->addr_fields;
-
-    return "Unit # is required."
-      if $self->address2 =~ /^\s*$/
-      && $conf->exists('cust_main-require_address2');
-
-  }
-
-  #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
-  #  or return "Illegal payby: ". $self->payby;
-  #$self->payby($1);
-  FS::payby->can_payby($self->table, $self->payby)
-    or return "Illegal payby: ". $self->payby;
-
-  $error =    $self->ut_numbern('paystart_month')
-           || $self->ut_numbern('paystart_year')
-           || $self->ut_numbern('payissue')
-           || $self->ut_textn('paytype')
-  ;
-  return $error if $error;
-
-  if ( $self->payip eq '' ) {
-    $self->payip('');
-  } else {
-    $error = $self->ut_ip('payip');
-    return $error if $error;
+    return "$daytime_label, $night_label or $mobile_label is required"
+  
   }
 
   }
 
-  # If it is encrypted and the private key is not availaible then we can't
-  # check the credit card.
+  return "Please select an invoicing locale"
+    if ! $self->locale
+    && ! $self->custnum
+    && $conf->exists('cust_main-require_locale');
 
 
-  my $check_payinfo = 1;
-
-  if ($self->is_encrypted($self->payinfo)) {
-    $check_payinfo = 0;
+  foreach my $flag (qw( tax spool_cdr squelch_cdr archived email_csv_cdr )) {
+    $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
+    $self->$flag($1);
   }
 
   }
 
-  if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
-
-    my $payinfo = $self->payinfo;
-    $payinfo =~ s/\D//g;
-    $payinfo =~ /^(\d{13,16})$/
-      or return gettext('invalid_card'); # . ": ". $self->payinfo;
-    $payinfo = $1;
-    $self->payinfo($payinfo);
-    validate($payinfo)
-      or return gettext('invalid_card'); # . ": ". $self->payinfo;
-
-    return gettext('unknown_card_type')
-      if cardtype($self->payinfo) eq "Unknown";
-
-    my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
-    if ( $ban ) {
-      return 'Banned credit card: banned on '.
-             time2str('%a %h %o at %r', $ban->_date).
-             ' by '. $ban->otaker.
-             ' (ban# '. $ban->bannum. ')';
-    }
-
-    if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
-      if ( cardtype($self->payinfo) eq 'American Express card' ) {
-        $self->paycvv =~ /^(\d{4})$/
-          or return "CVV2 (CID) for American Express cards is four digits.";
-        $self->paycvv($1);
-      } else {
-        $self->paycvv =~ /^(\d{3})$/
-          or return "CVV2 (CVC2/CID) is three digits.";
-        $self->paycvv($1);
-      }
-    } else {
-      $self->paycvv('');
-    }
-
-    my $cardtype = cardtype($payinfo);
-    if ( $cardtype =~ /^(Switch|Solo)$/i ) {
-
-      return "Start date or issue number is required for $cardtype cards"
-        unless $self->paystart_month && $self->paystart_year or $self->payissue;
+  $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
 
-      return "Start month must be between 1 and 12"
-        if $self->paystart_month
-           and $self->paystart_month < 1 || $self->paystart_month > 12;
-
-      return "Start year must be 1990 or later"
-        if $self->paystart_year
-           and $self->paystart_year < 1990;
-
-      return "Issue number must be beween 1 and 99"
-        if $self->payissue
-          and $self->payissue < 1 || $self->payissue > 99;
-
-    } else {
-      $self->paystart_month('');
-      $self->paystart_year('');
-      $self->payissue('');
-    }
-
-  } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
-
-    my $payinfo = $self->payinfo;
-    $payinfo =~ s/[^\d\@]//g;
-    if ( $conf->exists('echeck-nonus') ) {
-      $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@aba';
-      $payinfo = "$1\@$2";
-    } else {
-      $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
-      $payinfo = "$1\@$2";
-    }
-    $self->payinfo($payinfo);
-    $self->paycvv('');
-
-    my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
-    if ( $ban ) {
-      return 'Banned ACH account: banned on '.
-             time2str('%a %h %o at %r', $ban->_date).
-             ' by '. $ban->otaker.
-             ' (ban# '. $ban->bannum. ')';
-    }
+  warn "$me check AFTER: \n". $self->_dump
+    if $DEBUG > 2;
 
 
-  } elsif ( $self->payby eq 'LECB' ) {
+  $self->SUPER::check;
+}
 
 
-    my $payinfo = $self->payinfo;
-    $payinfo =~ s/\D//g;
-    $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
-    $payinfo = $1;
-    $self->payinfo($payinfo);
-    $self->paycvv('');
+sub check_payinfo_cardtype {
+  my $self = shift;
 
 
-  } elsif ( $self->payby eq 'BILL' ) {
+  return '' unless $self->payby =~ /^(CARD|DCRD)$/;
 
 
-    $error = $self->ut_textn('payinfo');
-    return "Illegal P.O. number: ". $self->payinfo if $error;
-    $self->paycvv('');
+  my $payinfo = $self->payinfo;
+  $payinfo =~ s/\D//g;
 
 
-  } elsif ( $self->payby eq 'COMP' ) {
+  return '' if $payinfo =~ /^99\d{14}$/; #token
 
 
-    my $curuser = $FS::CurrentUser::CurrentUser;
-    if (    ! $self->custnum
-         && ! $curuser->access_right('Complimentary customer')
-       )
-    {
-      return "You are not permitted to create complimentary accounts."
-    }
+  my %bop_card_types = map { $_=>1 } values %{ card_types() };
+  my $cardtype = cardtype($payinfo);
 
 
-    $error = $self->ut_textn('payinfo');
-    return "Illegal comp account issuer: ". $self->payinfo if $error;
-    $self->paycvv('');
+  return "$cardtype not accepted" unless $bop_card_types{$cardtype};
 
 
-  } elsif ( $self->payby eq 'PREPAY' ) {
+  '';
 
 
-    my $payinfo = $self->payinfo;
-    $payinfo =~ s/\W//g; #anything else would just confuse things
-    $self->payinfo($payinfo);
-    $error = $self->ut_alpha('payinfo');
-    return "Illegal prepayment identifier: ". $self->payinfo if $error;
-    return "Unknown prepayment identifier"
-      unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
-    $self->paycvv('');
+}
 
 
-  }
+=item replace_check
 
 
-  if ( $self->paydate eq '' || $self->paydate eq '-' ) {
-    return "Expiration date required"
-      unless $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD)$/;
-    $self->paydate('');
-  } else {
-    my( $m, $y );
-    if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
-      ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
-    } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
-      ( $m, $y ) = ( $2, "19$1" );
-    } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
-      ( $m, $y ) = ( $3, "20$2" );
-    } else {
-      return "Illegal expiration date: ". $self->paydate;
-    }
-    $self->paydate("$y-$m-01");
-    my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
-    return gettext('expired_card')
-      if !$import
-      && !$ignore_expired_card 
-      && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
-  }
+Additional checks for replace only.
 
 
-  if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
-       ( ! $conf->exists('require_cardname')
-         || $self->payby !~ /^(CARD|DCRD)$/  ) 
-  ) {
-    $self->payname( $self->first. " ". $self->getfield('last') );
-  } else {
-    $self->payname =~ /^([\w \,\.\-\'\&]+)$/
-      or return gettext('illegal_name'). " payname: ". $self->payname;
-    $self->payname($1);
-  }
+=cut
 
 
-  foreach my $flag (qw( tax spool_cdr squelch_cdr archived email_csv_cdr )) {
-    $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
-    $self->$flag($1);
+sub replace_check {
+  my ($new,$old) = @_;
+  #preserve old value if global config is set
+  if ($old && $conf->exists('invoice-ship_address')) {
+    $new->invoice_ship_address($old->invoice_ship_address);
   }
   }
-
-  $self->otaker(getotaker) unless $self->otaker;
-
-  warn "$me check AFTER: \n". $self->_dump
-    if $DEBUG > 2;
-
-  $self->SUPER::check;
+  return '';
 }
 
 =item addr_fields 
 }
 
 =item addr_fields 
@@ -1894,8 +1912,10 @@ Returns a list of fields which have ship_ duplicates.
 
 sub addr_fields {
   qw( last first company
 
 sub addr_fields {
   qw( last first company
+      locationname
       address1 address2 city county state zip country
       address1 address2 city county state zip country
-      daytime night fax
+      latitude longitude
+      daytime night fax mobile
     );
 }
 
     );
 }
 
@@ -1907,314 +1927,129 @@ Returns true if this customer record has a separate shipping address.
 
 sub has_ship_address {
   my $self = shift;
 
 sub has_ship_address {
   my $self = shift;
-  scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
+  $self->bill_locationnum != $self->ship_locationnum;
 }
 
 =item location_hash
 
 }
 
 =item location_hash
 
-Returns a list of key/value pairs, with the following keys: address1, adddress2,
-city, county, state, zip, country.  The shipping address is used if present.
+Returns a list of key/value pairs, with the following keys: address1, 
+adddress2, city, county, state, zip, country, district, and geocode.  The 
+shipping address is used if present.
 
 =cut
 
 
 =cut
 
-#geocode?  dependent on tax-ship_address config, not available in cust_location
-#mostly.  not yet then.
-
 sub location_hash {
   my $self = shift;
 sub location_hash {
   my $self = shift;
-  my $prefix = $self->has_ship_address ? 'ship_' : '';
-
-  map { $_ => $self->get($prefix.$_) }
-      qw( address1 address2 city county state zip country geocode );
-      #fields that cust_location has
+  $self->ship_location->location_hash;
 }
 
 }
 
-=item all_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
+=item cust_location
 
 
-Returns all packages (see L<FS::cust_pkg>) for this customer.
+Returns all locations (see L<FS::cust_location>) for this customer.
 
 =cut
 
 
 =cut
 
-sub all_pkgs {
+sub cust_location {
   my $self = shift;
   my $self = shift;
-  my $extra_qsearch = ref($_[0]) ? shift : {};
-
-  return $self->num_pkgs unless wantarray || keys(%$extra_qsearch);
-
-  my @cust_pkg = ();
-  if ( $self->{'_pkgnum'} ) {
-    @cust_pkg = values %{ $self->{'_pkgnum'}->cache };
-  } else {
-    @cust_pkg = $self->_cust_pkg($extra_qsearch);
-  }
-
-  sort sort_packages @cust_pkg;
+  qsearch('cust_location', { 'custnum'     => $self->custnum,
+                             'prospectnum' => '' } );
 }
 
 }
 
-=item cust_pkg
+=item cust_contact
 
 
-Synonym for B<all_pkgs>.
+Returns all contact associations (see L<FS::cust_contact>) for this customer.
 
 =cut
 
 
 =cut
 
-sub cust_pkg {
-  shift->all_pkgs(@_);
+sub cust_contact {
+  my $self = shift;
+  qsearch('cust_contact', { 'custnum' => $self->custnum } );
 }
 
 }
 
-=item cust_location
+=item cust_payby PAYBY
 
 
-Returns all locations (see L<FS::cust_location>) for this customer.
+Returns all payment methods (see L<FS::cust_payby>) for this customer.
+
+If one or more PAYBY are specified, returns only payment methods for specified PAYBY.
+Does not validate PAYBY.
 
 =cut
 
 
 =cut
 
-sub cust_location {
+sub cust_payby {
   my $self = shift;
   my $self = shift;
-  qsearch('cust_location', { 'custnum' => $self->custnum } );
-}
+  my @payby = @_;
+  my $search = {
+    'table'    => 'cust_payby',
+    'hashref'  => { 'custnum' => $self->custnum },
+    'order_by' => "ORDER BY payby IN ('CARD','CHEK') DESC, weight ASC",
+  };
+  $search->{'extra_sql'} = ' AND payby IN ( ' . join(',', map { dbh->quote($_) } @payby) . ' ) '
+    if @payby;
 
 
-=item location_label [ OPTION => VALUE ... ]
+  qsearch($search);
+}
 
 
-Returns the label of the service location (see analog in L<FS::cust_location>) for this customer.
+=item has_cust_payby_auto
 
 
-Options are
+Returns true if customer has an automatic payment method ('CARD' or 'CHEK')
 
 
-=over 4
+=cut
 
 
-=item join_string
+sub has_cust_payby_auto {
+  my $self = shift;
+  scalar( qsearch({ 
+    'table'     => 'cust_payby',
+    'hashref'   => { 'custnum' => $self->custnum, },
+    'extra_sql' => " AND payby IN ( 'CARD', 'CHEK' ) ",
+    'order_by'  => 'LIMIT 1',
+  }) );
 
 
-used to separate the address elements (defaults to ', ')
+}
 
 
-=item escape_function
+=item unsuspend
 
 
-a callback used for escaping the text of the address elements
+Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
+and L<FS::cust_pkg>) for this customer, except those on hold.
 
 
-=back
+Returns a list: an empty list on success or a list of errors.
 
 =cut
 
 
 =cut
 
-# false laziness with FS::cust_location::line
-
-sub location_label {
+sub unsuspend {
   my $self = shift;
   my $self = shift;
-  my %opt = @_;
-
-  my $separator = $opt{join_string} || ', ';
-  my $escape = $opt{escape_function} || sub{ shift };
-  my $line = '';
-  my $cydefault = FS::conf->new->config('countrydefault') || 'US';
-  my $prefix = length($self->ship_last) ? 'ship_' : '';
-
-  my $notfirst = 0;
-  foreach (qw ( address1 address2 ) ) {
-    my $method = "$prefix$_";
-    $line .= ($notfirst ? $separator : ''). &$escape($self->$method)
-      if $self->$method;
-    $notfirst++;
-  }
-  $notfirst = 0;
-  foreach (qw ( city county state zip ) ) {
-    my $method = "$prefix$_";
-    if ( $self->$method ) {
-      $line .= ' (' if $method eq 'county';
-      $line .= ($notfirst ? ' ' : $separator). &$escape($self->$method);
-      $line .= ' )' if $method eq 'county';
-      $notfirst++;
-    }
-  }
-  $line .= $separator. &$escape(code2country($self->country))
-    if $self->country ne $cydefault;
-
-  $line;
+  grep { ($_->get('setup')) && $_->unsuspend } $self->suspended_pkgs;
 }
 
 }
 
-=item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
+=item release_hold
 
 
-Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
+Unsuspends all suspended packages in the on-hold state (those without setup 
+dates) for this customer. 
 
 =cut
 
 
 =cut
 
-sub ncancelled_pkgs {
+sub release_hold {
   my $self = shift;
   my $self = shift;
-  my $extra_qsearch = ref($_[0]) ? shift : {};
+  grep { (!$_->setup) && $_->unsuspend } $self->suspended_pkgs;
+}
 
 
-  return $self->num_ncancelled_pkgs unless wantarray;
+=item suspend
 
 
-  my @cust_pkg = ();
-  if ( $self->{'_pkgnum'} ) {
+Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
 
 
-    warn "$me ncancelled_pkgs: returning cached objects"
-      if $DEBUG > 1;
+Returns a list: an empty list on success or a list of errors.
 
 
-    @cust_pkg = grep { ! $_->getfield('cancel') }
-                values %{ $self->{'_pkgnum'}->cache };
+=cut
 
 
-  } else {
+sub suspend {
+  my $self = shift;
+  grep { $_->suspend(@_) } $self->unsuspended_pkgs;
+}
 
 
-    warn "$me ncancelled_pkgs: searching for packages with custnum ".
-         $self->custnum. "\n"
-      if $DEBUG > 1;
+=item suspend_if_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
 
 
-    $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 {
-  
-  my $locationsort = ( $a->locationnum || 0 ) <=> ( $b->locationnum || 0 );
-  return $locationsort if $locationsort;
-
-  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 {
-    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
-
-Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
-
-=cut
-
-sub suspended_pkgs {
-  my $self = shift;
-  grep { $_->susp } $self->ncancelled_pkgs;
-}
-
-=item unflagged_suspended_pkgs
-
-Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
-customer (thouse packages without the `manual_flag' set).
-
-=cut
-
-sub unflagged_suspended_pkgs {
-  my $self = shift;
-  return $self->suspended_pkgs
-    unless dbdef->table('cust_pkg')->column('manual_flag');
-  grep { ! $_->manual_flag } $self->suspended_pkgs;
-}
-
-=item unsuspended_pkgs
-
-Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
-this customer.
-
-=cut
-
-sub unsuspended_pkgs {
-  my $self = shift;
-  grep { ! $_->susp } $self->ncancelled_pkgs;
-}
-
-=item next_bill_date
-
-Returns the next date this customer will be billed, as a UNIX timestamp, or
-undef if no active package has a next bill date.
-
-=cut
-
-sub next_bill_date {
-  my $self = shift;
-  min( map $_->get('bill'), grep $_->get('bill'), $self->unsuspended_pkgs );
-}
-
-=item num_cancelled_pkgs
-
-Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
-customer.
-
-=cut
-
-sub num_cancelled_pkgs {
-  shift->num_pkgs("cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0");
-}
-
-sub num_ncancelled_pkgs {
-  shift->num_pkgs("( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )");
-}
-
-sub num_pkgs {
-  my( $self ) = shift;
-  my $sql = scalar(@_) ? shift : '';
-  $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
-  my $sth = dbh->prepare(
-    "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
-  ) or die dbh->errstr;
-  $sth->execute($self->custnum) or die $sth->errstr;
-  $sth->fetchrow_arrayref->[0];
-}
-
-=item unsuspend
-
-Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
-and L<FS::cust_pkg>) for this customer.  Always returns a list: an empty list
-on success or a list of errors.
-
-=cut
-
-sub unsuspend {
-  my $self = shift;
-  grep { $_->unsuspend } $self->suspended_pkgs;
-}
-
-=item suspend
-
-Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
-
-Returns a list: an empty list on success or a list of errors.
-
-=cut
-
-sub suspend {
-  my $self = shift;
-  grep { $_->suspend(@_) } $self->unsuspended_pkgs;
-}
-
-=item suspend_if_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
-
-Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
-PKGPARTs (see L<FS::part_pkg>).  Preferred usage is to pass a hashref instead
-of a list of pkgparts; the hashref has the following keys:
+Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
+PKGPARTs (see L<FS::part_pkg>).  Preferred usage is to pass a hashref instead
+of a list of pkgparts; the hashref has the following keys:
 
 =over 4
 
 
 =over 4
 
@@ -2309,16 +2144,21 @@ sub cancel {
   return ( 'access denied' )
     unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
 
   return ( 'access denied' )
     unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
 
-  if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
+  if ( $opt{'ban'} ) {
+
+    foreach my $cust_payby ( $self->cust_payby ) {
+
+      #well, if they didn't get decrypted on search, then we don't have to 
+      # try again... queue a job for the server that does have decryption
+      # capability if we're in a paranoid multi-server implementation?
+      return ( "Can't (yet) ban encrypted credit cards" )
+        if $cust_payby->is_encrypted($cust_payby->payinfo);
 
 
-    #should try decryption (we might have the private key)
-    # and if not maybe queue a job for the server that does?
-    return ( "Can't (yet) ban encrypted credit cards" )
-      if $self->is_encrypted($self->payinfo);
+      my $ban = new FS::banned_pay $cust_payby->_new_banned_pay_hashref;
+      my $error = $ban->insert;
+      return ( $error ) if $error;
 
 
-    my $ban = new FS::banned_pay $self->_banned_pay_hashref;
-    my $error = $ban->insert;
-    return ( $error ) if $error;
+    }
 
   }
 
 
   }
 
@@ -2339,6 +2179,8 @@ sub cancel {
 }
 
 sub _banned_pay_hashref {
 }
 
 sub _banned_pay_hashref {
+  die 'cust_main->_banned_pay_hashref deprecated';
+
   my $self = shift;
 
   my %payby2ban = (
   my $self = shift;
 
   my %payby2ban = (
@@ -2350,7 +2192,7 @@ sub _banned_pay_hashref {
 
   {
     'payby'   => $payby2ban{$self->payby},
 
   {
     'payby'   => $payby2ban{$self->payby},
-    'payinfo' => md5_base64($self->payinfo),
+    'payinfo' => $self->payinfo,
     #don't ever *search* on reason! #'reason'  =>
   };
 }
     #don't ever *search* on reason! #'reason'  =>
   };
 }
@@ -2362,42 +2204,54 @@ Returns all notes (see L<FS::cust_main_note>) for this customer.
 =cut
 
 sub notes {
 =cut
 
 sub notes {
-  my $self = shift;
-  #order by?
+  my($self,$orderby_classnum) = (shift,shift);
+  my $orderby = "sticky DESC, _date DESC";
+  $orderby = "classnum ASC, $orderby" if $orderby_classnum;
   qsearch( 'cust_main_note',
            { 'custnum' => $self->custnum },
   qsearch( 'cust_main_note',
            { 'custnum' => $self->custnum },
-          '',
-          'ORDER BY _DATE DESC'
-        );
+           '',
+           "ORDER BY $orderby",
+         );
 }
 
 =item agent
 
 Returns the agent (see L<FS::agent>) for this customer.
 
 }
 
 =item agent
 
 Returns the agent (see L<FS::agent>) for this customer.
 
+=item agent_name
+
+Returns the agent name (see L<FS::agent>) for this customer.
+
 =cut
 
 =cut
 
-sub agent {
+sub agent_name {
   my $self = shift;
   my $self = shift;
-  qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
+  $self->agent->agent;
 }
 
 }
 
-=item cust_class
+=item cust_tag
 
 
-Returns the customer class, as an FS::cust_class object, or the empty string
-if there is no customer class.
+Returns any tags associated with this customer, as FS::cust_tag objects,
+or an empty list if there are no tags.
+
+=item part_tag
+
+Returns any tags associated with this customer, as FS::part_tag objects,
+or an empty list if there are no tags.
 
 =cut
 
 
 =cut
 
-sub cust_class {
+sub part_tag {
   my $self = shift;
   my $self = shift;
-  if ( $self->classnum ) {
-    qsearchs('cust_class', { 'classnum' => $self->classnum } );
-  } else {
-    return '';
-  } 
+  map $_->part_tag, $self->cust_tag; 
 }
 
 }
 
+
+=item cust_class
+
+Returns the customer class, as an FS::cust_class object, or the empty string
+if there is no customer class.
+
 =item categoryname 
 
 Returns the customer category name, or the empty string if there is no customer
 =item categoryname 
 
 Returns the customer category name, or the empty string if there is no customer
@@ -2428,3943 +2282,557 @@ sub classname {
     : '';
 }
 
     : '';
 }
 
+=item tax_status
 
 
-=item bill_and_collect 
-
-Cancels and suspends any packages due, generates bills, applies payments and
-credits, and applies collection events to run cards, send bills and notices,
-etc.
+Returns the external tax status, as an FS::tax_status object, or the empty 
+string if there is no tax status.
 
 
-By default, warns on errors and continues with the next operation (but see the
-"fatal" flag below).
-
-Options are passed as name-value pairs.  Currently available options are:
+=cut
 
 
-=over 4
+sub tax_status {
+  my $self = shift;
+  if ( $self->taxstatusnum ) {
+    qsearchs('tax_status', { 'taxstatusnum' => $self->taxstatusnum } );
+  } else {
+    return '';
+  } 
+}
 
 
-=item time
+=item taxstatus
 
 
-Bills the customer as if it were that time.  Specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.  For example:
+Returns the tax status code if there is one.
 
 
- use Date::Parse;
- ...
- $cust_main->bill( 'time' => str2time('April 20th, 2001') );
+=cut
 
 
-=item invoice_time
+sub taxstatus {
+  my $self = shift;
+  my $tax_status = $self->tax_status;
+  $tax_status
+    ? $tax_status->taxstatus
+    : '';
+}
 
 
-Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices.  Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
+=item BILLING METHODS
 
 
-=item check_freq
+Documentation on billing methods has been moved to
+L<FS::cust_main::Billing>.
 
 
-"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
+=item REALTIME BILLING METHODS
 
 
-=item resetup
+Documentation on realtime billing methods has been moved to
+L<FS::cust_main::Billing_Realtime>.
 
 
-If set true, re-charges setup fees.
+=item remove_cvv
 
 
-=item fatal
+Removes the I<paycvv> field from the database directly.
 
 
-If set any errors prevent subsequent operations from continusing.  If set
-specifically to "return", returns the error (or false, if there is no error).
-Any other true value causes errors to die.
+If there is an error, returns the error, otherwise returns false.
 
 
-=item debug
+=cut
 
 
-Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
+sub remove_cvv {
+  die 'cust_main->remove_cvv deprecated';
+  my $self = shift;
+  my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?")
+    or return dbh->errstr;
+  $sth->execute($self->custnum)
+    or return $sth->errstr;
+  $self->paycvv('');
+  '';
+}
 
 
-=back
+=item total_owed
 
 
-Options are passed to the B<bill> and B<collect> methods verbatim, so all
-options of those methods are also available.
+Returns the total owed for this customer on all invoices
+(see L<FS::cust_bill/owed>).
 
 =cut
 
 
 =cut
 
-sub bill_and_collect {
-  my( $self, %options ) = @_;
-
-  my $error;
+sub total_owed {
+  my $self = shift;
+  $self->total_owed_date(2145859200); #12/31/2037
+}
 
 
-  #$options{actual_time} not $options{time} because freeside-daily -d is for
-  #pre-printing invoices
+=item total_owed_date TIME
 
 
-  $options{'actual_time'} ||= time;
+Returns the total owed for this customer on all invoices with date earlier than
+TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
+see L<Time::Local> and L<Date::Parse> for conversion functions.
 
 
-  $error = $self->cancel_expired_pkgs( $options{actual_time} );
-  if ( $error ) {
-    $error = "Error expiring custnum ". $self->custnum. ": $error";
-    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
-    elsif ( $options{fatal}                                ) { die    $error; }
-    else                                                     { warn   $error; }
-  }
+=cut
 
 
-  $error = $self->suspend_adjourned_pkgs( $options{actual_time} );
-  if ( $error ) {
-    $error = "Error adjourning custnum ". $self->custnum. ": $error";
-    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
-    elsif ( $options{fatal}                                ) { die    $error; }
-    else                                                     { warn   $error; }
-  }
+sub total_owed_date {
+  my $self = shift;
+  my $time = shift;
 
 
-  $error = $self->bill( %options );
-  if ( $error ) {
-    $error = "Error billing custnum ". $self->custnum. ": $error";
-    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
-    elsif ( $options{fatal}                                ) { die    $error; }
-    else                                                     { warn   $error; }
-  }
+  my $custnum = $self->custnum;
 
 
-  $error = $self->apply_payments_and_credits;
-  if ( $error ) {
-    $error = "Error applying custnum ". $self->custnum. ": $error";
-    if    ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
-    elsif ( $options{fatal}                                ) { die    $error; }
-    else                                                     { warn   $error; }
-  }
+  my $owed_sql = FS::cust_bill->owed_sql;
 
 
-  unless ( $conf->exists('cancelled_cust-noevents')
-           && ! $self->num_ncancelled_pkgs
-  ) {
-    $error = $self->collect( %options );
-    if ( $error ) {
-      $error = "Error collecting custnum ". $self->custnum. ": $error";
-      if    ($options{fatal} && $options{fatal} eq 'return') { return $error; }
-      elsif ($options{fatal}                               ) { die    $error; }
-      else                                                   { warn   $error; }
-    }
-  }
+  my $sql = "
+    SELECT SUM($owed_sql) FROM cust_bill
+      WHERE custnum = $custnum
+        AND _date <= $time
+  ";
 
 
-  '';
+  sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
 
 }
 
 
 }
 
-sub cancel_expired_pkgs {
-  my ( $self, $time, %options ) = @_;
+=item total_owed_pkgnum PKGNUM
 
 
-  my @cancel_pkgs = $self->ncancelled_pkgs( { 
-    'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
-  } );
+Returns the total owed on all invoices for this customer's specific package
+when using experimental package balances (see L<FS::cust_bill/owed_pkgnum>).
 
 
-  my @errors = ();
+=cut
 
 
-  foreach my $cust_pkg ( @cancel_pkgs ) {
-    my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
-    my $error = $cust_pkg->cancel($cpr ? ( 'reason'        => $cpr->reasonnum,
-                                           'reason_otaker' => $cpr->otaker
-                                         )
-                                       : ()
-                                 );
-    push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
-  }
+sub total_owed_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  $self->total_owed_date_pkgnum(2145859200, $pkgnum); #12/31/2037
+}
 
 
-  scalar(@errors) ? join(' / ', @errors) : '';
+=item total_owed_date_pkgnum TIME PKGNUM
 
 
-}
+Returns the total owed for this customer's specific package when using
+experimental package balances on all invoices with date earlier than
+TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
+see L<Time::Local> and L<Date::Parse> for conversion functions.
 
 
-sub suspend_adjourned_pkgs {
-  my ( $self, $time, %options ) = @_;
+=cut
 
 
-  my @susp_pkgs = $self->ncancelled_pkgs( {
-    'extra_sql' =>
-      " AND ( susp IS NULL OR susp = 0 )
-        AND (    ( bill    IS NOT NULL AND bill    != 0 AND bill    <  $time )
-              OR ( adjourn IS NOT NULL AND adjourn != 0 AND adjourn <= $time )
-            )
-      ",
-  } );
+sub total_owed_date_pkgnum {
+  my( $self, $time, $pkgnum ) = @_;
 
 
-  #only because there's no SQL test for is_prepaid :/
-  @susp_pkgs = 
-    grep {     (    $_->part_pkg->is_prepaid
-                 && $_->bill
-                 && $_->bill < $time
-               )
-            || (    $_->adjourn
-                 && $_->adjourn <= $time
-               )
-           
-         }
-         @susp_pkgs;
-
-  my @errors = ();
-
-  foreach my $cust_pkg ( @susp_pkgs ) {
-    my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
-      if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T);
-    my $error = $cust_pkg->suspend($cpr ? ( 'reason' => $cpr->reasonnum,
-                                            'reason_otaker' => $cpr->otaker
-                                          )
-                                        : ()
-                                  );
-    push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
+  my $total_bill = 0;
+  foreach my $cust_bill (
+    grep { $_->_date <= $time }
+      qsearch('cust_bill', { 'custnum' => $self->custnum, } )
+  ) {
+    $total_bill += $cust_bill->owed_pkgnum($pkgnum);
   }
   }
-
-  scalar(@errors) ? join(' / ', @errors) : '';
+  sprintf( "%.2f", $total_bill );
 
 }
 
 
 }
 
-=item bill OPTIONS
+=item total_paid
+
+Returns the total amount of all payments.
 
 
-Generates invoices (see L<FS::cust_bill>) for this customer.  Usually used in
-conjunction with the collect method by calling B<bill_and_collect>.
+=cut
 
 
-If there is an error, returns the error, otherwise returns false.
+sub total_paid {
+  my $self = shift;
+  my $total = 0;
+  $total += $_->paid foreach $self->cust_pay;
+  sprintf( "%.2f", $total );
+}
 
 
-Options are passed as name-value pairs.  Currently available options are:
+=item total_unapplied_credits
 
 
-=over 4
+Returns the total outstanding credit (see L<FS::cust_credit>) for this
+customer.  See L<FS::cust_credit/credited>.
 
 
-=item resetup
+=item total_credited
 
 
-If set true, re-charges setup fees.
+Old name for total_unapplied_credits.  Don't use.
 
 
-=item time
+=cut
 
 
-Bills the customer as if it were that time.  Specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.  For example:
+sub total_credited {
+  #carp "total_credited deprecated, use total_unapplied_credits";
+  shift->total_unapplied_credits(@_);
+}
 
 
- use Date::Parse;
- ...
- $cust_main->bill( 'time' => str2time('April 20th, 2001') );
+sub total_unapplied_credits {
+  my $self = shift;
 
 
-=item pkg_list
+  my $custnum = $self->custnum;
 
 
-An array ref of specific packages (objects) to attempt billing, instead trying all of them.
+  my $unapplied_sql = FS::cust_credit->unapplied_sql;
 
 
- $cust_main->bill( pkg_list => [$pkg1, $pkg2] );
+  my $sql = "
+    SELECT SUM($unapplied_sql) FROM cust_credit
+      WHERE custnum = $custnum
+  ";
 
 
-=item not_pkgpart
+  sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
 
 
-A hashref of pkgparts to exclude from this billing run (can also be specified as a comma-separated scalar).
+}
 
 
-=item invoice_time
+=item total_unapplied_credits_pkgnum PKGNUM
 
 
-Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices.  Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
+Returns the total outstanding credit (see L<FS::cust_credit>) for this
+customer.  See L<FS::cust_credit/credited>.
 
 
-=item cancel
+=cut
 
 
-This boolean value informs the us that the package is being cancelled.  This
-typically might mean not charging the normal recurring fee but only usage
-fees since the last billing. Setup charges may be charged.  Not all package
-plans support this feature (they tend to charge 0).
+sub total_unapplied_credits_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  my $total_credit = 0;
+  $total_credit += $_->credited foreach $self->cust_credit_pkgnum($pkgnum);
+  sprintf( "%.2f", $total_credit );
+}
 
 
-=item invoice_terms
 
 
-Optional terms to be printed on this invoice.  Otherwise, customer-specific
-terms or the default terms are used.
+=item total_unapplied_payments
 
 
-=back
+Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
+See L<FS::cust_pay/unapplied>.
 
 =cut
 
 
 =cut
 
-sub bill {
-  my( $self, %options ) = @_;
-  return '' if $self->payby eq 'COMP';
-  warn "$me bill customer ". $self->custnum. "\n"
-    if $DEBUG;
+sub total_unapplied_payments {
+  my $self = shift;
 
 
-  my $time = $options{'time'} || time;
-  my $invoice_time = $options{'invoice_time'} || $time;
+  my $custnum = $self->custnum;
 
 
-  $options{'not_pkgpart'} ||= {};
-  $options{'not_pkgpart'} = { map { $_ => 1 }
-                                  split(/\s*,\s*/, $options{'not_pkgpart'})
-                            }
-    unless ref($options{'not_pkgpart'});
+  my $unapplied_sql = FS::cust_pay->unapplied_sql;
 
 
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
+  my $sql = "
+    SELECT SUM($unapplied_sql) FROM cust_pay
+      WHERE custnum = $custnum
+  ";
 
 
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
+  sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
 
 
-  $self->select_for_update; #mutex
+}
 
 
-  my $error = $self->do_cust_event(
-    'debug'      => ( $options{'debug'} || 0 ),
-    'time'       => $invoice_time,
-    'check_freq' => $options{'check_freq'},
-    'stage'      => 'pre-bill',
-  );
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
-  #keep auto-charge and non-auto-charge line items separate
-  my @passes = ( '', 'no_auto' );
-
-  my %cust_bill_pkg = map { $_ => [] } @passes;
-
-  ###
-  # find the packages which are due for billing, find out how much they are
-  # & generate invoice database.
-  ###
+=item total_unapplied_payments_pkgnum PKGNUM
 
 
-  my %total_setup   = map { my $z = 0; $_ => \$z; } @passes;
-  my %total_recur   = map { my $z = 0; $_ => \$z; } @passes;
+Returns the total unapplied payments (see L<FS::cust_pay>) for this customer's
+specific package when using experimental package balances.  See
+L<FS::cust_pay/unapplied>.
 
 
-  my %taxlisthash = map { $_ => {} } @passes;
+=cut
 
 
-  my @precommit_hooks = ();
+sub total_unapplied_payments_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  my $total_unapplied = 0;
+  $total_unapplied += $_->unapplied foreach $self->cust_pay_pkgnum($pkgnum);
+  sprintf( "%.2f", $total_unapplied );
+}
 
 
-  $options{'pkg_list'} ||= [ $self->ncancelled_pkgs ];  #param checks?
-  foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) {
 
 
-    next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
+=item total_unapplied_refunds
 
 
-    warn "  bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
+Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
+customer.  See L<FS::cust_refund/unapplied>.
 
 
-    #? to avoid use of uninitialized value errors... ?
-    $cust_pkg->setfield('bill', '')
-      unless defined($cust_pkg->bill);
-    #my $part_pkg = $cust_pkg->part_pkg;
+=cut
 
 
-    my $real_pkgpart = $cust_pkg->pkgpart;
-    my %hash = $cust_pkg->hash;
+sub total_unapplied_refunds {
+  my $self = shift;
+  my $custnum = $self->custnum;
 
 
-    foreach my $part_pkg ( $cust_pkg->part_pkg->self_and_bill_linked ) {
+  my $unapplied_sql = FS::cust_refund->unapplied_sql;
 
 
-      $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
+  my $sql = "
+    SELECT SUM($unapplied_sql) FROM cust_refund
+      WHERE custnum = $custnum
+  ";
 
 
-      my $pass = ($cust_pkg->no_auto || $part_pkg->no_auto) ? 'no_auto' : '';
+  sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
 
 
-      my $error =
-        $self->_make_lines( 'part_pkg'            => $part_pkg,
-                            'cust_pkg'            => $cust_pkg,
-                            'precommit_hooks'     => \@precommit_hooks,
-                            'line_items'          => $cust_bill_pkg{$pass},
-                            'setup'               => $total_setup{$pass},
-                            'recur'               => $total_recur{$pass},
-                            'tax_matrix'          => $taxlisthash{$pass},
-                            'time'                => $time,
-                            'real_pkgpart'        => $real_pkgpart,
-                            'options'             => \%options,
-                          );
-      if ($error) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
+}
 
 
-    } #foreach my $part_pkg
+=item balance
 
 
-  } #foreach my $cust_pkg
+Returns the balance for this customer (total_owed plus total_unrefunded, minus
+total_unapplied_credits minus total_unapplied_payments).
 
 
-  #if the customer isn't on an automatic payby, everything can go on a single
-  #invoice anyway?
-  #if ( $cust_main->payby !~ /^(CARD|CHEK)$/ ) {
-    #merge everything into one list
-  #}
+=cut
 
 
-  foreach my $pass (@passes) { # keys %cust_bill_pkg ) {
+sub balance {
+  my $self = shift;
+  $self->balance_date_range;
+}
 
 
-    my @cust_bill_pkg = @{ $cust_bill_pkg{$pass} };
+=item balance_date TIME
 
 
-    next unless @cust_bill_pkg; #don't create an invoice w/o line items
+Returns the balance for this customer, only considering invoices with date
+earlier than TIME (total_owed_date minus total_credited minus
+total_unapplied_payments).  TIME is specified as a UNIX timestamp; see
+L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion
+functions.
 
 
-    if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
-           !$conf->exists('postal_invoice-recurring_only')
-       )
-    {
+=cut
 
 
-      my $postal_pkg = $self->charge_postal_fee();
-      if ( $postal_pkg && !ref( $postal_pkg ) ) {
+sub balance_date {
+  my $self = shift;
+  $self->balance_date_range(shift);
+}
 
 
-        $dbh->rollback if $oldAutoCommit;
-        return "can't charge postal invoice fee for customer ".
-          $self->custnum. ": $postal_pkg";
-
-      } elsif ( $postal_pkg ) {
-
-        my $real_pkgpart = $postal_pkg->pkgpart;
-        foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
-          my %postal_options = %options;
-          delete $postal_options{cancel};
-          my $error =
-            $self->_make_lines( 'part_pkg'            => $part_pkg,
-                                'cust_pkg'            => $postal_pkg,
-                                'precommit_hooks'     => \@precommit_hooks,
-                                'line_items'          => \@cust_bill_pkg,
-                                'setup'               => $total_setup{$pass},
-                                'recur'               => $total_recur{$pass},
-                                'tax_matrix'          => $taxlisthash{$pass},
-                                'time'                => $time,
-                                'real_pkgpart'        => $real_pkgpart,
-                                'options'             => \%postal_options,
-                              );
-          if ($error) {
-            $dbh->rollback if $oldAutoCommit;
-            return $error;
-          }
-        }
+=item balance_date_range [ START_TIME [ END_TIME [ OPTION => VALUE ... ] ] ]
 
 
-      }
+Returns the balance for this customer, optionally considering invoices with
+date earlier than START_TIME, and not later than END_TIME
+(total_owed_date minus total_unapplied_credits minus total_unapplied_payments).
 
 
-    }
+Times are specified as SQL fragments or numeric
+UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
+L<Date::Parse> for conversion functions.  The empty string can be passed
+to disable that time constraint completely.
 
 
-    my $listref_or_error =
-      $self->calculate_taxes( \@cust_bill_pkg, $taxlisthash{$pass}, $invoice_time);
+Accepts the same options as L<balance_date_sql>:
 
 
-    unless ( ref( $listref_or_error ) ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $listref_or_error;
-    }
+=over 4
 
 
-    foreach my $taxline ( @$listref_or_error ) {
-      ${ $total_setup{$pass} } =
-        sprintf('%.2f', ${ $total_setup{$pass} } + $taxline->setup );
-      push @cust_bill_pkg, $taxline;
-    }
+=item unapplied_date
 
 
-    #add tax adjustments
-    warn "adding tax adjustments...\n" if $DEBUG > 2;
-    foreach my $cust_tax_adjustment (
-      qsearch('cust_tax_adjustment', { 'custnum'    => $self->custnum,
-                                       'billpkgnum' => '',
-                                     }
-             )
-    ) {
-
-      my $tax = sprintf('%.2f', $cust_tax_adjustment->amount );
-
-      my $itemdesc = $cust_tax_adjustment->taxname;
-      $itemdesc = '' if $itemdesc eq 'Tax';
-
-      push @cust_bill_pkg, new FS::cust_bill_pkg {
-        'pkgnum'      => 0,
-        'setup'       => $tax,
-        'recur'       => 0,
-        'sdate'       => '',
-        'edate'       => '',
-        'itemdesc'    => $itemdesc,
-        'itemcomment' => $cust_tax_adjustment->comment,
-        'cust_tax_adjustment' => $cust_tax_adjustment,
-        #'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
-      };
+set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
 
 
-    }
+=item cutoff
 
 
-    my $charged = sprintf('%.2f', ${ $total_setup{$pass} } + ${ $total_recur{$pass} } );
-
-    my @cust_bill = $self->cust_bill;
-    my $balance = $self->balance;
-    my $previous_balance = scalar(@cust_bill)
-                             ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
-                             : 0;
-
-    $previous_balance += $cust_bill[$#cust_bill]->charged
-      if scalar(@cust_bill);
-    #my $balance_adjustments =
-    #  sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
-
-    #create the new invoice
-    my $cust_bill = new FS::cust_bill ( {
-      'custnum'             => $self->custnum,
-      '_date'               => ( $invoice_time ),
-      'charged'             => $charged,
-      'billing_balance'     => $balance,
-      'previous_balance'    => $previous_balance,
-      'invoice_terms'       => $options{'invoice_terms'},
-    } );
-    $error = $cust_bill->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "can't create invoice for customer #". $self->custnum. ": $error";
-    }
+An absolute cutoff time.  Payments, credits, and refunds I<applied> after this 
+time will be ignored.  Note that START_TIME and END_TIME only limit the date 
+range for invoices and I<unapplied> payments, credits, and refunds.
 
 
-    foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
-      $cust_bill_pkg->invnum($cust_bill->invnum); 
-      my $error = $cust_bill_pkg->insert;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "can't create invoice line item: $error";
-      }
-    }
+=back
 
 
-  } #foreach my $pass ( keys %cust_bill_pkg )
+=cut
 
 
-  foreach my $hook ( @precommit_hooks ) { 
-    eval {
-      &{$hook}; #($self) ?
-    };
-    if ( $@ ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "$@ running precommit hook $hook\n";
-    }
-  }
-  
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  ''; #no error
+sub balance_date_range {
+  my $self = shift;
+  my $sql = 'SELECT SUM('. $self->balance_date_sql(@_).
+            ') FROM cust_main WHERE custnum='. $self->custnum;
+  sprintf( '%.2f', $self->scalar_sql($sql) || 0 );
 }
 
 }
 
-=item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME
-
-This is a weird one.  Perhaps it should not even be exposed.
+=item balance_pkgnum PKGNUM
 
 
-Generates tax line items (see L<FS::cust_bill_pkg>) for this customer.
-Usually used internally by bill method B<bill>.
+Returns the balance for this customer's specific package when using
+experimental package balances (total_owed plus total_unrefunded, minus
+total_unapplied_credits minus total_unapplied_payments)
 
 
-If there is an error, returns the error, otherwise returns reference to a
-list of line items suitable for insertion.
+=cut
 
 
-=over 4
+sub balance_pkgnum {
+  my( $self, $pkgnum ) = @_;
 
 
-=item LINEITEMREF
+  sprintf( "%.2f",
+      $self->total_owed_pkgnum($pkgnum)
+# n/a - refunds aren't part of pkg-balances since they don't apply to invoices
+#    + $self->total_unapplied_refunds_pkgnum($pkgnum)
+    - $self->total_unapplied_credits_pkgnum($pkgnum)
+    - $self->total_unapplied_payments_pkgnum($pkgnum)
+  );
+}
 
 
-An array ref of the line items being billed.
+=item payment_info
 
 
-=item TAXHASHREF
+Returns a hash of useful information for making a payment.
 
 
-A strange beast.  The keys to this hash are internal identifiers consisting
-of the name of the tax object type, a space, and its unique identifier ( e.g.
- 'cust_main_county 23' ).  The values of the hash are listrefs.  The first
-item in the list is the tax object.  The remaining items are either line
-items or floating point values (currency amounts).
+=over 4
 
 
-The taxes are calculated on this entity.  Calculated exemption records are
-transferred to the LINEITEMREF items on the assumption that they are related.
+=item balance
 
 
-Read the source.
+Current balance.
 
 
-=item INVOICE_TIME
+=item payby
 
 
-This specifies the date appearing on the associated invoice.  Some
-jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
+'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
 
 
 =back
 
-=cut
-sub calculate_taxes {
-  my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
+For credit card transactions:
 
 
-  my @tax_line_items = ();
+=over 4
 
 
-  warn "having a look at the taxes we found...\n" if $DEBUG > 2;
+=item card_type 1
 
 
-  # keys are tax names (as printed on invoices / itemdesc )
-  # values are listrefs of taxlisthash keys (internal identifiers)
-  my %taxname = ();
+=item payname
 
 
-  # keys are taxlisthash keys (internal identifiers)
-  # values are (cumulative) amounts
-  my %tax = ();
+Exact name on card
 
 
-  # keys are taxlisthash keys (internal identifiers)
-  # values are listrefs of cust_bill_pkg_tax_location hashrefs
-  my %tax_location = ();
+=back
 
 
-  # keys are taxlisthash keys (internal identifiers)
-  # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs
-  my %tax_rate_location = ();
+For electronic check transactions:
 
 
-  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,
-                            'invoice_time' => $invoice_time
-                          );
-    return $hashref_or_error unless ref($hashref_or_error);
+=over 4
 
 
-    unshift @{ $taxlisthash->{$tax} }, $tax_object;
+=item stateid_state
 
 
-    my $name   = $hashref_or_error->{'name'};
-    my $amount = $hashref_or_error->{'amount'};
+=back
 
 
-    #warn "adding $amount as $name\n";
-    $taxname{ $name } ||= [];
-    push @{ $taxname{ $name } }, $tax;
+=cut
 
 
-    $tax{ $tax } += $amount;
+#XXX i need to be updated for 4.x+
+sub payment_info {
+  my $self = shift;
 
 
-    $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 ),
-        };
-    }
+  my %return = ();
 
 
-    $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,
-        };
-    }
+  $return{balance} = $self->balance;
 
 
-  }
+  $return{payname} = $self->payname
+                     || ( $self->first. ' '. $self->get('last') );
 
 
-  #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';
+  $return{$_} = $self->bill_location->$_
+    for qw(address1 address2 city state zip);
 
 
-      push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, 
-        splice( @{ $_->_cust_tax_exempt_pkg } );
-    }
-  }
+  $return{payby} = $self->payby;
+  $return{stateid_state} = $self->stateid_state;
 
 
-  #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}++;
-      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 } };
-      push @cust_bill_pkg_tax_rate_location,
-        map { new FS::cust_bill_pkg_tax_rate_location $_ }
-            @{ $tax_rate_location{ $taxitem } };
-    }
-    next unless $tax;
+  if ( $self->payby =~ /^(CARD|DCRD)$/ ) {
+    $return{card_type} = cardtype($self->payinfo);
+    $return{payinfo} = $self->paymask;
 
 
-    $tax = sprintf('%.2f', $tax );
-  
-    my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
-                                                   'disabled'     => '',
-                                                 },
-                               );
+    @return{'month', 'year'} = $self->paydate_monthyear;
 
 
-    my @display = ();
-    if ( $pkg_category and
-         $conf->config('invoice_latexsummary') ||
-         $conf->config('invoice_htmlsummary')
-       )
-    {
+  }
 
 
-      my %hash = (  'section' => $pkg_category->categoryname );
-      push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
+  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;
 
 
-    }
+  }
 
 
-    push @tax_line_items, new FS::cust_bill_pkg {
-      'pkgnum'   => 0,
-      'setup'    => $tax,
-      'recur'    => 0,
-      'sdate'    => '',
-      'edate'    => '',
-      'itemdesc' => $taxname,
-      'display'  => \@display,
-      'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
-      'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
-    };
+  #doubleclick protection
+  my $_date = time;
+  $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32;
 
 
-  }
+  %return;
 
 
-  \@tax_line_items;
 }
 
 }
 
-sub _make_lines {
-  my ($self, %params) = @_;
+=item paydate_epoch
 
 
-  my $part_pkg = $params{part_pkg} or die "no part_pkg specified";
-  my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified";
-  my $precommit_hooks = $params{precommit_hooks} or die "no package specified";
-  my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified";
-  my $total_setup = $params{setup} or die "no setup accumulator specified";
-  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}};
+Returns the next payment expiration date for this customer. If they have no
+payment methods that will expire, returns 0.
 
 
-  my $dbh = dbh;
-  my $real_pkgpart = $params{real_pkgpart};
-  my %hash = $cust_pkg->hash;
-  my $old_cust_pkg = new FS::cust_pkg \%hash;
-
-  my @details = ();
-  my @discounts = ();
-  my $lineitems = 0;
-
-  $cust_pkg->pkgpart($part_pkg->pkgpart);
-
-  ###
-  # bill setup
-  ###
-
-  my $setup = 0;
-  my $unitsetup = 0;
-  if ( $options{'resetup'}
-       || ( ! $cust_pkg->setup
-            && ( ! $cust_pkg->start_date
-                 || $cust_pkg->start_date <= $time
-               )
-            && ( ! $conf->exists('disable_setup_suspended_pkgs')
-                 || ( $conf->exists('disable_setup_suspended_pkgs') &&
-                      ! $cust_pkg->getfield('susp')
-                    )
-               )
-          )
-    )
-  {
-    
-    warn "    bill setup\n" if $DEBUG > 1;
-    $lineitems++;
+=cut
 
 
-    $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
-    return "$@ running calc_setup for $cust_pkg\n"
-      if $@;
+sub paydate_epoch {
+  my $self = shift;
+  # filter out the ones that individually return 0, but then return 0 if
+  # there are no results
+  my @epochs = grep { $_ > 0 } map { $_->paydate_epoch } $self->cust_payby;
+  min( @epochs ) || 0;
+}
 
 
-    $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
+=item paydate_epoch_sql
 
 
-    $cust_pkg->setfield('setup', $time)
-      unless $cust_pkg->setup;
-          #do need it, but it won't get written to the db
-          #|| $cust_pkg->pkgpart != $real_pkgpart;
+Returns an SQL expression to get the next payment expiration date for a
+customer. Returns 2143260000 (2037-12-01) if there are no payment expiration
+dates, so that it's safe to test for "will it expire before date X" for any
+date up to then.
 
 
-    $cust_pkg->setfield('start_date', '')
-      if $cust_pkg->start_date;
+=cut
 
 
-  }
+sub paydate_epoch_sql {
+  my $class = shift;
+  my $paydate = FS::cust_payby->paydate_epoch_sql;
+  "(SELECT COALESCE(MIN($paydate), 2143260000) FROM cust_payby WHERE cust_payby.custnum = cust_main.custnum)";
+}
 
 
-  ###
-  # bill recurring fee
-  ### 
-
-  #XXX unit stuff here too
-  my $recur = 0;
-  my $unitrecur = 0;
-  my $sdate;
-  if (     ! $cust_pkg->get('susp')
-       and ! $cust_pkg->get('start_date')
-       and ( $part_pkg->getfield('freq') ne '0'
-             && ( $cust_pkg->getfield('bill') || 0 ) <= $time
-           )
-        || ( $part_pkg->plan eq 'voip_cdr'
-              && $part_pkg->option('bill_every_call')
-           )
-        || ( $options{cancel} )
-  ) {
+sub tax_exemption {
+  my( $self, $taxname ) = @_;
 
 
-    # XXX should this be a package event?  probably.  events are called
-    # at collection time at the moment, though...
-    $part_pkg->reset_usage($cust_pkg, 'debug'=>$DEBUG)
-      if $part_pkg->can('reset_usage');
-      #don't want to reset usage just cause we want a line item??
-      #&& $part_pkg->pkgpart == $real_pkgpart;
-
-    warn "    bill recur\n" if $DEBUG > 1;
-    $lineitems++;
-
-    # XXX shared with $recur_prog
-    $sdate = ( $options{cancel} ? $cust_pkg->last_bill : $cust_pkg->bill )
-             || $cust_pkg->setup
-             || $time;
-
-    #over two params!  lets at least switch to a hashref for the rest...
-    my $increment_next_bill = ( $part_pkg->freq ne '0'
-                                && ( $cust_pkg->getfield('bill') || 0 ) <= $time
-                                && !$options{cancel}
-                              );
-    my %param = ( 'precommit_hooks'     => $precommit_hooks,
-                  'increment_next_bill' => $increment_next_bill,
-                  'discounts'           => \@discounts,
-                );
+  qsearchs( 'cust_main_exemption', { 'custnum' => $self->custnum,
+                                     'taxname' => $taxname,
+                                   },
+          );
+}
 
 
-    my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur';
-    $recur = eval { $cust_pkg->$method( \$sdate, \@details, \%param ) };
-    return "$@ running $method for $cust_pkg\n"
-      if ( $@ );
+=item cust_main_exemption
 
 
-    if ( $increment_next_bill ) {
+=item invoicing_list
 
 
-      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);
+Returns a list of email addresses (with svcnum entries expanded), and the word
+'POST' if the customer receives postal invoices.
 
 
-      $cust_pkg->setfield('bill', $next_bill );
+=cut
 
 
-    }
+sub invoicing_list {
+  my( $self, $arrayref ) = @_;
 
 
+  if ( $arrayref ) {
+    warn "FS::cust_main::invoicing_list(ARRAY) is no longer supported.";
   }
   }
-
-  warn "\$setup is undefined" unless defined($setup);
-  warn "\$recur is undefined" unless defined($recur);
-  warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
-  
-  ###
-  # If there's line items, create em cust_bill_pkg records
-  # If $cust_pkg has been modified, update it (if we're a real pkgpart)
-  ###
-
-  if ( $lineitems ) {
-
-    if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) {
-      # hmm.. and if just the options are modified in some weird price plan?
-  
-      warn "  package ". $cust_pkg->pkgnum. " modified; updating\n"
-        if $DEBUG >1;
-  
-      my $error = $cust_pkg->replace( $old_cust_pkg,
-                                      'options' => { $cust_pkg->options },
-                                    );
-      return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error"
-        if $error; #just in case
-    }
   
   
-    $setup = sprintf( "%.2f", $setup );
-    $recur = sprintf( "%.2f", $recur );
-    if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) {
-      return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
-    }
-    if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) {
-      return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
-    }
+  my @emails = $self->invoicing_list_emailonly;
+  push @emails, 'POST' if $self->get('postal_invoice');
 
 
-    if ( $setup != 0 || $recur != 0 ) {
+  @emails;
+}
 
 
-      warn "    charges (setup=$setup, recur=$recur); adding line items\n"
-        if $DEBUG > 1;
+=item check_invoicing_list ARRAYREF
 
 
-      my @cust_pkg_detail = map { $_->detail } $cust_pkg->cust_pkg_detail('I');
-      if ( $DEBUG > 1 ) {
-        warn "      adding customer package invoice detail: $_\n"
-          foreach @cust_pkg_detail;
-      }
-      push @details, @cust_pkg_detail;
-
-      my $cust_bill_pkg = new FS::cust_bill_pkg {
-        'pkgnum'    => $cust_pkg->pkgnum,
-        'setup'     => $setup,
-        'unitsetup' => $unitsetup,
-        'recur'     => $recur,
-        'unitrecur' => $unitrecur,
-        'quantity'  => $cust_pkg->quantity,
-        'details'   => \@details,
-        'discounts' => \@discounts,
-        'hidden'    => $part_pkg->hidden,
-      };
+Checks these arguements as valid input for the invoicing_list method.  If there
+is an error, returns the error, otherwise returns false.
 
 
-      if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) {
-        $cust_bill_pkg->sdate( $hash{last_bill} );
-        $cust_bill_pkg->edate( $sdate - 86399   ); #60s*60m*24h-1
-        $cust_bill_pkg->edate( $time ) if $options{cancel};
-      } else { #if ( $part_pkg->option('recur_temporality', 1) eq 'upcoming' ) {
-        $cust_bill_pkg->sdate( $sdate );
-        $cust_bill_pkg->edate( $cust_pkg->bill );
-        #$cust_bill_pkg->edate( $time ) if $options{cancel};
-      }
+=cut
 
 
-      $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart)
-        unless $part_pkg->pkgpart == $real_pkgpart;
+sub check_invoicing_list {
+  my( $self, $arrayref ) = @_;
 
 
-      $$total_setup += $setup;
-      $$total_recur += $recur;
+  foreach my $address ( @$arrayref ) {
 
 
-      ###
-      # handle taxes
-      ###
+    if ($address eq 'FAX' and $self->getfield('fax') eq '') {
+      return 'Can\'t add FAX invoice destination with a blank FAX number.';
+    }
 
 
-      my $error = 
-        $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options);
-      return $error if $error;
+    my $cust_main_invoice = new FS::cust_main_invoice ( {
+      'custnum' => $self->custnum,
+      'dest'    => $address,
+    } );
+    my $error = $self->custnum
+                ? $cust_main_invoice->check
+                : $cust_main_invoice->checkdest
+    ;
+    return $error if $error;
 
 
-      push @$cust_bill_pkgs, $cust_bill_pkg;
+  }
 
 
-    } #if $setup != 0 || $recur != 0
-      
-  } #if $line_items
+  return "Email address required"
+    if $conf->exists('cust_main-require_invoicing_list_email', $self->agentnum)
+    && ! grep { $_ !~ /^([A-Z]+)$/ } @$arrayref;
 
   '';
 
   '';
-
 }
 
 }
 
-sub _handle_taxes {
-  my $self = shift;
-  my $part_pkg = shift;
-  my $taxlisthash = shift;
-  my $cust_bill_pkg = shift;
-  my $cust_pkg = shift;
-  my $invoice_time = shift;
-  my $real_pkgpart = shift;
-  my $options = shift;
-
-  my %cust_bill_pkg = ();
-  my %taxes = ();
-    
-  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 && !$options->{cancel});
-  push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
-
-  if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
-
-    if ( $conf->exists('enable_taxproducts')
-         && ( scalar($part_pkg->part_pkg_taxoverride)
-              || $part_pkg->has_taxproduct
-            )
-       )
-    {
-
-      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
-        return "fatal: Can't (yet) use tax-pkg_address with taxproducts";
-      }
+=item all_emails
 
 
-      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;
-      }
+Returns the email addresses of all accounts provisioned for this customer.
 
 
-      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;
-      }
+=cut
 
 
-    } else {
+sub all_emails {
+  my $self = shift;
+  my %list;
+  foreach my $cust_pkg ( $self->all_pkgs ) {
+    my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
+    my @svc_acct =
+      map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
+        grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
+          @cust_svc;
+    $list{$_}=1 foreach map { $_->email } @svc_acct;
+  }
+  keys %list;
+}
 
 
-      my @loc_keys = qw( city county state 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;
-      }
+=item invoicing_list_addpost
 
 
-      $taxhash{'taxclass'} = $part_pkg->taxclass;
+Adds postal invoicing to this customer.  If this customer is already configured
+to receive postal invoices, does nothing.
 
 
-      my @taxes = ();
-      my %taxhash_elim = %taxhash;
-      my @elim = qw( city county state );
-      do { 
+=cut
 
 
-        #first try a match with taxclass
-        @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
+sub invoicing_list_addpost {
+  my $self = shift;
+  if ( $self->get('postal_invoice') eq '' ) {
+    $self->set('postal_invoice', 'Y');
+    my $error = $self->replace;
+    warn $error if $error; # should fail harder, but this is traditional
+  }
+}
 
 
-        if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
-          #then try a match without taxclass
-          my %no_taxclass = %taxhash_elim;
-          $no_taxclass{ 'taxclass' } = '';
-          @taxes = qsearch( 'cust_main_county', \%no_taxclass );
-        }
-
-        $taxhash_elim{ shift(@elim) } = '';
-
-      } while ( !scalar(@taxes) && scalar(@elim) );
-
-      @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
-                    @taxes
-        if $self->cust_main_exemption; #just to be safe
-
-      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 = ();
-  my $separate = $conf->exists('separate_usage');
-  my $usage_mandate = $cust_pkg->part_pkg->option('usage_mandate', 'Hush!');
-  if ( $separate || $cust_bill_pkg->hidden || $usage_mandate ) {
-
-    my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart };
-    my %hash = $cust_bill_pkg->hidden  # maybe for all bill linked?
-               ? (  'section' => $temp_pkg->part_pkg->categoryname )
-               : ();
-
-    my $section = $cust_pkg->part_pkg->option('usage_section', 'Hush!');
-    my $summary = $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
-    if ( $separate ) {
-      push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
-      push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
-    } else {
-      push @display, new FS::cust_bill_pkg_display
-                       { type => '',
-                         %hash,
-                         ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
-                       };
-    }
-
-    if ($separate && $section && $summary) {
-      push @display, new FS::cust_bill_pkg_display { type    => 'U',
-                                                     summary => 'Y',
-                                                     %hash,
-                                                   };
-    }
-    if ($usage_mandate || $section && $summary) {
-      $hash{post_total} = 'Y';
-    }
-
-    $hash{section} = $section if ($separate || $usage_mandate);
-    push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
-
-  }
-  $cust_bill_pkg->set('display', \@display);
-
-  my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
-  foreach my $key (keys %tax_cust_bill_pkg) {
-    my @taxes = @{ $taxes{$key} || [] };
-    my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
-
-    my %localtaxlisthash = ();
-    foreach my $tax ( @taxes ) {
-
-      my $taxname = ref( $tax ). ' '. $tax->taxnum;
-#      $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
-#                  ' locationnum'. $cust_pkg->locationnum
-#        if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
-
-      $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};
-
-      }
-    }
-
-  }
-
-  '';
-}
-
-sub _gather_taxes {
-  my $self = shift;
-  my $part_pkg = shift;
-  my $class = shift;
-
-  my @taxes = ();
-  my $geocode = $self->geocode('cch');
-
-  my @taxclassnums = map { $_->taxclassnum }
-                     $part_pkg->part_pkg_taxoverride($class);
-
-  unless (@taxclassnums) {
-    @taxclassnums = map { $_->taxclassnum }
-                    grep { $_->taxable eq 'Y' }
-                    $part_pkg->part_pkg_taxrate('cch', $geocode, $class);
-  }
-  warn "Found taxclassnum values of ". join(',', @taxclassnums)
-    if $DEBUG;
-
-  my $extra_sql =
-    "AND (".
-    join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
-
-  @taxes = qsearch({ 'table' => 'tax_rate',
-                     'hashref' => { 'geocode' => $geocode, },
-                     'extra_sql' => $extra_sql,
-                  })
-    if scalar(@taxclassnums);
-
-  warn "Found taxes ".
-       join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n" 
-   if $DEBUG;
-
-  [ @taxes ];
-
-}
-
-=item collect [ HASHREF | OPTION => VALUE ... ]
-
-(Attempt to) collect money for this customer's outstanding invoices (see
-L<FS::cust_bill>).  Usually used after the bill method.
-
-Actions are now triggered by billing events; see L<FS::part_event> and the
-billing events web interface.  Old-style invoice events (see
-L<FS::part_bill_event>) have been deprecated.
-
-If there is an error, returns the error, otherwise returns false.
-
-Options are passed as name-value pairs.
-
-Currently available options are:
-
-=over 4
-
-=item invoice_time
-
-Use this time when deciding when to print invoices and late notices on those invoices.  The default is now.  It is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.
-
-=item retry
-
-Retry card/echeck/LEC transactions even when not scheduled by invoice events.
-
-=item check_freq
-
-"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
-
-=item quiet
-
-set true to surpress email card/ACH decline notices.
-
-=item debug
-
-Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
-
-=back
-
-# =item payby
-#
-# allows for one time override of normal customer billing method
-
-=cut
-
-sub collect {
-  my( $self, %options ) = @_;
-  my $invoice_time = $options{'invoice_time'} || time;
-
-  #put below somehow?
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  $self->select_for_update; #mutex
-
-  if ( $DEBUG ) {
-    my $balance = $self->balance;
-    warn "$me collect customer ". $self->custnum. ": balance $balance\n"
-  }
-
-  if ( exists($options{'retry_card'}) ) {
-    carp 'retry_card option passed to collect is deprecated; use retry';
-    $options{'retry'} ||= $options{'retry_card'};
-  }
-  if ( exists($options{'retry'}) && $options{'retry'} ) {
-    my $error = $self->retry_realtime;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }
-  }
-
-  my $error = $self->do_cust_event(
-    'debug'      => ( $options{'debug'} || 0 ),
-    'time'       => $invoice_time,
-    'check_freq' => $options{'check_freq'},
-    'stage'      => 'collect',
-  );
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
-
-}
-
-=item do_cust_event [ HASHREF | OPTION => VALUE ... ]
-
-Runs billing events; see L<FS::part_event> and the billing events web
-interface.
-
-If there is an error, returns the error, otherwise returns false.
-
-Options are passed as name-value pairs.
-
-Currently available options are:
-
-=over 4
-
-=item time
-
-Use this time when deciding when to print invoices and late notices on those invoices.  The default is now.  It is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.
-
-=item check_freq
-
-"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
-
-=item stage
-
-"collect" (the default) or "pre-bill"
-
-=item quiet
-set true to surpress email card/ACH decline notices.
-
-=item debug
-
-Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
-
-=cut
-
-# =item payby
-#
-# allows for one time override of normal customer billing method
-
-# =item retry
-#
-# Retry card/echeck/LEC transactions even when not scheduled by invoice events.
-
-sub do_cust_event {
-  my( $self, %options ) = @_;
-  my $time = $options{'time'} || time;
-
-  #put below somehow?
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  $self->select_for_update; #mutex
-
-  if ( $DEBUG ) {
-    my $balance = $self->balance;
-    warn "$me do_cust_event customer ". $self->custnum. ": balance $balance\n"
-  }
-
-#  if ( exists($options{'retry_card'}) ) {
-#    carp 'retry_card option passed to collect is deprecated; use retry';
-#    $options{'retry'} ||= $options{'retry_card'};
-#  }
-#  if ( exists($options{'retry'}) && $options{'retry'} ) {
-#    my $error = $self->retry_realtime;
-#    if ( $error ) {
-#      $dbh->rollback if $oldAutoCommit;
-#      return $error;
-#    }
-#  }
-
-  # false laziness w/pay_batch::import_results
-
-  my $due_cust_event = $self->due_cust_event(
-    'debug'      => ( $options{'debug'} || 0 ),
-    'time'       => $time,
-    'check_freq' => $options{'check_freq'},
-    'stage'      => ( $options{'stage'} || 'collect' ),
-  );
-  unless( ref($due_cust_event) ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $due_cust_event;
-  }
-
-  foreach my $cust_event ( @$due_cust_event ) {
-
-    #XXX lock event
-    
-    #re-eval event conditions (a previous event could have changed things)
-    unless ( $cust_event->test_conditions( 'time' => $time ) ) {
-      #don't leave stray "new/locked" records around
-      my $error = $cust_event->delete;
-      if ( $error ) {
-        #gah, even with transactions
-        $dbh->commit if $oldAutoCommit; #well.
-        return $error;
-      }
-      next;
-    }
-
-    {
-      local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
-      warn "  running cust_event ". $cust_event->eventnum. "\n"
-        if $DEBUG > 1;
-
-      
-      #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
-      if ( my $error = $cust_event->do_event() ) {
-        #XXX wtf is this?  figure out a proper dealio with return value
-        #from do_event
-         # gah, even with transactions.
-         $dbh->commit if $oldAutoCommit; #well.
-         return $error;
-       }
-    }
-
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
-
-}
-
-=item due_cust_event [ HASHREF | OPTION => VALUE ... ]
-
-Inserts database records for and returns an ordered listref of new events due
-for this customer, as FS::cust_event objects (see L<FS::cust_event>).  If no
-events are due, an empty listref is returned.  If there is an error, returns a
-scalar error message.
-
-To actually run the events, call each event's test_condition method, and if
-still true, call the event's do_event method.
-
-Options are passed as a hashref or as a list of name-value pairs.  Available
-options are:
-
-=over 4
-
-=item check_freq
-
-Search only for events of this check frequency (how often events of this type are checked); currently "1d" (daily, the default) and "1m" (monthly) are recognized.
-
-=item stage
-
-"collect" (the default) or "pre-bill"
-
-=item time
-
-"Current time" for the events.
-
-=item debug
-
-Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
-
-=item eventtable
-
-Only return events for the specified eventtable (by default, events of all eventtables are returned)
-
-=item objects
-
-Explicitly pass the objects to be tested (typically used with eventtable).
-
-=item testonly
-
-Set to true to return the objects, but not actually insert them into the
-database.
-
-=back
-
-=cut
-
-sub due_cust_event {
-  my $self = shift;
-  my %opt = ref($_[0]) ? %{ $_[0] } : @_;
-
-  #???
-  #my $DEBUG = $opt{'debug'}
-  local($DEBUG) = $opt{'debug'}
-    if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG;
-
-  warn "$me due_cust_event called with options ".
-       join(', ', map { "$_: $opt{$_}" } keys %opt). "\n"
-    if $DEBUG;
-
-  $opt{'time'} ||= time;
-
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  $self->select_for_update #mutex
-    unless $opt{testonly};
-
-  ###
-  # find possible events (initial search)
-  ###
-  
-  my @cust_event = ();
-
-  my @eventtable = $opt{'eventtable'}
-                     ? ( $opt{'eventtable'} )
-                     : FS::part_event->eventtables_runorder;
-
-  foreach my $eventtable ( @eventtable ) {
-
-    my @objects;
-    if ( $opt{'objects'} ) {
-
-      @objects = @{ $opt{'objects'} };
-
-    } else {
-
-      #my @objects = $self->eventtable(); # sub cust_main { @{ [ $self ] }; }
-      @objects = ( $eventtable eq 'cust_main' )
-                   ? ( $self )
-                   : ( $self->$eventtable() );
-
-    }
-
-    my @e_cust_event = ();
-
-    my $cross = "CROSS JOIN $eventtable";
-    $cross .= ' LEFT JOIN cust_main USING ( custnum )'
-      unless $eventtable eq 'cust_main';
-
-    foreach my $object ( @objects ) {
-
-      #this first search uses the condition_sql magic for optimization.
-      #the more possible events we can eliminate in this step the better
-
-      my $cross_where = '';
-      my $pkey = $object->primary_key;
-      $cross_where = "$eventtable.$pkey = ". $object->$pkey();
-
-      my $join = FS::part_event_condition->join_conditions_sql( $eventtable );
-      my $extra_sql =
-        FS::part_event_condition->where_conditions_sql( $eventtable,
-                                                        'time'=>$opt{'time'}
-                                                      );
-      my $order = FS::part_event_condition->order_conditions_sql( $eventtable );
-
-      $extra_sql = "AND $extra_sql" if $extra_sql;
-
-      #here is the agent virtualization
-      $extra_sql .= " AND (    part_event.agentnum IS NULL
-                            OR part_event.agentnum = ". $self->agentnum. ' )';
-
-      $extra_sql .= " $order";
-
-      warn "searching for events for $eventtable ". $object->$pkey. "\n"
-        if $opt{'debug'} > 2;
-      my @part_event = qsearch( {
-        'debug'     => ( $opt{'debug'} > 3 ? 1 : 0 ),
-        'select'    => 'part_event.*',
-        'table'     => 'part_event',
-        'addl_from' => "$cross $join",
-        'hashref'   => { 'check_freq' => ( $opt{'check_freq'} || '1d' ),
-                         'eventtable' => $eventtable,
-                         'disabled'   => '',
-                       },
-        'extra_sql' => "AND $cross_where $extra_sql",
-      } );
-
-      if ( $DEBUG > 2 ) {
-        my $pkey = $object->primary_key;
-        warn "      ". scalar(@part_event).
-             " possible events found for $eventtable ". $object->$pkey(). "\n";
-      }
-
-      push @e_cust_event, map { $_->new_cust_event($object) } @part_event;
-
-    }
-
-    warn "    ". scalar(@e_cust_event).
-         " subtotal possible cust events found for $eventtable\n"
-      if $DEBUG > 1;
-
-    push @cust_event, @e_cust_event;
-
-  }
-
-  warn "  ". scalar(@cust_event).
-       " total possible cust events found in initial search\n"
-    if $DEBUG; # > 1;
-
-
-  ##
-  # test stage
-  ##
-
-  $opt{stage} ||= 'collect';
-  @cust_event =
-    grep { my $stage = $_->part_event->event_stage;
-           $opt{stage} eq $stage or ( ! $stage && $opt{stage} eq 'collect' )
-         }
-         @cust_event;
-
-  ##
-  # test conditions
-  ##
-  
-  my %unsat = ();
-
-  @cust_event = grep $_->test_conditions( 'time'          => $opt{'time'},
-                                          'stats_hashref' => \%unsat ),
-                     @cust_event;
-
-  warn "  ". scalar(@cust_event). " cust events left satisfying conditions\n"
-    if $DEBUG; # > 1;
-
-  warn "    invalid conditions not eliminated with condition_sql:\n".
-       join('', map "      $_: ".$unsat{$_}."\n", keys %unsat )
-    if keys %unsat && $DEBUG; # > 1;
-
-  ##
-  # insert
-  ##
-
-  unless( $opt{testonly} ) {
-    foreach my $cust_event ( @cust_event ) {
-
-      my $error = $cust_event->insert();
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
-                                       
-    }
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
-  ##
-  # return
-  ##
-
-  warn "  returning events: ". Dumper(@cust_event). "\n"
-    if $DEBUG > 2;
-
-  \@cust_event;
-
-}
-
-=item retry_realtime
-
-Schedules realtime / batch  credit card / electronic check / LEC billing
-events for for retry.  Useful if card information has changed or manual
-retry is desired.  The 'collect' method must be called to actually retry
-the transaction.
-
-Implementation details: For either this customer, or for each of this
-customer's open invoices, changes the status of the first "done" (with
-statustext error) realtime processing event to "failed".
-
-=cut
-
-sub retry_realtime {
-  my $self = shift;
-
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  #a little false laziness w/due_cust_event (not too bad, really)
-
-  my $join = FS::part_event_condition->join_conditions_sql;
-  my $order = FS::part_event_condition->order_conditions_sql;
-  my $mine = 
-  '( '
-   . join ( ' OR ' , map { 
-    "( part_event.eventtable = " . dbh->quote($_) 
-    . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
-   } FS::part_event->eventtables)
-   . ') ';
-
-  #here is the agent virtualization
-  my $agent_virt = " (    part_event.agentnum IS NULL
-                       OR part_event.agentnum = ". $self->agentnum. ' )';
-
-  #XXX this shouldn't be hardcoded, actions should declare it...
-  my @realtime_events = qw(
-    cust_bill_realtime_card
-    cust_bill_realtime_check
-    cust_bill_realtime_lec
-    cust_bill_batch
-  );
-
-  my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
-                                                  @realtime_events
-                                     ).
-                          ' ) ';
-
-  my @cust_event = qsearchs({
-    'table'     => 'cust_event',
-    'select'    => 'cust_event.*',
-    'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
-    'hashref'   => { 'status' => 'done' },
-    'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
-                   " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
-  });
-
-  my %seen_invnum = ();
-  foreach my $cust_event (@cust_event) {
-
-    #max one for the customer, one for each open invoice
-    my $cust_X = $cust_event->cust_X;
-    next if $seen_invnum{ $cust_event->part_event->eventtable eq 'cust_bill'
-                          ? $cust_X->invnum
-                          : 0
-                        }++
-         or $cust_event->part_event->eventtable eq 'cust_bill'
-            && ! $cust_X->owed;
-
-    my $error = $cust_event->retry;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "error scheduling event for retry: $error";
-    }
-
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
-
-}
-
-
-=cut
-
-=item realtime_collect [ OPTION => VALUE ... ]
-
-Runs a realtime credit card, ACH (electronic check) or phone bill transaction
-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.
-
-On failure returns an error message.
-
-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<pkgnum>
-
-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,
-if set, will override the value from the customer record.
-
-I<description> is a free-text field passed to the gateway.  It defaults to
-the value defined by the business-onlinepayment-description configuration
-option, or "Internet services" if that is unset.
-
-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 or set the I<apply> option.
-
-I<apply> can be set to true to apply a resulting payment.
-
-I<quiet> can be set true to surpress email decline notices.
-
-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
-
-=cut
-
-sub realtime_collect {
-  my( $self, %options ) = @_;
-
-  if ( $DEBUG ) {
-    warn "$me realtime_collect:\n";
-    warn "  $_ => $options{$_}\n" foreach keys %options;
-  }
-
-  $options{amount} = $self->balance unless exists( $options{amount} );
-  $options{method} = FS::payby->payby2bop($self->payby)
-    unless exists( $options{method} );
-
-  return $self->realtime_bop({%options});
-
-}
-
-=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<apply>, 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
-the value defined by the business-onlinepayment-description configuration
-option, or "Internet services" if that is unset.
-
-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 or set the I<apply> option.
-
-I<apply> can be set to true to apply a resulting payment.
-
-I<quiet> can be set true to surpress email decline notices.
-
-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 _bop_recurring_billing {
-  my( $self, %opt ) = @_;
-
-  my $method = scalar($conf->config('credit_card-recurring_billing_flag'));
-
-  if ( defined($method) && $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;
-
-}
-
-sub _payment_gateway {
-  my ($self, $options) = @_;
-
-  $options->{payment_gateway} = $self->agent->payment_gateway( %$options )
-    unless exists($options->{payment_gateway});
-
-  $options->{payment_gateway};
-}
-
-sub _bop_auth {
-  my ($self, $options) = @_;
-
-  (
-    'login'    => $options->{payment_gateway}->gateway_username,
-    'password' => $options->{payment_gateway}->gateway_password,
-  );
-}
-
-sub _bop_options {
-  my ($self, $options) = @_;
-
-  $options->{payment_gateway}->gatewaynum
-    ? $options->{payment_gateway}->options
-    : @{ $options->{payment_gateway}->get('options') };
-}
-
-sub _bop_defaults {
-  my ($self, $options) = @_;
-
-  unless ( $options->{'description'} ) {
-    if ( $conf->exists('business-onlinepayment-description') ) {
-      my $dtempl = $conf->config('business-onlinepayment-description');
-
-      my $agent = $self->agent->agent;
-      #$pkgs... not here
-      $options->{'description'} = eval qq("$dtempl");
-    } else {
-      $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'});
-
-  $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 {
-    my ( $method, $amount ) = ( shift, shift );
-    %options = @_;
-    $options{method} = $method;
-    $options{amount} = $amount;
-  }
-  
-  if ( $DEBUG ) {
-    warn "$me realtime_bop (new): $options{method} $options{amount}\n";
-    warn "  $_ => $options{$_}\n" foreach keys %options;
-  }
-
-  return $self->fake_bop(%options) if $options{'fake'};
-
-  $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
-  ###
-
-  my $payment_gateway =  $self->_payment_gateway( \%options );
-  my $namespace = $payment_gateway->gateway_namespace;
-
-  eval "use $namespace";  
-  die $@ if $@;
-
-  ###
-  # 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 (%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;
-  if ( $conf->exists('emailinvoiceautoalways')
-       || $conf->exists('emailinvoiceauto') && ! @invoicing_list
-       || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
-    push @invoicing_list, $self->all_emails;
-  }
-
-  my $email = ($conf->exists('business-onlinepayment-email-override'))
-              ? $conf->config('business-onlinepayment-email-override')
-              : $invoicing_list[0];
-
-  my $paydate = '';
-  my %content = ();
-  if ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'CC' ) {
-
-    $content{card_number} = $options{payinfo};
-    $paydate = exists($options{'paydate'})
-                    ? $options{'paydate'}
-                    : $self->paydate;
-    $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
-    $content{expiration} = "$2/$1";
-
-    my $paycvv = exists($options{'paycvv'})
-                   ? $options{'paycvv'}
-                   : $self->paycvv;
-    $content{cvv2} = $paycvv
-      if length($paycvv);
-
-    my $paystart_month = exists($options{'paystart_month'})
-                           ? $options{'paystart_month'}
-                           : $self->paystart_month;
-
-    my $paystart_year  = exists($options{'paystart_year'})
-                           ? $options{'paystart_year'}
-                           : $self->paystart_year;
-
-    $content{card_start} = "$paystart_month/$paystart_year"
-      if $paystart_month && $paystart_year;
-
-    my $payissue       = exists($options{'payissue'})
-                           ? $options{'payissue'}
-                           : $self->payissue;
-    $content{issue_number} = $payissue if $payissue;
-
-    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} ) =
-      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{customer_org} = $self->company ? 'B' : 'I';
-    $content{state_id}       = exists($options{'stateid'})
-                                 ? $options{'stateid'}
-                                 : $self->getfield('stateid');
-    $content{state_id_state} = exists($options{'stateid_state'})
-                                 ? $options{'stateid_state'}
-                                 : $self->getfield('stateid_state');
-    $content{customer_ssn} = exists($options{'ss'})
-                               ? $options{'ss'}
-                               : $self->ss;
-  } 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
-  }
-
-  ###
-  # run transaction(s)
-  ###
-
-  my $balance = exists( $options{'balance'} )
-                  ? $options{'balance'}
-                  : $self->balance;
-
-  $self->select_for_update; #mutex ... just until we get our pending record in
-
-  #the checks here are intended to catch concurrent payments
-  #double-form-submission prevention is taken care of in cust_pay_pending::check
-
-  #check the balance
-  return "The customer's balance has changed; $options{method} transaction aborted."
-    if $self->balance < $balance;
-    #&& $self->balance < $options{amount}; #might as well anyway?
-
-  #also check and make sure there aren't *other* pending payments for this cust
-
-  my @pending = qsearch('cust_pay_pending', {
-    'custnum' => $self->custnum,
-    'status'  => { op=>'!=', value=>'done' } 
-  });
-  return "A payment is already being processed for this customer (".
-         join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
-         "); $options{method} transaction aborted."
-    if scalar(@pending);
-
-  #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,
-    'recurring_billing' => $content{recurring_billing},
-    'pkgnum'            => $options{'pkgnum'},
-    '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});
-  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*/, $payment_gateway->gateway_action );
-
-  my $transaction = new $namespace( $payment_gateway->gateway_module,
-                                    $self->_bop_options(\%options),
-                                  );
-
-  $transaction->content(
-    'type'           => $options{method},
-    $self->_bop_auth(\%options),          
-    'action'         => $action1,
-    'description'    => $options{'description'},
-    'amount'         => $options{amount},
-    #'invoice_number' => $options{'invnum'},
-    'customer_id'    => $self->custnum,
-    %bop_content,
-    'reference'      => $cust_pay_pending->paypendingnum, #for now
-    'email'          => $email,
-    %content, #after
-  );
-
-  $cust_pay_pending->status('pending');
-  my $cpp_pending_err = $cust_pay_pending->replace;
-  return $cpp_pending_err if $cpp_pending_err;
-
-  #config?
-  my $BOP_TESTING = 0;
-  my $BOP_TESTING_SUCCESS = 1;
-
-  unless ( $BOP_TESTING ) {
-    $transaction->submit();
-  } else {
-    if ( $BOP_TESTING_SUCCESS ) {
-      $transaction->is_success(1);
-      $transaction->authorization('fake auth');
-    } else {
-      $transaction->is_success(0);
-      $transaction->error_message('fake failure');
-    }
-  }
-
-  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;
-    return $cpp_authorized_err if $cpp_authorized_err;
-
-    my $auth = $transaction->authorization;
-    my $ordernum = $transaction->can('order_number')
-                   ? $transaction->order_number
-                   : '';
-
-    my $capture =
-      new Business::OnlinePayment( $payment_gateway->gateway_module,
-                                   $self->_bop_options(\%options),
-                                 );
-
-    my %capture = (
-      %content,
-      type           => $options{method},
-      action         => $action2,
-      $self->_bop_auth(\%options),          
-      order_number   => $ordernum,
-      amount         => $options{amount},
-      authorization  => $auth,
-      description    => $options{'description'},
-    );
-
-    foreach my $field (qw( authorization_source_code returned_ACI
-                           transaction_identifier validation_code           
-                           transaction_sequence_num local_transaction_date    
-                           local_transaction_time AVS_result_code          )) {
-      $capture{$field} = $transaction->$field() if $transaction->can($field);
-    }
-
-    $capture->content( %capture );
-
-    $capture->submit();
-
-    unless ( $capture->is_success ) {
-      my $e = "Authorization successful but capture failed, custnum #".
-              $self->custnum. ': '.  $capture->result_code.
-              ": ". $capture->error_message;
-      warn $e;
-      return $e;
-    }
-
-  }
-
-  ###
-  # remove paycvv after initial transaction
-  ###
-
-  #false laziness w/misc/process/payment.cgi - check both to make sure working
-  # correctly
-  if ( defined $self->dbdef_table->column('paycvv')
-       && length($self->paycvv)
-       && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save')
-  ) {
-    my $error = $self->remove_cvv;
-    if ( $error ) {
-      warn "WARNING: error removing cvv: $error\n";
-    }
-  }
-
-  ###
-  # 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->gatewaynum ) { # agent override
-      $paybatch = $payment_gateway->gatewaynum. '-';
-    }
-
-    $paybatch .= $payment_gateway->gateway_module. ":".
-      $transaction->authorization;
-
-    $paybatch .= ':'. $transaction->order_number
-      if $transaction->can('order_number')
-      && length($transaction->order_number);
-
-    my $cust_pay = new FS::cust_pay ( {
-       'custnum'  => $self->custnum,
-       'invnum'   => $options{'invnum'},
-       'paid'     => $cust_pay_pending->paid,
-       '_date'    => '',
-       'payby'    => $cust_pay_pending->payby,
-       #'payinfo'  => $payinfo,
-       'paybatch' => $paybatch,
-       'paydate'  => $cust_pay_pending->paydate,
-       'pkgnum'   => $cust_pay_pending->pkgnum,
-    } );
-    #doesn't hurt to know, even though the dup check is in cust_pay_pending now
-    $cust_pay->payunique( $options{payunique} )
-      if defined($options{payunique}) && length($options{payunique});
-
-    my $oldAutoCommit = $FS::UID::AutoCommit;
-    local $FS::UID::AutoCommit = 0;
-    my $dbh = dbh;
-
-    #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
-
-    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.  but at least we have a record of the state we had to abort in
-        # from cust_pay_pending now.
-        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";
-        warn $e;
-        return $e;
-      }
-    }
-
-    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: $options{method} captured but payment not recorded - ".
-              "error updating status for paypendingnum ".
-              $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
-      warn $e;
-      return $e;
-
-    } else {
-
-      $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
-      if ( $options{'apply'} ) {
-        my $apply_error = $self->apply_payments_and_credits;
-        if ( $apply_error ) {
-          warn "WARNING: error applying payment: $apply_error\n";
-          #but we still should return no error cause the payment otherwise went
-          #through...
-        }
-      }
-
-      return ''; #no error
-
-    }
-
-  } else {
-
-    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;
-      if ( $transaction->can('response_page') ) {
-        $t_response = {
-                        'page'    => ( $transaction->can('response_page')
-                                         ? $transaction->response_page
-                                         : ''
-                                     ),
-                        'code'    => ( $transaction->can('response_code')
-                                         ? $transaction->response_code
-                                         : ''
-                                     ),
-                        'headers' => ( $transaction->can('response_headers')
-                                         ? $transaction->response_headers
-                                         : ''
-                                     ),
-                      };
-      } else {
-        $t_response .=
-          "No additional debugging information available for ".
-            $payment_gateway->gateway_module;
-      }
-
-      $perror .= "No error_message returned from ".
-                   $payment_gateway->gateway_module. " -- ".
-                 ( ref($t_response) ? Dumper($t_response) : $t_response );
-
-    }
-
-    if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
-         && $conf->exists('emaildecline')
-         && grep { $_ ne 'POST' } $self->invoicing_list
-         && ! grep { $transaction->error_message =~ /$_/ }
-                   $conf->config('emaildecline-exclude')
-    ) {
-      my @templ = $conf->config('declinetemplate');
-      my $template = new Text::Template (
-        TYPE   => 'ARRAY',
-        SOURCE => [ map "$_\n", @templ ],
-      ) or return "($perror) can't create template: $Text::Template::ERROR";
-      $template->compile()
-        or return "($perror) can't compile template: $Text::Template::ERROR";
-
-      my $templ_hash = {
-        'company_name'    =>
-          scalar( $conf->config('company_name', $self->agentnum ) ),
-        'company_address' =>
-          join("\n", $conf->config('company_address', $self->agentnum ) ),
-        'error'           => $transaction->error_message,
-      };
-
-      my $error = send_email(
-        'from'    => $conf->config('invoice_from', $self->agentnum ),
-        'to'      => [ grep { $_ ne 'POST' } $self->invoicing_list ],
-        'subject' => 'Your payment could not be processed',
-        'body'    => [ $template->fill_in(HASH => $templ_hash) ],
-      );
-
-      $perror .= " (also received error sending decline notification: $error)"
-        if $error;
-
-    }
-
-    $cust_pay_pending->status('done');
-    $cust_pay_pending->statustext("declined: $perror");
-    my $cpp_done_err = $cust_pay_pending->replace;
-    if ( $cpp_done_err ) {
-      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)";
-    }
-
-    return $perror;
-  }
-
-}
-
-=item realtime_botpp_capture CUST_PAY_PENDING [ OPTION => VALUE ... ]
-
-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.
-
-Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
-
-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.
-
-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.
-
-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.
-
-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;
-  }
-
-  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;
-  }
-
-  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 DEPRECATED -- use agent->payment_gateway
-
-=cut
-
-sub default_payment_gateway {
-  my( $self, $method ) = @_;
-
-  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'
-    if $method =~ /^(ECHECK|CHEK)$/ && $conf->exists($bop_config. '-ach');
-  my ( $processor, $login, $password, $action, @bop_options ) =
-    $conf->config($bop_config);
-  $action ||= 'normal authorization';
-  pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
-  die "No real-time processor is enabled - ".
-      "did you set the business-onlinepayment configuration value?\n"
-    unless $processor;
-
-  ( $processor, $login, $password, $action, @bop_options )
-}
-
-=item remove_cvv
-
-Removes the I<paycvv> field from the database directly.
-
-If there is an error, returns the error, otherwise returns false.
-
-=cut
-
-sub remove_cvv {
-  my $self = shift;
-  my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?")
-    or return dbh->errstr;
-  $sth->execute($self->custnum)
-    or return $sth->errstr;
-  $self->paycvv('');
-  '';
-}
-
-=item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
-
-Refunds 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.
-
-Available methods are: I<CC>, I<ECHECK> and I<LEC>
-
-Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
-
-Most gateways require a reference to an original payment transaction to refund,
-so you probably need to specify a I<paynum>.
-
-I<amount> defaults to the original amount of the payment if not specified.
-
-I<reason> specifies a reason for the refund.
-
-I<paydate> specifies the expiration date for a credit card overriding the
-value from the customer record or the payment record. Specified as yyyy-mm-dd
-
-Implementation note: If I<amount> is unspecified or equal to the amount of the
-orignal payment, first an attempt is made to "void" the transaction via
-the gateway (to cancel a not-yet settled transaction) and then if that fails,
-the normal attempt is made to "refund" ("credit") the transaction via the
-gateway is attempted.
-
-#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.
-
-#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.
-
-=cut
-
-#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 = 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 (new): $options{method} refund\n";
-    warn "  $_ => $options{$_}\n" foreach keys %options;
-  }
-
-  ###
-  # look up the original payment and optionally a gateway for that payment
-  ###
-
-  my $cust_pay = '';
-  my $amount = $options{'amount'};
-
-  my( $processor, $login, $password, @bop_options, $namespace ) ;
-  my( $auth, $order_number ) = ( '', '', '' );
-
-  if ( $options{'paynum'} ) {
-
-    warn "  paynum: $options{paynum}\n" if $DEBUG > 1;
-    $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
-      or return "Unknown paynum $options{'paynum'}";
-    $amount ||= $cust_pay->paid;
-
-    $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
-      or return "Can't parse paybatch for paynum $options{'paynum'}: ".
-                $cust_pay->paybatch;
-    my $gatewaynum = '';
-    ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
-
-    if ( $gatewaynum ) { #gateway for the payment to be refunded
-
-      my $payment_gateway =
-        qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
-      die "payment gateway $gatewaynum not found"
-        unless $payment_gateway;
-
-      $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;
-      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"
-        unless $processor eq $conf_processor;
-
-    }
-
-
-  } else { # didn't specify a paynum, so look for agent gateway overrides
-           # like a normal transaction 
-    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 );
-
-    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'           => $options{method},
-    'login'          => $login,
-    'password'       => $password,
-    'order_number'   => $order_number,
-    'amount'         => $amount,
-    'referer'        => 'http://cleanwhisker.420.am/', #XXX fix referer :/
-  );
-  $content{authorization} = $auth
-    if length($auth); #echeck/ACH transactions have an order # but no auth
-                      #(at least with authorize.net)
-
-  my $disable_void_after;
-  if ($conf->exists('disable_void_after')
-      && $conf->config('disable_void_after') =~ /^(\d+)$/) {
-    $disable_void_after = $1;
-  }
-
-  #first try void if applicable
-  if ( $cust_pay && $cust_pay->paid == $amount
-    && (
-      ( not defined($disable_void_after) )
-      || ( time < ($cust_pay->_date + $disable_void_after ) )
-    )
-  ) {
-    warn "  attempting void\n" if $DEBUG > 1;
-    my $void = new Business::OnlinePayment( $processor, @bop_options );
-    if ( $void->can('info') ) {
-      if ( $cust_pay->payby eq 'CARD'
-           && $void->info('CC_void_requires_card') )
-      {
-        $content{'card_number'} = $cust_pay->payinfo;
-      } elsif ( $cust_pay->payby eq 'CHEK'
-                && $void->info('ECHECK_void_requires_account') )
-      {
-        ( $content{'account_number'}, $content{'routing_code'} ) =
-          split('@', $cust_pay->payinfo);
-        $content{'name'} = $self->get('first'). ' '. $self->get('last');
-      }
-    }
-    $void->content( 'action' => 'void', %content );
-    $void->submit();
-    if ( $void->is_success ) {
-      my $error = $cust_pay->void($options{'reason'});
-      if ( $error ) {
-        # gah, even with transactions.
-        my $e = 'WARNING: Card/ACH voided but database not updated - '.
-                "error voiding payment: $error";
-        warn $e;
-        return $e;
-      }
-      warn "  void successful\n" if $DEBUG > 1;
-      return '';
-    }
-  }
-
-  warn "  void unsuccessful, trying refund\n"
-    if $DEBUG > 1;
-
-  #massage data
-  my $address = $self->address1;
-  $address .= ", ". $self->address2 if $self->address2;
-
-  my($payname, $payfirst, $paylast);
-  if ( $self->payname && $options{method} ne 'ECHECK' ) {
-    $payname = $self->payname;
-    $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 @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;
-  }
-
-  my $email = ($conf->exists('business-onlinepayment-email-override'))
-              ? $conf->config('business-onlinepayment-email-override')
-              : $invoicing_list[0];
-
-  my $payip = exists($options{'payip'})
-                ? $options{'payip'}
-                : $self->payip;
-  $content{customer_ip} = $payip
-    if length($payip);
-
-  my $payinfo = '';
-  if ( $options{method} eq 'CC' ) {
-
-    if ( $cust_pay ) {
-      $content{card_number} = $payinfo = $cust_pay->payinfo;
-      (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
-        =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
-        ($content{expiration} = "$2/$1");  # where available
-    } else {
-      $content{card_number} = $payinfo = $self->payinfo;
-      (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
-        =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
-      $content{expiration} = "$2/$1";
-    }
-
-  } elsif ( $options{method} eq 'ECHECK' ) {
-
-    if ( $cust_pay ) {
-      $payinfo = $cust_pay->payinfo;
-    } else {
-      $payinfo = $self->payinfo;
-    } 
-    ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
-    $content{bank_name} = $self->payname;
-    $content{account_type} = 'CHECKING';
-    $content{account_name} = $payname;
-    $content{customer_org} = $self->company ? 'B' : 'I';
-    $content{customer_ssn} = $self->ss;
-  } elsif ( $options{method} eq 'LEC' ) {
-    $content{phone} = $payinfo = $self->payinfo;
-  }
-
-  #then try refund
-  my $refund = new Business::OnlinePayment( $processor, @bop_options );
-  my %sub_content = $refund->content(
-    'action'         => 'credit',
-    'customer_id'    => $self->custnum,
-    'last_name'      => $paylast,
-    'first_name'     => $payfirst,
-    'name'           => $payname,
-    'address'        => $address,
-    'city'           => $self->city,
-    'state'          => $self->state,
-    'zip'            => $self->zip,
-    'country'        => $self->country,
-    'email'          => $email,
-    'phone'          => $self->daytime || $self->night,
-    %content, #after
-  );
-  warn join('', map { "  $_ => $sub_content{$_}\n" } keys %sub_content )
-    if $DEBUG > 1;
-  $refund->submit();
-
-  return "$processor error: ". $refund->error_message
-    unless $refund->is_success();
-
-  my $paybatch = "$processor:". $refund->authorization;
-  $paybatch .= ':'. $refund->order_number
-    if $refund->can('order_number') && $refund->order_number;
-
-  while ( $cust_pay && $cust_pay->unapplied < $amount ) {
-    my @cust_bill_pay = $cust_pay->cust_bill_pay;
-    last unless @cust_bill_pay;
-    my $cust_bill_pay = pop @cust_bill_pay;
-    my $error = $cust_bill_pay->delete;
-    last if $error;
-  }
-
-  my $cust_refund = new FS::cust_refund ( {
-    'custnum'  => $self->custnum,
-    'paynum'   => $options{'paynum'},
-    'refund'   => $amount,
-    '_date'    => '',
-    'payby'    => $bop_method2payby{$options{method}},
-    'payinfo'  => $payinfo,
-    'paybatch' => $paybatch,
-    'reason'   => $options{'reason'} || 'card or ACH refund',
-  } );
-  my $error = $cust_refund->insert;
-  if ( $error ) {
-    $cust_refund->paynum(''); #try again with no specific paynum
-    my $error2 = $cust_refund->insert;
-    if ( $error2 ) {
-      # gah, even with transactions.
-      my $e = 'WARNING: Card/ACH refunded but database not updated - '.
-              "error inserting refund ($processor): $error2".
-              " (previously tried insert with paynum #$options{'paynum'}" .
-              ": $error )";
-      warn $e;
-      return $e;
-    }
-  }
-
-  ''; #no error
-
-}
-
-=item batch_card OPTION => VALUE...
-
-Adds a payment for this invoice to the pending credit card batch (see
-L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
-runs the payment using a realtime gateway.
-
-=cut
-
-sub batch_card {
-  my ($self, %options) = @_;
-
-  my $amount;
-  if (exists($options{amount})) {
-    $amount = $options{amount};
-  }else{
-    $amount = sprintf("%.2f", $self->balance - $self->in_transit_payments);
-  }
-  return '' unless $amount > 0;
-  
-  my $invnum = delete $options{invnum};
-  my $payby = $options{invnum} || $self->payby;  #dubious
-
-  if ($options{'realtime'}) {
-    return $self->realtime_bop( FS::payby->payby2bop($self->payby),
-                                $amount,
-                                %options,
-                              );
-  }
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  #this needs to handle mysql as well as Pg, like svc_acct.pm
-  #(make it into a common function if folks need to do batching with mysql)
-  $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
-    or return "Cannot lock pay_batch: " . $dbh->errstr;
-
-  my %pay_batch = (
-    'status' => 'O',
-    'payby'  => FS::payby->payby2payment($payby),
-  );
-
-  my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
-
-  unless ( $pay_batch ) {
-    $pay_batch = new FS::pay_batch \%pay_batch;
-    my $error = $pay_batch->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      die "error creating new batch: $error\n";
-    }
-  }
-
-  my $old_cust_pay_batch = qsearchs('cust_pay_batch', {
-      'batchnum' => $pay_batch->batchnum,
-      'custnum'  => $self->custnum,
-  } );
-
-  foreach (qw( address1 address2 city state zip country payby payinfo paydate
-               payname )) {
-    $options{$_} = '' unless exists($options{$_});
-  }
-
-  my $cust_pay_batch = new FS::cust_pay_batch ( {
-    'batchnum' => $pay_batch->batchnum,
-    'invnum'   => $invnum || 0,                    # is there a better value?
-                                                   # this field should be
-                                                   # removed...
-                                                   # cust_bill_pay_batch now
-    'custnum'  => $self->custnum,
-    'last'     => $self->getfield('last'),
-    'first'    => $self->getfield('first'),
-    'address1' => $options{address1} || $self->address1,
-    'address2' => $options{address2} || $self->address2,
-    'city'     => $options{city}     || $self->city,
-    'state'    => $options{state}    || $self->state,
-    'zip'      => $options{zip}      || $self->zip,
-    'country'  => $options{country}  || $self->country,
-    'payby'    => $options{payby}    || $self->payby,
-    'payinfo'  => $options{payinfo}  || $self->payinfo,
-    'exp'      => $options{paydate}  || $self->paydate,
-    'payname'  => $options{payname}  || $self->payname,
-    'amount'   => $amount,                         # consolidating
-  } );
-  
-  $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum)
-    if $old_cust_pay_batch;
-
-  my $error;
-  if ($old_cust_pay_batch) {
-    $error = $cust_pay_batch->replace($old_cust_pay_batch)
-  } else {
-    $error = $cust_pay_batch->insert;
-  }
-
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    die $error;
-  }
-
-  my $unapplied =   $self->total_unapplied_credits
-                  + $self->total_unapplied_payments
-                  + $self->in_transit_payments;
-  foreach my $cust_bill ($self->open_cust_bill) {
-    #$dbh->commit or die $dbh->errstr if $oldAutoCommit;
-    my $cust_bill_pay_batch = new FS::cust_bill_pay_batch {
-      'invnum' => $cust_bill->invnum,
-      'paybatchnum' => $cust_pay_batch->paybatchnum,
-      'amount' => $cust_bill->owed,
-      '_date' => time,
-    };
-    if ($unapplied >= $cust_bill_pay_batch->amount){
-      $unapplied -= $cust_bill_pay_batch->amount;
-      next;
-    }else{
-      $cust_bill_pay_batch->amount(sprintf ( "%.2f", 
-                                   $cust_bill_pay_batch->amount - $unapplied ));      $unapplied = 0;
-    }
-    $error = $cust_bill_pay_batch->insert;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      die $error;
-    }
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
-}
-
-=item apply_payments_and_credits [ OPTION => VALUE ... ]
-
-Applies unapplied payments and credits.
-
-In most cases, this new method should be used in place of sequential
-apply_payments and apply_credits methods.
-
-A hash of optional arguments may be passed.  Currently "manual" is supported.
-If true, a payment receipt is sent instead of a statement when
-'payment_receipt_email' configuration option is set.
-
-If there is an error, returns the error, otherwise returns false.
-
-=cut
-
-sub apply_payments_and_credits {
-  my( $self, %options ) = @_;
-
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  $self->select_for_update; #mutex
-
-  foreach my $cust_bill ( $self->open_cust_bill ) {
-    my $error = $cust_bill->apply_payments_and_credits(%options);
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "Error applying: $error";
-    }
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  ''; #no error
-
-}
-
-=item apply_credits OPTION => VALUE ...
-
-Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
-to outstanding invoice balances in chronological order (or reverse
-chronological order if the I<order> option is set to B<newest>) and returns the
-value of any remaining unapplied credits available for refund (see
-L<FS::cust_refund>).
-
-Dies if there is an error.
-
-=cut
-
-sub apply_credits {
-  my $self = shift;
-  my %opt = @_;
-
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  $self->select_for_update; #mutex
-
-  unless ( $self->total_unapplied_credits ) {
-    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-    return 0;
-  }
-
-  my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
-      qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
-
-  my @invoices = $self->open_cust_bill;
-  @invoices = sort { $b->_date <=> $a->_date } @invoices
-    if defined($opt{'order'}) && $opt{'order'} eq 'newest';
-
-  if ( $conf->exists('pkg-balances') ) {
-    # limit @credits to those w/ a pkgnum grepped from $self
-    my %pkgnums = ();
-    foreach my $i (@invoices) {
-      foreach my $li ( $i->cust_bill_pkg ) {
-        $pkgnums{$li->pkgnum} = 1;
-      }
-    }
-    @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
-  }
-
-  my $credit;
-
-  foreach my $cust_bill ( @invoices ) {
-
-    if ( !defined($credit) || $credit->credited == 0) {
-      $credit = pop @credits or last;
-    }
-
-    my $owed;
-    if ( $conf->exists('pkg-balances') && $credit->pkgnum ) {
-      $owed = $cust_bill->owed_pkgnum($credit->pkgnum);
-    } else {
-      $owed = $cust_bill->owed;
-    }
-    unless ( $owed > 0 ) {
-      push @credits, $credit;
-      next;
-    }
-
-    my $amount = min( $credit->credited, $owed );
-    
-    my $cust_credit_bill = new FS::cust_credit_bill ( {
-      'crednum' => $credit->crednum,
-      'invnum'  => $cust_bill->invnum,
-      'amount'  => $amount,
-    } );
-    $cust_credit_bill->pkgnum( $credit->pkgnum )
-      if $conf->exists('pkg-balances') && $credit->pkgnum;
-    my $error = $cust_credit_bill->insert;
-    if ( $error ) {
-      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
-      die $error;
-    }
-    
-    redo if ($cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
-
-  }
-
-  my $total_unapplied_credits = $self->total_unapplied_credits;
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
-  return $total_unapplied_credits;
-}
-
-=item apply_payments  [ OPTION => VALUE ... ]
-
-Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
-to outstanding invoice balances in chronological order.
-
- #and returns the value of any remaining unapplied payments.
-
-A hash of optional arguments may be passed.  Currently "manual" is supported.
-If true, a payment receipt is sent instead of a statement when
-'payment_receipt_email' configuration option is set.
-
-Dies if there is an error.
-
-=cut
-
-sub apply_payments {
-  my( $self, %options ) = @_;
-
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-
-  $self->select_for_update; #mutex
-
-  #return 0 unless
-
-  my @payments = sort { $b->_date <=> $a->_date }
-                 grep { $_->unapplied > 0 }
-                 $self->cust_pay;
-
-  my @invoices = sort { $a->_date <=> $b->_date}
-                 grep { $_->owed > 0 }
-                 $self->cust_bill;
-
-  if ( $conf->exists('pkg-balances') ) {
-    # limit @payments to those w/ a pkgnum grepped from $self
-    my %pkgnums = ();
-    foreach my $i (@invoices) {
-      foreach my $li ( $i->cust_bill_pkg ) {
-        $pkgnums{$li->pkgnum} = 1;
-      }
-    }
-    @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
-  }
-
-  my $payment;
-
-  foreach my $cust_bill ( @invoices ) {
-
-    if ( !defined($payment) || $payment->unapplied == 0 ) {
-      $payment = pop @payments or last;
-    }
-
-    my $owed;
-    if ( $conf->exists('pkg-balances') && $payment->pkgnum ) {
-      $owed = $cust_bill->owed_pkgnum($payment->pkgnum);
-    } else {
-      $owed = $cust_bill->owed;
-    }
-    unless ( $owed > 0 ) {
-      push @payments, $payment;
-      next;
-    }
-
-    my $amount = min( $payment->unapplied, $owed );
-
-    my $cust_bill_pay = new FS::cust_bill_pay ( {
-      'paynum' => $payment->paynum,
-      'invnum' => $cust_bill->invnum,
-      'amount' => $amount,
-    } );
-    $cust_bill_pay->pkgnum( $payment->pkgnum )
-      if $conf->exists('pkg-balances') && $payment->pkgnum;
-    my $error = $cust_bill_pay->insert(%options);
-    if ( $error ) {
-      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
-      die $error;
-    }
-
-    redo if ( $cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
-
-  }
-
-  my $total_unapplied_payments = $self->total_unapplied_payments;
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
-  return $total_unapplied_payments;
-}
-
-=item total_owed
-
-Returns the total owed for this customer on all invoices
-(see L<FS::cust_bill/owed>).
-
-=cut
-
-sub total_owed {
-  my $self = shift;
-  $self->total_owed_date(2145859200); #12/31/2037
-}
-
-=item total_owed_date TIME
-
-Returns the total owed for this customer on all invoices with date earlier than
-TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
-see L<Time::Local> and L<Date::Parse> for conversion functions.
-
-=cut
-
-sub total_owed_date {
-  my $self = shift;
-  my $time = shift;
-
-#  my $custnum = $self->custnum;
-#
-#  my $owed_sql = FS::cust_bill->owed_sql;
-#
-#  my $sql = "
-#    SELECT SUM($owed_sql) FROM cust_bill
-#      WHERE custnum = $custnum
-#        AND _date <= $time
-#  ";
-#
-#  my $sth = dbh->prepare($sql) or die dbh->errstr;
-#  $sth->execute() or die $sth->errstr;
-#
-#  return sprintf( '%.2f', $sth->fetchrow_arrayref->[0] );
-
-  my $total_bill = 0;
-  foreach my $cust_bill (
-    grep { $_->_date <= $time }
-      qsearch('cust_bill', { 'custnum' => $self->custnum, } )
-  ) {
-    $total_bill += $cust_bill->owed;
-  }
-  sprintf( "%.2f", $total_bill );
-
-}
-
-=item total_owed_pkgnum PKGNUM
-
-Returns the total owed on all invoices for this customer's specific package
-when using experimental package balances (see L<FS::cust_bill/owed_pkgnum>).
-
-=cut
-
-sub total_owed_pkgnum {
-  my( $self, $pkgnum ) = @_;
-  $self->total_owed_date_pkgnum(2145859200, $pkgnum); #12/31/2037
-}
-
-=item total_owed_date_pkgnum TIME PKGNUM
-
-Returns the total owed for this customer's specific package when using
-experimental package balances on all invoices with date earlier than
-TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
-see L<Time::Local> and L<Date::Parse> for conversion functions.
-
-=cut
-
-sub total_owed_date_pkgnum {
-  my( $self, $time, $pkgnum ) = @_;
-
-  my $total_bill = 0;
-  foreach my $cust_bill (
-    grep { $_->_date <= $time }
-      qsearch('cust_bill', { 'custnum' => $self->custnum, } )
-  ) {
-    $total_bill += $cust_bill->owed_pkgnum($pkgnum);
-  }
-  sprintf( "%.2f", $total_bill );
-
-}
-
-=item total_paid
-
-Returns the total amount of all payments.
-
-=cut
-
-sub total_paid {
-  my $self = shift;
-  my $total = 0;
-  $total += $_->paid foreach $self->cust_pay;
-  sprintf( "%.2f", $total );
-}
-
-=item total_unapplied_credits
-
-Returns the total outstanding credit (see L<FS::cust_credit>) for this
-customer.  See L<FS::cust_credit/credited>.
-
-=item total_credited
-
-Old name for total_unapplied_credits.  Don't use.
-
-=cut
-
-sub total_credited {
-  #carp "total_credited deprecated, use total_unapplied_credits";
-  shift->total_unapplied_credits(@_);
-}
-
-sub total_unapplied_credits {
-  my $self = shift;
-  my $total_credit = 0;
-  $total_credit += $_->credited foreach $self->cust_credit;
-  sprintf( "%.2f", $total_credit );
-}
-
-=item total_unapplied_credits_pkgnum PKGNUM
-
-Returns the total outstanding credit (see L<FS::cust_credit>) for this
-customer.  See L<FS::cust_credit/credited>.
-
-=cut
-
-sub total_unapplied_credits_pkgnum {
-  my( $self, $pkgnum ) = @_;
-  my $total_credit = 0;
-  $total_credit += $_->credited foreach $self->cust_credit_pkgnum($pkgnum);
-  sprintf( "%.2f", $total_credit );
-}
-
-
-=item total_unapplied_payments
-
-Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
-See L<FS::cust_pay/unapplied>.
-
-=cut
-
-sub total_unapplied_payments {
-  my $self = shift;
-  my $total_unapplied = 0;
-  $total_unapplied += $_->unapplied foreach $self->cust_pay;
-  sprintf( "%.2f", $total_unapplied );
-}
-
-=item total_unapplied_payments_pkgnum PKGNUM
-
-Returns the total unapplied payments (see L<FS::cust_pay>) for this customer's
-specific package when using experimental package balances.  See
-L<FS::cust_pay/unapplied>.
-
-=cut
-
-sub total_unapplied_payments_pkgnum {
-  my( $self, $pkgnum ) = @_;
-  my $total_unapplied = 0;
-  $total_unapplied += $_->unapplied foreach $self->cust_pay_pkgnum($pkgnum);
-  sprintf( "%.2f", $total_unapplied );
-}
-
-
-=item total_unapplied_refunds
-
-Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
-customer.  See L<FS::cust_refund/unapplied>.
-
-=cut
-
-sub total_unapplied_refunds {
-  my $self = shift;
-  my $total_unapplied = 0;
-  $total_unapplied += $_->unapplied foreach $self->cust_refund;
-  sprintf( "%.2f", $total_unapplied );
-}
-
-=item balance
-
-Returns the balance for this customer (total_owed plus total_unrefunded, minus
-total_unapplied_credits minus total_unapplied_payments).
-
-=cut
-
-sub balance {
-  my $self = shift;
-  sprintf( "%.2f",
-      $self->total_owed
-    + $self->total_unapplied_refunds
-    - $self->total_unapplied_credits
-    - $self->total_unapplied_payments
-  );
-}
-
-=item balance_date TIME
-
-Returns the balance for this customer, only considering invoices with date
-earlier than TIME (total_owed_date minus total_credited minus
-total_unapplied_payments).  TIME is specified as a UNIX timestamp; see
-L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion
-functions.
-
-=cut
-
-sub balance_date {
-  my $self = shift;
-  my $time = shift;
-  sprintf( "%.2f",
-        $self->total_owed_date($time)
-      + $self->total_unapplied_refunds
-      - $self->total_unapplied_credits
-      - $self->total_unapplied_payments
-  );
-}
-
-=item balance_date_range START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
-
-Returns the balance for this customer, only considering invoices with date
-earlier than START_TIME, and optionally not later than END_TIME
-(total_owed_date minus total_unapplied_credits minus total_unapplied_payments).
-
-Times are specified as SQL fragments or numeric
-UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
-L<Date::Parse> for conversion functions.  The empty string can be passed
-to disable that time constraint completely.
-
-Available options are:
-
-=over 4
-
-=item unapplied_date
-
-set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
-
-=back
-
-=cut
-
-sub balance_date_range {
-  my $self = shift;
-  my $sql = 'SELECT SUM('. $self->balance_date_sql(@_).
-            ') FROM cust_main WHERE custnum='. $self->custnum;
-  sprintf( "%.2f", $self->scalar_sql($sql) );
-}
-
-=item balance_pkgnum PKGNUM
-
-Returns the balance for this customer's specific package when using
-experimental package balances (total_owed plus total_unrefunded, minus
-total_unapplied_credits minus total_unapplied_payments)
-
-=cut
-
-sub balance_pkgnum {
-  my( $self, $pkgnum ) = @_;
-
-  sprintf( "%.2f",
-      $self->total_owed_pkgnum($pkgnum)
-# n/a - refunds aren't part of pkg-balances since they don't apply to invoices
-#    + $self->total_unapplied_refunds_pkgnum($pkgnum)
-    - $self->total_unapplied_credits_pkgnum($pkgnum)
-    - $self->total_unapplied_payments_pkgnum($pkgnum)
-  );
-}
-
-=item in_transit_payments
-
-Returns the total of requests for payments for this customer pending in 
-batches in transit to the bank.  See L<FS::pay_batch> and L<FS::cust_pay_batch>
-
-=cut
-
-sub in_transit_payments {
-  my $self = shift;
-  my $in_transit_payments = 0;
-  foreach my $pay_batch ( qsearch('pay_batch', {
-    'status' => 'I',
-  } ) ) {
-    foreach my $cust_pay_batch ( qsearch('cust_pay_batch', {
-      'batchnum' => $pay_batch->batchnum,
-      'custnum' => $self->custnum,
-    } ) ) {
-      $in_transit_payments += $cust_pay_batch->amount;
-    }
-  }
-  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
-paydate (credit card expiration date for CARD customers)
-
-=cut
-
-sub paydate_monthyear {
-  my $self = shift;
-  if ( $self->paydate  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
-    ( $2, $1 );
-  } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
-    ( $1, $3 );
-  } else {
-    ('', '');
-  }
-}
-
-=item tax_exemption TAXNAME
-
-=cut
-
-sub tax_exemption {
-  my( $self, $taxname ) = @_;
-
-  qsearchs( 'cust_main_exemption', { 'custnum' => $self->custnum,
-                                     'taxname' => $taxname,
-                                   },
-          );
-}
-
-=item cust_main_exemption
-
-=cut
-
-sub cust_main_exemption {
-  my $self = shift;
-  qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } );
-}
-
-=item invoicing_list [ ARRAYREF ]
-
-If an arguement is given, sets these email addresses as invoice recipients
-(see L<FS::cust_main_invoice>).  Errors are not fatal and are not reported
-(except as warnings), so use check_invoicing_list first.
-
-Returns a list of email addresses (with svcnum entries expanded).
-
-Note: You can clear the invoicing list by passing an empty ARRAYREF.  You can
-check it without disturbing anything by passing nothing.
-
-This interface may change in the future.
-
-=cut
-
-sub invoicing_list {
-  my( $self, $arrayref ) = @_;
-
-  if ( $arrayref ) {
-    my @cust_main_invoice;
-    if ( $self->custnum ) {
-      @cust_main_invoice = 
-        qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
-    } else {
-      @cust_main_invoice = ();
-    }
-    foreach my $cust_main_invoice ( @cust_main_invoice ) {
-      #warn $cust_main_invoice->destnum;
-      unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
-        #warn $cust_main_invoice->destnum;
-        my $error = $cust_main_invoice->delete;
-        warn $error if $error;
-      }
-    }
-    if ( $self->custnum ) {
-      @cust_main_invoice = 
-        qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
-    } else {
-      @cust_main_invoice = ();
-    }
-    my %seen = map { $_->address => 1 } @cust_main_invoice;
-    foreach my $address ( @{$arrayref} ) {
-      next if exists $seen{$address} && $seen{$address};
-      $seen{$address} = 1;
-      my $cust_main_invoice = new FS::cust_main_invoice ( {
-        'custnum' => $self->custnum,
-        'dest'    => $address,
-      } );
-      my $error = $cust_main_invoice->insert;
-      warn $error if $error;
-    }
-  }
-  
-  if ( $self->custnum ) {
-    map { $_->address }
-      qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
-  } else {
-    ();
-  }
-
-}
-
-=item check_invoicing_list ARRAYREF
-
-Checks these arguements as valid input for the invoicing_list method.  If there
-is an error, returns the error, otherwise returns false.
-
-=cut
-
-sub check_invoicing_list {
-  my( $self, $arrayref ) = @_;
-
-  foreach my $address ( @$arrayref ) {
-
-    if ($address eq 'FAX' and $self->getfield('fax') eq '') {
-      return 'Can\'t add FAX invoice destination with a blank FAX number.';
-    }
-
-    my $cust_main_invoice = new FS::cust_main_invoice ( {
-      'custnum' => $self->custnum,
-      'dest'    => $address,
-    } );
-    my $error = $self->custnum
-                ? $cust_main_invoice->check
-                : $cust_main_invoice->checkdest
-    ;
-    return $error if $error;
-
-  }
-
-  return "Email address required"
-    if $conf->exists('cust_main-require_invoicing_list_email')
-    && ! grep { $_ !~ /^([A-Z]+)$/ } @$arrayref;
-
-  '';
-}
-
-=item set_default_invoicing_list
-
-Sets the invoicing list to all accounts associated with this customer,
-overwriting any previous invoicing list.
-
-=cut
-
-sub set_default_invoicing_list {
-  my $self = shift;
-  $self->invoicing_list($self->all_emails);
-}
-
-=item all_emails
-
-Returns the email addresses of all accounts provisioned for this customer.
-
-=cut
-
-sub all_emails {
-  my $self = shift;
-  my %list;
-  foreach my $cust_pkg ( $self->all_pkgs ) {
-    my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
-    my @svc_acct =
-      map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
-        grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
-          @cust_svc;
-    $list{$_}=1 foreach map { $_->email } @svc_acct;
-  }
-  keys %list;
-}
-
-=item invoicing_list_addpost
-
-Adds postal invoicing to this customer.  If this customer is already configured
-to receive postal invoices, does nothing.
-
-=cut
-
-sub invoicing_list_addpost {
-  my $self = shift;
-  return if grep { $_ eq 'POST' } $self->invoicing_list;
-  my @invoicing_list = $self->invoicing_list;
-  push @invoicing_list, 'POST';
-  $self->invoicing_list(\@invoicing_list);
-}
-
-=item invoicing_list_emailonly
+=item invoicing_list_emailonly
 
 Returns the list of email invoice recipients (invoicing_list without non-email
 destinations such as POST and FAX).
 
 Returns the list of email invoice recipients (invoicing_list without non-email
 destinations such as POST and FAX).
@@ -6375,7 +2843,16 @@ sub invoicing_list_emailonly {
   my $self = shift;
   warn "$me invoicing_list_emailonly called"
     if $DEBUG;
   my $self = shift;
   warn "$me invoicing_list_emailonly called"
     if $DEBUG;
-  grep { $_ !~ /^([A-Z]+)$/ } $self->invoicing_list;
+  return () if !$self->custnum; # not yet inserted
+  return map { $_->emailaddress }
+    qsearch({
+        table     => 'cust_contact',
+        select    => 'emailaddress',
+        addl_from => ' JOIN contact USING (contactnum) '.
+                     ' JOIN contact_email USING (contactnum)',
+        hashref   => { 'custnum' => $self->custnum, },
+        extra_sql => q( AND cust_contact.invoice_dest = 'Y'),
+    });
 }
 
 =item invoicing_list_emailonly_scalar
 }
 
 =item invoicing_list_emailonly_scalar
@@ -6496,6 +2973,8 @@ reason, and a 'reason_type' option must be passed to indicate the
 FS::reason_type for the new reason.
 
 An I<addlinfo> option may be passed to set the credit's I<addlinfo> field.
 FS::reason_type for the new reason.
 
 An I<addlinfo> option may be passed to set the credit's I<addlinfo> field.
+Likewise for I<eventnum>, I<commission_agentnum>, I<commission_salesnum> and
+I<commission_pkgnum>.
 
 Any other options are passed to FS::cust_credit::insert.
 
 
 Any other options are passed to FS::cust_credit::insert.
 
@@ -6521,10 +3000,10 @@ sub credit {
     $cust_credit->set('reason', $reason)
   }
 
     $cust_credit->set('reason', $reason)
   }
 
-  for (qw( addlinfo eventnum )) {
-    $cust_credit->$_( delete $options{$_} )
-      if exists($options{$_});
-  }
+  $cust_credit->$_( delete $options{$_} )
+    foreach grep exists($options{$_}),
+              qw( addlinfo eventnum ),
+              map "commission_$_", qw( agentnum salesnum pkgnum );
 
   $cust_credit->insert(%options);
 
 
   $cust_credit->insert(%options);
 
@@ -6549,6 +3028,8 @@ New-style, with a hashref of options:
 
                                     'setuptax'   => '', # or 'Y' for tax exempt
 
 
                                     'setuptax'   => '', # or 'Y' for tax exempt
 
+                                    'locationnum'=> 1234, # optional
+
                                     #internal taxation
                                     'taxclass'   => 'Tax class',
 
                                     #internal taxation
                                     'taxclass'   => 'Tax class',
 
@@ -6571,17 +3052,21 @@ Old-style:
 
 =cut
 
 
 =cut
 
+#super false laziness w/quotation::charge
 sub charge {
   my $self = shift;
 sub charge {
   my $self = shift;
-  my ( $amount, $quantity, $start_date, $classnum );
+  my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
   my ( $pkg, $comment, $additional );
   my ( $setuptax, $taxclass );   #internal taxes
   my ( $taxproduct, $override ); #vendor (CCH) taxes
   my $no_auto = '';
   my ( $pkg, $comment, $additional );
   my ( $setuptax, $taxclass );   #internal taxes
   my ( $taxproduct, $override ); #vendor (CCH) taxes
   my $no_auto = '';
+  my $separate_bill = '';
   my $cust_pkg_ref = '';
   my ( $bill_now, $invoice_terms ) = ( 0, '' );
   my $cust_pkg_ref = '';
   my ( $bill_now, $invoice_terms ) = ( 0, '' );
+  my $locationnum;
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
+    $setup_cost = $_[0]->{setup_cost};
     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
     $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
     $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
     $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
     $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
@@ -6597,8 +3082,11 @@ sub charge {
     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
-  } else {
+    $locationnum = $_[0]->{locationnum} || $self->ship_locationnum;
+    $separate_bill = $_[0]->{separate_bill} || '';
+  } else { # yuck
     $amount     = shift;
     $amount     = shift;
+    $setup_cost = '';
     $quantity   = 1;
     $start_date = '';
     $pkg        = @_ ? shift : 'One-time charge';
     $quantity   = 1;
     $start_date = '';
     $pkg        = @_ ? shift : 'One-time charge';
@@ -6629,6 +3117,7 @@ sub charge {
     'setuptax'      => $setuptax,
     'taxclass'      => $taxclass,
     'taxproductnum' => $taxproduct,
     'setuptax'      => $setuptax,
     'taxclass'      => $taxclass,
     'taxproductnum' => $taxproduct,
+    'setup_cost'    => $setup_cost,
   } );
 
   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
   } );
 
   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
@@ -6663,6 +3152,8 @@ sub charge {
     'quantity'   => $quantity,
     'start_date' => $start_date,
     'no_auto'    => $no_auto,
     'quantity'   => $quantity,
     'start_date' => $start_date,
     'no_auto'    => $no_auto,
+    'separate_bill' => $separate_bill,
+    'locationnum'=> $locationnum,
   } );
 
   $error = $cust_pkg->insert;
   } );
 
   $error = $cust_pkg->insert;
@@ -6673,1920 +3164,1601 @@ sub charge {
     ${$cust_pkg_ref} = $cust_pkg;
   }
 
     ${$cust_pkg_ref} = $cust_pkg;
   }
 
-  if ( $bill_now ) {
-    my $error = $self->bill( 'invoice_terms' => $invoice_terms,
-                             'pkg_list'      => [ $cust_pkg ],
-                           );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }   
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  return '';
-
-}
-
-#=item charge_postal_fee
-#
-#Applies a one time charge this customer.  If there is an error,
-#returns the error, returns the cust_pkg charge object or false
-#if there was no charge.
-#
-#=cut
-#
-# This should be a customer event.  For that to work requires that bill
-# also be a customer event.
-
-sub charge_postal_fee {
-  my $self = shift;
-
-  my $pkgpart = $conf->config('postal_invoice-fee_pkgpart');
-  return '' unless ($pkgpart && grep { $_ eq 'POST' } $self->invoicing_list);
-
-  my $cust_pkg = new FS::cust_pkg ( {
-    'custnum'  => $self->custnum,
-    'pkgpart'  => $pkgpart,
-    'quantity' => 1,
-  } );
-
-  my $error = $cust_pkg->insert;
-  $error ? $error : $cust_pkg;
-}
-
-=item cust_bill
-
-Returns all the invoices (see L<FS::cust_bill>) for this customer.
-
-=cut
-
-sub cust_bill {
-  my $self = shift;
-  map { $_ } #return $self->num_cust_bill unless wantarray;
-  sort { $a->_date <=> $b->_date }
-    qsearch('cust_bill', { 'custnum' => $self->custnum, } )
-}
-
-=item open_cust_bill
-
-Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
-customer.
-
-=cut
-
-sub open_cust_bill {
-  my $self = shift;
-
-  qsearch({
-    'table'     => 'cust_bill',
-    'hashref'   => { 'custnum' => $self->custnum, },
-    'extra_sql' => ' AND '. FS::cust_bill->owed_sql. ' > 0',
-    'order_by'  => 'ORDER BY _date ASC',
-  });
-
-}
-
-=item cust_statements
-
-Returns all the statements (see L<FS::cust_statement>) for this customer.
-
-=cut
-
-sub cust_statement {
-  my $self = shift;
-  map { $_ } #return $self->num_cust_statement unless wantarray;
-  sort { $a->_date <=> $b->_date }
-    qsearch('cust_statement', { 'custnum' => $self->custnum, } )
-}
-
-=item cust_credit
-
-Returns all the credits (see L<FS::cust_credit>) for this customer.
-
-=cut
-
-sub cust_credit {
-  my $self = shift;
-  map { $_ } #return $self->num_cust_credit unless wantarray;
-  sort { $a->_date <=> $b->_date }
-    qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
-}
-
-=item cust_credit_pkgnum
-
-Returns all the credits (see L<FS::cust_credit>) for this customer's specific
-package when using experimental package balances.
-
-=cut
-
-sub cust_credit_pkgnum {
-  my( $self, $pkgnum ) = @_;
-  map { $_ } #return $self->num_cust_credit_pkgnum($pkgnum) unless wantarray;
-  sort { $a->_date <=> $b->_date }
-    qsearch( 'cust_credit', { 'custnum' => $self->custnum,
-                              'pkgnum'  => $pkgnum,
-                            }
-    );
-}
-
-=item cust_pay
-
-Returns all the payments (see L<FS::cust_pay>) for this customer.
-
-=cut
-
-sub cust_pay {
-  my $self = shift;
-  return $self->num_cust_pay unless wantarray;
-  sort { $a->_date <=> $b->_date }
-    qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
-}
-
-=item num_cust_pay
-
-Returns the number of payments (see L<FS::cust_pay>) for this customer.  Also
-called automatically when the cust_pay method is used in a scalar context.
-
-=cut
-
-sub num_cust_pay {
-  my $self = shift;
-  my $sql = "SELECT COUNT(*) FROM cust_pay WHERE custnum = ?";
-  my $sth = dbh->prepare($sql) or die dbh->errstr;
-  $sth->execute($self->custnum) or die $sth->errstr;
-  $sth->fetchrow_arrayref->[0];
-}
-
-=item cust_pay_pkgnum
-
-Returns all the payments (see L<FS::cust_pay>) for this customer's specific
-package when using experimental package balances.
-
-=cut
-
-sub cust_pay_pkgnum {
-  my( $self, $pkgnum ) = @_;
-  map { $_ } #return $self->num_cust_pay_pkgnum($pkgnum) unless wantarray;
-  sort { $a->_date <=> $b->_date }
-    qsearch( 'cust_pay', { 'custnum' => $self->custnum,
-                           'pkgnum'  => $pkgnum,
-                         }
-    );
-}
-
-=item cust_pay_void
-
-Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
-
-=cut
-
-sub cust_pay_void {
-  my $self = shift;
-  map { $_ } #return $self->num_cust_pay_void unless wantarray;
-  sort { $a->_date <=> $b->_date }
-    qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
-}
-
-=item cust_pay_batch
-
-Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
-
-=cut
-
-sub cust_pay_batch {
-  my $self = shift;
-  map { $_ } #return $self->num_cust_pay_batch unless wantarray;
-  sort { $a->paybatchnum <=> $b->paybatchnum }
-    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.
-
-=cut
-
-sub cust_refund {
-  my $self = shift;
-  map { $_ } #return $self->num_cust_refund unless wantarray;
-  sort { $a->_date <=> $b->_date }
-    qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
-}
-
-=item display_custnum
-
-Returns the displayed customer number for this customer: agent_custid if
-cust_main-default_agent_custid is set and it has a value, custnum otherwise.
-
-=cut
-
-sub display_custnum {
-  my $self = shift;
-  if ( $conf->exists('cust_main-default_agent_custid') && $self->agent_custid ){
-    return $self->agent_custid;
-  } else {
-    return $self->custnum;
-  }
-}
-
-=item name
-
-Returns a name string for this customer, either "Company (Last, First)" or
-"Last, First".
-
-=cut
-
-sub name {
-  my $self = shift;
-  my $name = $self->contact;
-  $name = $self->company. " ($name)" if $self->company;
-  $name;
-}
-
-=item ship_name
-
-Returns a name string for this (service/shipping) contact, either
-"Company (Last, First)" or "Last, First".
-
-=cut
-
-sub ship_name {
-  my $self = shift;
-  if ( $self->get('ship_last') ) { 
-    my $name = $self->ship_contact;
-    $name = $self->ship_company. " ($name)" if $self->ship_company;
-    $name;
-  } else {
-    $self->name;
+  if ( $bill_now ) {
+    my $error = $self->bill( 'invoice_terms' => $invoice_terms,
+                             'pkg_list'      => [ $cust_pkg ],
+                           );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }   
   }
   }
-}
 
 
-=item name_short
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  return '';
 
 
-Returns a name string for this customer, either "Company" or "First Last".
+}
 
 
-=cut
+#=item charge_postal_fee
+#
+#Applies a one time charge this customer.  If there is an error,
+#returns the error, returns the cust_pkg charge object or false
+#if there was no charge.
+#
+#=cut
+#
+# This should be a customer event.  For that to work requires that bill
+# also be a customer event.
 
 
-sub name_short {
+sub charge_postal_fee {
   my $self = shift;
   my $self = shift;
-  $self->company !~ /^\s*$/ ? $self->company : $self->contact_firstlast;
-}
 
 
-=item ship_name_short
-
-Returns a name string for this (service/shipping) contact, either "Company"
-or "First Last".
+  my $pkgpart = $conf->config('postal_invoice-fee_pkgpart', $self->agentnum);
+  return '' unless ($pkgpart && grep { $_ eq 'POST' } $self->invoicing_list);
 
 
-=cut
+  my $cust_pkg = new FS::cust_pkg ( {
+    'custnum'  => $self->custnum,
+    'pkgpart'  => $pkgpart,
+    'quantity' => 1,
+  } );
 
 
-sub ship_name_short {
-  my $self = shift;
-  if ( $self->get('ship_last') ) { 
-    $self->ship_company !~ /^\s*$/
-      ? $self->ship_company
-      : $self->ship_contact_firstlast;
-  } else {
-    $self->name_company_or_firstlast;
-  }
+  my $error = $cust_pkg->insert;
+  $error ? $error : $cust_pkg;
 }
 
 }
 
-=item contact
+=item cust_bill [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 
-Returns this customer's full (billing) contact name only, "Last, First"
+Returns all the invoices (see L<FS::cust_bill>) for this customer.
+
+Optionally, a list or hashref of additional arguments to the qsearch call can
+be passed.
 
 =cut
 
 
 =cut
 
-sub contact {
+sub cust_bill {
   my $self = shift;
   my $self = shift;
-  $self->get('last'). ', '. $self->first;
-}
-
-=item ship_contact
+  my $opt = ref($_[0]) ? shift : { @_ };
 
 
-Returns this customer's full (shipping) contact name only, "Last, First"
+  #return $self->num_cust_bill unless wantarray || keys %$opt;
 
 
-=cut
+  $opt->{'table'} = 'cust_bill';
+  $opt->{'hashref'} ||= {}; #i guess it would autovivify anyway...
+  $opt->{'hashref'}{'custnum'} = $self->custnum;
+  $opt->{'order_by'} ||= 'ORDER BY _date ASC';
 
 
-sub ship_contact {
-  my $self = shift;
-  $self->get('ship_last')
-    ? $self->get('ship_last'). ', '. $self->ship_first
-    : $self->contact;
+  map { $_ } #behavior of sort undefined in scalar context
+    sort { $a->_date <=> $b->_date }
+      qsearch($opt);
 }
 
 }
 
-=item contact_firstlast
+=item open_cust_bill
 
 
-Returns this customers full (billing) contact name only, "First Last".
+Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
+customer.
 
 =cut
 
 
 =cut
 
-sub contact_firstlast {
+sub open_cust_bill {
   my $self = shift;
   my $self = shift;
-  $self->first. ' '. $self->get('last');
-}
-
-=item ship_contact_firstlast
-
-Returns this customer's full (shipping) contact name only, "First Last".
 
 
-=cut
+  $self->cust_bill(
+    'extra_sql' => ' AND '. FS::cust_bill->owed_sql. ' > 0',
+    #@_
+  );
 
 
-sub ship_contact_firstlast {
-  my $self = shift;
-  $self->get('ship_last')
-    ? $self->first. ' '. $self->get('ship_last')
-    : $self->contact_firstlast;
 }
 
 }
 
-=item country_full
+=item legacy_cust_bill [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 
-Returns this customer's full country name
+Returns all the legacy invoices (see L<FS::legacy_cust_bill>) for this customer.
 
 =cut
 
 
 =cut
 
-sub country_full {
+sub legacy_cust_bill {
   my $self = shift;
   my $self = shift;
-  code2country($self->country);
-}
-
-=item geocode DATA_VENDOR
-
-Returns a value for the customer location as encoded by DATA_VENDOR.
-Currently this only makes sense for "CCH" as DATA_VENDOR.
 
 
-=cut
+  #return $self->num_legacy_cust_bill unless wantarray;
 
 
-sub geocode {
-  my ($self, $data_vendor) = (shift, shift);  #always cch for now
+  map { $_ } #behavior of sort undefined in scalar context
+    sort { $a->_date <=> $b->_date }
+      qsearch({ 'table'    => 'legacy_cust_bill',
+                'hashref'  => { 'custnum' => $self->custnum, },
+                'order_by' => 'ORDER BY _date ASC',
+             });
+}
 
 
-  my $geocode = $self->get('geocode');  #XXX only one data_vendor for geocode
-  return $geocode if $geocode;
+=item cust_statement [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 
-  my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) )
-               ? 'ship_'
-               : '';
+Returns all the statements (see L<FS::cust_statement>) for this customer.
 
 
-  my($zip,$plus4) = split /-/, $self->get("${prefix}zip")
-    if $self->country eq 'US';
+Optionally, a list or hashref of additional arguments to the qsearch call can
+be passed.
 
 
-  $zip ||= '';
-  $plus4 ||= '';
-  #CCH specific location stuff
-  my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
+=cut
 
 
-  my @cust_tax_location =
-    qsearch( {
-               'table'     => 'cust_tax_location', 
-               'hashref'   => { 'zip' => $zip, 'data_vendor' => $data_vendor },
-               'extra_sql' => $extra_sql,
-               'order_by'  => 'ORDER BY plus4hi',#overlapping with distinct ends
-             }
-           );
-  $geocode = $cust_tax_location[0]->geocode
-    if scalar(@cust_tax_location);
+=item cust_bill_void
 
 
-  $geocode;
-}
+Returns all the voided invoices (see L<FS::cust_bill_void>) for this customer.
 
 
-=item cust_status
+=cut
 
 
-=item status
+sub cust_bill_void {
+  my $self = shift;
 
 
-Returns a status string for this customer, currently:
+  map { $_ } #return $self->num_cust_bill_void unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_bill_void', { 'custnum' => $self->custnum } )
+}
 
 
-=over 4
+sub cust_statement {
+  my $self = shift;
+  my $opt = ref($_[0]) ? shift : { @_ };
 
 
-=item prospect - No packages have ever been ordered
+  #return $self->num_cust_statement unless wantarray || keys %$opt;
 
 
-=item active - One or more recurring packages is active
+  $opt->{'table'} = 'cust_statement';
+  $opt->{'hashref'} ||= {}; #i guess it would autovivify anyway...
+  $opt->{'hashref'}{'custnum'} = $self->custnum;
+  $opt->{'order_by'} ||= 'ORDER BY _date ASC';
 
 
-=item inactive - No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled)
+  map { $_ } #behavior of sort undefined in scalar context
+    sort { $a->_date <=> $b->_date }
+      qsearch($opt);
+}
 
 
-=item suspended - All non-cancelled recurring packages are suspended
+=item svc_x SVCDB [ OPTION => VALUE | EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 
-=item cancelled - All recurring packages are cancelled
+Returns all services of type SVCDB (such as 'svc_acct') for this customer.  
 
 
-=back
+Optionally, a list or hashref of additional arguments to the qsearch call can 
+be passed following the SVCDB.
 
 =cut
 
 
 =cut
 
-sub status { shift->cust_status(@_); }
-
-sub cust_status {
+sub svc_x {
   my $self = shift;
   my $self = shift;
-  for my $status (qw( prospect active inactive suspended cancelled )) {
-    my $method = $status.'_sql';
-    my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
-    my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
-    $sth->execute( ($self->custnum) x $numnum )
-      or die "Error executing 'SELECT $sql': ". $sth->errstr;
-    return $status if $sth->fetchrow_arrayref->[0];
+  my $svcdb = shift;
+  if ( ! $svcdb =~ /^svc_\w+$/ ) {
+    warn "$me svc_x requires a svcdb";
+    return;
   }
   }
-}
-
-=item ucfirst_cust_status
+  my $opt = ref($_[0]) ? shift : { @_ };
 
 
-=item ucfirst_status
+  $opt->{'table'} = $svcdb;
+  $opt->{'addl_from'} = 
+    'LEFT JOIN cust_svc USING (svcnum) LEFT JOIN cust_pkg USING (pkgnum) '.
+    ($opt->{'addl_from'} || '');
 
 
-Returns the status with the first character capitalized.
+  my $custnum = $self->custnum;
+  $custnum =~ /^\d+$/ or die "bad custnum '$custnum'";
+  my $where = "cust_pkg.custnum = $custnum";
 
 
-=cut
+  my $extra_sql = $opt->{'extra_sql'} || '';
+  if ( keys %{ $opt->{'hashref'} } ) {
+    $extra_sql = " AND $where $extra_sql";
+  }
+  else {
+    if ( $opt->{'extra_sql'} =~ /^\s*where\s(.*)/si ) {
+      $extra_sql = "WHERE $where AND $1";
+    }
+    else {
+      $extra_sql = "WHERE $where $extra_sql";
+    }
+  }
+  $opt->{'extra_sql'} = $extra_sql;
 
 
-sub ucfirst_status { shift->ucfirst_cust_status(@_); }
+  qsearch($opt);
+}
 
 
-sub ucfirst_cust_status {
+# required for use as an eventtable; 
+sub svc_acct {
   my $self = shift;
   my $self = shift;
-  ucfirst($self->cust_status);
+  $self->svc_x('svc_acct', @_);
 }
 
 }
 
-=item statuscolor
+=item cust_credit
 
 
-Returns a hex triplet color string for this customer's status.
+Returns all the credits (see L<FS::cust_credit>) for this customer.
 
 =cut
 
 
 =cut
 
-use vars qw(%statuscolor);
-tie %statuscolor, 'Tie::IxHash',
-  'prospect'  => '7e0079', #'000000', #black?  naw, purple
-  'active'    => '00CC00', #green
-  'inactive'  => '0000CC', #blue
-  'suspended' => 'FF9900', #yellow
-  'cancelled' => 'FF0000', #red
-;
-
-sub statuscolor { shift->cust_statuscolor(@_); }
-
-sub cust_statuscolor {
+sub cust_credit {
   my $self = shift;
   my $self = shift;
-  $statuscolor{$self->cust_status};
+  map { $_ } #return $self->num_cust_credit unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
 }
 
 }
 
-=item tickets
+=item cust_credit_pkgnum
 
 
-Returns an array of hashes representing the customer's RT tickets.
+Returns all the credits (see L<FS::cust_credit>) for this customer's specific
+package when using experimental package balances.
 
 =cut
 
 
 =cut
 
-sub tickets {
-  my $self = shift;
-
-  my $num = $conf->config('cust_main-max_tickets') || 10;
-  my @tickets = ();
+sub cust_credit_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  map { $_ } #return $self->num_cust_credit_pkgnum($pkgnum) unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_credit', { 'custnum' => $self->custnum,
+                              'pkgnum'  => $pkgnum,
+                            }
+    );
+}
 
 
-  if ( $conf->config('ticket_system') ) {
-    unless ( $conf->config('ticket_system-custom_priority_field') ) {
+=item cust_credit_void
 
 
-      @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
+Returns all voided credits (see L<FS::cust_credit_void>) for this customer.
 
 
-    } else {
+=cut
 
 
-      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);
+sub cust_credit_void {
+  my $self = shift;
+  map { $_ }
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_credit_void', { 'custnum' => $self->custnum } )
 }
 
 }
 
-# Return services representing svc_accts in customer support packages
-sub support_services {
-  my $self = shift;
-  my %packages = map { $_ => 1 } $conf->config('support_packages');
+=item cust_pay
 
 
-  grep { $_->pkg_svc && $_->pkg_svc->primary_svc eq 'Y' }
-    grep { $_->part_svc->svcdb eq 'svc_acct' }
-    map { $_->cust_svc }
-    grep { exists $packages{ $_->pkgpart } }
-    $self->ncancelled_pkgs;
+Returns all the payments (see L<FS::cust_pay>) for this customer.
 
 
-}
+=cut
 
 
-# Return a list of latitude/longitude for one of the services (if any)
-sub service_coordinates {
+sub cust_pay {
   my $self = shift;
   my $self = shift;
+  my $opt = ref($_[0]) ? shift : { @_ };
 
 
-  my @svc_X = 
-    grep { $_->latitude && $_->longitude }
-    map { $_->svc_x }
-    map { $_->cust_svc }
-    $self->ncancelled_pkgs;
-
-  scalar(@svc_X) ? ( $svc_X[0]->latitude, $svc_X[0]->longitude ) : ()
-}
-
-=back
+  return $self->num_cust_pay unless wantarray || keys %$opt;
 
 
-=head1 CLASS METHODS
+  $opt->{'table'} = 'cust_pay';
+  $opt->{'hashref'}{'custnum'} = $self->custnum;
 
 
-=over 4
+  map { $_ } #behavior of sort undefined in scalar context
+    sort { $a->_date <=> $b->_date }
+      qsearch($opt);
 
 
-=item statuses
+}
 
 
-Class method that returns the list of possible status strings for customers
-(see L<the status method|/status>).  For example:
+=item num_cust_pay
 
 
-  @statuses = FS::cust_main->statuses();
+Returns the number of payments (see L<FS::cust_pay>) for this customer.  Also
+called automatically when the cust_pay method is used in a scalar context.
 
 =cut
 
 
 =cut
 
-sub statuses {
-  #my $self = shift; #could be class...
-  keys %statuscolor;
+sub num_cust_pay {
+  my $self = shift;
+  my $sql = "SELECT COUNT(*) FROM cust_pay WHERE custnum = ?";
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute($self->custnum) or die $sth->errstr;
+  $sth->fetchrow_arrayref->[0];
 }
 
 }
 
-=item prospect_sql
+=item unapplied_cust_pay
 
 
-Returns an SQL expression identifying prospective cust_main records (customers
-with no packages ever ordered)
+Returns all the unapplied payments (see L<FS::cust_pay>) for this customer.
 
 =cut
 
 
 =cut
 
-use vars qw($select_count_pkgs);
-$select_count_pkgs =
-  "SELECT COUNT(*) FROM cust_pkg
-    WHERE cust_pkg.custnum = cust_main.custnum";
+sub unapplied_cust_pay {
+  my $self = shift;
 
 
-sub select_count_pkgs_sql {
-  $select_count_pkgs;
-}
+  $self->cust_pay(
+    'extra_sql' => ' AND '. FS::cust_pay->unapplied_sql. ' > 0',
+    #@_
+  );
 
 
-sub prospect_sql { "
-  0 = ( $select_count_pkgs )
-"; }
+}
 
 
-=item active_sql
+=item cust_pay_pkgnum
 
 
-Returns an SQL expression identifying active cust_main records (customers with
-active recurring packages).
+Returns all the payments (see L<FS::cust_pay>) for this customer's specific
+package when using experimental package balances.
 
 =cut
 
 
 =cut
 
-sub active_sql { "
-  0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. "
-      )
-"; }
+sub cust_pay_pkgnum {
+  my( $self, $pkgnum ) = @_;
+  map { $_ } #return $self->num_cust_pay_pkgnum($pkgnum) unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_pay', { 'custnum' => $self->custnum,
+                           'pkgnum'  => $pkgnum,
+                         }
+    );
+}
 
 
-=item inactive_sql
+=item cust_pay_void
 
 
-Returns an SQL expression identifying inactive cust_main records (customers with
-no active recurring packages, but otherwise unsuspended/uncancelled).
+Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
 
 =cut
 
 
 =cut
 
-sub inactive_sql { "
-  0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
-  AND
-  0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
-"; }
+sub cust_pay_void {
+  my $self = shift;
+  map { $_ } #return $self->num_cust_pay_void unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
+}
 
 
-=item susp_sql
-=item suspended_sql
+=item cust_pay_pending
 
 
-Returns an SQL expression identifying suspended cust_main records.
+Returns all pending payments (see L<FS::cust_pay_pending>) for this customer
+(without status "done").
 
 =cut
 
 
 =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' },
+                                 },
+           );
+}
 
 
-sub suspended_sql { susp_sql(@_); }
-sub susp_sql { "
-    0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " )
-    AND
-    0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
-"; }
-
-=item cancel_sql
-=item cancelled_sql
+=item cust_pay_pending_attempt
 
 
-Returns an SQL expression identifying cancelled cust_main records.
+Returns all payment attempts / declined payments for this customer, as pending
+payments objects (see L<FS::cust_pay_pending>), with status "done" but without
+a corresponding payment (see L<FS::cust_pay>).
 
 =cut
 
 
 =cut
 
-sub cancelled_sql { cancel_sql(@_); }
-sub cancel_sql {
-
-  my $recurring_sql = FS::cust_pkg->recurring_sql;
-  my $cancelled_sql = FS::cust_pkg->cancelled_sql;
-
-  "
-        0 < ( $select_count_pkgs )
-    AND 0 < ( $select_count_pkgs AND $recurring_sql AND $cancelled_sql   )
-    AND 0 = ( $select_count_pkgs AND $recurring_sql
-                  AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
-            )
-    AND 0 = (  $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
-  ";
-
+sub cust_pay_pending_attempt {
+  my $self = shift;
+  return $self->num_cust_pay_pending_attempt unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_pay_pending', {
+                                   'custnum' => $self->custnum,
+                                   'status'  => 'done',
+                                   'paynum'  => '',
+                                 },
+           );
 }
 
 }
 
-=item uncancel_sql
-=item uncancelled_sql
+=item num_cust_pay_pending
 
 
-Returns an SQL expression identifying un-cancelled cust_main records.
+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
 
 
 =cut
 
-sub uncancelled_sql { uncancel_sql(@_); }
-sub uncancel_sql { "
-  ( 0 < ( $select_count_pkgs
-                   AND ( cust_pkg.cancel IS NULL
-                         OR cust_pkg.cancel = 0
-                       )
-        )
-    OR 0 = ( $select_count_pkgs )
-  )
-"; }
+sub num_cust_pay_pending {
+  my $self = shift;
+  $self->scalar_sql(
+    " SELECT COUNT(*) FROM cust_pay_pending ".
+      " WHERE custnum = ? AND status != 'done' ",
+    $self->custnum
+  );
+}
 
 
-=item balance_sql
+=item num_cust_pay_pending_attempt
 
 
-Returns an SQL fragment to retreive the balance.
+Returns the number of pending payments (see L<FS::cust_pay_pending>) for this
+customer, with status "done" but without a corresp.  Also called automatically when the
+cust_pay_pending method is used in a scalar context.
 
 =cut
 
 
 =cut
 
-sub balance_sql { "
-    ( SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill
-        WHERE cust_bill.custnum   = cust_main.custnum     )
-  - ( SELECT COALESCE( SUM(paid),    0 ) FROM cust_pay
-        WHERE cust_pay.custnum    = cust_main.custnum     )
-  - ( SELECT COALESCE( SUM(amount),  0 ) FROM cust_credit
-        WHERE cust_credit.custnum = cust_main.custnum     )
-  + ( SELECT COALESCE( SUM(refund),  0 ) FROM cust_refund
-        WHERE cust_refund.custnum = cust_main.custnum     )
-"; }
-
-=item balance_date_sql START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
-
-Returns an SQL fragment to retreive the balance for this customer, only
-considering invoices with date earlier than START_TIME, and optionally not
-later than END_TIME (total_owed_date minus total_unapplied_credits minus
-total_unapplied_payments).
+sub num_cust_pay_pending_attempt {
+  my $self = shift;
+  $self->scalar_sql(
+    " SELECT COUNT(*) FROM cust_pay_pending ".
+      " WHERE custnum = ? AND status = 'done' AND paynum IS NULL",
+    $self->custnum
+  );
+}
 
 
-Times are specified as SQL fragments or numeric
-UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
-L<Date::Parse> for conversion functions.  The empty string can be passed
-to disable that time constraint completely.
+=item cust_refund
 
 
-Available options are:
+Returns all the refunds (see L<FS::cust_refund>) for this customer.
 
 
-=over 4
+=cut
 
 
-=item unapplied_date
+sub cust_refund {
+  my $self = shift;
+  map { $_ } #return $self->num_cust_refund unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
+}
 
 
-set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
+=item display_custnum
 
 
-=item total
+Returns the displayed customer number for this customer: agent_custid if
+cust_main-default_agent_custid is set and it has a value, custnum otherwise.
 
 
-(unused.  obsolete?)
-set to true to remove all customer comparison clauses, for totals
+=cut
 
 
-=item where
+sub display_custnum {
+  my $self = shift;
 
 
-(unused.  obsolete?)
-WHERE clause hashref (elements "AND"ed together) (typically used with the total option)
+  my $prefix = $conf->config('cust_main-custnum-display_prefix', $self->agentnum) || '';
+  if ( my $special = $conf->config('cust_main-custnum-display_special') ) {
+    if ( $special eq 'CoStAg' ) {
+      $prefix = uc( join('',
+        $self->country,
+        ($self->state =~ /^(..)/),
+        $prefix || ($self->agent->agent =~ /^(..)/)
+      ) );
+    }
+    elsif ( $special eq 'CoStCl' ) {
+      $prefix = uc( join('',
+        $self->country,
+        ($self->state =~ /^(..)/),
+        ($self->classnum ? $self->cust_class->classname =~ /^(..)/ : '__')
+      ) );
+    }
+    # add any others here if needed
+  }
 
 
-=item join
+  my $length = $conf->config('cust_main-custnum-display_length');
+  if ( $conf->exists('cust_main-default_agent_custid') && $self->agent_custid ){
+    return $self->agent_custid;
+  } elsif ( $prefix ) {
+    $length = 8 if !defined($length);
+    return $prefix . 
+           sprintf('%0'.$length.'d', $self->custnum)
+  } elsif ( $length ) {
+    return sprintf('%0'.$length.'d', $self->custnum);
+  } else {
+    return $self->custnum;
+  }
+}
 
 
-(unused.  obsolete?)
-JOIN clause (typically used with the total option)
+=item name
 
 
-=back
+Returns a name string for this customer, either "Company (Last, First)" or
+"Last, First".
 
 =cut
 
 
 =cut
 
-sub balance_date_sql {
-  my( $class, $start, $end, %opt ) = @_;
-
-  my $owed         = FS::cust_bill->owed_sql;
-  my $unapp_refund = FS::cust_refund->unapplied_sql;
-  my $unapp_credit = FS::cust_credit->unapplied_sql;
-  my $unapp_pay    = FS::cust_pay->unapplied_sql;
+sub name {
+  my $self = shift;
+  my $name = $self->contact;
+  $name = $self->company. " ($name)" if $self->company;
+  $name;
+}
 
 
-  my $j = $opt{'join'} || '';
+=item service_contact
 
 
-  my $owed_wh   = $class->_money_table_where( 'cust_bill',   $start,$end,%opt );
-  my $refund_wh = $class->_money_table_where( 'cust_refund', $start,$end,%opt );
-  my $credit_wh = $class->_money_table_where( 'cust_credit', $start,$end,%opt );
-  my $pay_wh    = $class->_money_table_where( 'cust_pay',    $start,$end,%opt );
+Returns the L<FS::contact> object for this customer that has the 'Service'
+contact class, or undef if there is no such contact.  Deprecated; don't use
+this in new code.
 
 
-  "   ( SELECT COALESCE(SUM($owed),         0) FROM cust_bill   $j $owed_wh   )
-    + ( SELECT COALESCE(SUM($unapp_refund), 0) FROM cust_refund $j $refund_wh )
-    - ( SELECT COALESCE(SUM($unapp_credit), 0) FROM cust_credit $j $credit_wh )
-    - ( SELECT COALESCE(SUM($unapp_pay),    0) FROM cust_pay    $j $pay_wh    )
-  ";
+=cut
 
 
+sub service_contact {
+  my $self = shift;
+  if ( !exists($self->{service_contact}) ) {
+    my $classnum = $self->scalar_sql(
+      'SELECT classnum FROM contact_class WHERE classname = \'Service\''
+    ) || 0; #if it's zero, qsearchs will return nothing
+    my $cust_contact = qsearchs('cust_contact', { 
+        'classnum' => $classnum,
+        'custnum'  => $self->custnum,
+    });
+    $self->{service_contact} = $cust_contact->contact if $cust_contact;
+  }
+  $self->{service_contact};
 }
 
 }
 
-=item unapplied_payments_date_sql START_TIME [ END_TIME ]
-
-Returns an SQL fragment to retreive the total unapplied payments for this
-customer, only considering invoices with date earlier than START_TIME, and
-optionally not later than END_TIME.
-
-Times are specified as SQL fragments or numeric
-UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
-L<Date::Parse> for conversion functions.  The empty string can be passed
-to disable that time constraint completely.
+=item ship_name
 
 
-Available options are:
+Returns a name string for this (service/shipping) contact, either
+"Company (Last, First)" or "Last, First".
 
 =cut
 
 
 =cut
 
-sub unapplied_payments_date_sql {
-  my( $class, $start, $end, ) = @_;
+sub ship_name {
+  my $self = shift;
 
 
-  my $unapp_pay    = FS::cust_pay->unapplied_sql;
+  my $name = $self->ship_contact;
+  $name = $self->company. " ($name)" if $self->company;
+  $name;
+}
 
 
-  my $pay_where = $class->_money_table_where( 'cust_pay', $start, $end,
-                                                          'unapplied_date'=>1 );
+=item name_short
 
 
-  " ( SELECT COALESCE(SUM($unapp_pay), 0) FROM cust_pay $pay_where ) ";
-}
+Returns a name string for this customer, either "Company" or "First Last".
 
 
-=item _money_table_where TABLE START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
+=cut
 
 
-Helper method for balance_date_sql; name (and usage) subject to change
-(suggestions welcome).
+sub name_short {
+  my $self = shift;
+  $self->company !~ /^\s*$/ ? $self->company : $self->contact_firstlast;
+}
 
 
-Returns a WHERE clause for the specified monetary TABLE (cust_bill,
-cust_refund, cust_credit or cust_pay).
+=item ship_name_short
 
 
-If TABLE is "cust_bill" or the unapplied_date option is true, only
-considers records with date earlier than START_TIME, and optionally not
-later than END_TIME .
+Returns a name string for this (service/shipping) contact, either "Company"
+or "First Last".
 
 =cut
 
 
 =cut
 
-sub _money_table_where {
-  my( $class, $table, $start, $end, %opt ) = @_;
+sub ship_name_short {
+  my $self = shift;
+  $self->service_contact 
+    ? $self->ship_contact_firstlast 
+    : $self->name_short
+}
 
 
-  my @where = ();
-  push @where, "cust_main.custnum = $table.custnum" unless $opt{'total'};
-  if ( $table eq 'cust_bill' || $opt{'unapplied_date'} ) {
-    push @where, "$table._date <= $start" if defined($start) && length($start);
-    push @where, "$table._date >  $end"   if defined($end)   && length($end);
-  }
-  push @where, @{$opt{'where'}} if $opt{'where'};
-  my $where = scalar(@where) ? 'WHERE '. join(' AND ', @where ) : '';
+=item contact
 
 
-  $where;
+Returns this customer's full (billing) contact name only, "Last, First"
+
+=cut
 
 
+sub contact {
+  my $self = shift;
+  $self->get('last'). ', '. $self->first;
 }
 
 }
 
-=item search HASHREF
+=item ship_contact
 
 
-(Class method)
+Returns this customer's full (shipping) contact name only, "Last, First"
 
 
-Returns a qsearch hash expression to search for parameters specified in
-HASHREF.  Valid parameters are
+=cut
 
 
-=over 4
+sub ship_contact {
+  my $self = shift;
+  my $contact = $self->service_contact || $self;
+  $contact->get('last') . ', ' . $contact->get('first');
+}
 
 
-=item agentnum
+=item contact_firstlast
 
 
-=item status
+Returns this customers full (billing) contact name only, "First Last".
 
 
-=item cancelled_pkgs
+=cut
 
 
-bool
+sub contact_firstlast {
+  my $self = shift;
+  $self->first. ' '. $self->get('last');
+}
 
 
-=item signupdate
+=item ship_contact_firstlast
 
 
-listref of start date, end date
+Returns this customer's full (shipping) contact name only, "First Last".
 
 
-=item payby
+=cut
 
 
-listref
+sub ship_contact_firstlast {
+  my $self = shift;
+  my $contact = $self->service_contact || $self;
+  $contact->get('first') . ' '. $contact->get('last');
+}
 
 
-=item paydate_year
+sub bill_country_full {
+  my $self = shift;
+  $self->bill_location->country_full;
+}
 
 
-=item paydate_month
+sub ship_country_full {
+  my $self = shift;
+  $self->ship_location->country_full;
+}
 
 
-=item current_balance
+=item county_state_county [ PREFIX ]
 
 
-listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance'))
+Returns a string consisting of just the county, state and country.
 
 
-=item cust_fields
+=cut
 
 
-=item flattened_pkgs
+sub county_state_country {
+  my $self = shift;
+  my $locationnum;
+  if ( @_ && $_[0] && $self->has_ship_address ) {
+    $locationnum = $self->ship_locationnum;
+  } else {
+    $locationnum = $self->bill_locationnum;
+  }
+  my $cust_location = qsearchs('cust_location', { locationnum=>$locationnum });
+  $cust_location->county_state_country;
+}
 
 
-bool
+=item geocode DATA_VENDOR
 
 
-=back
+Returns a value for the customer location as encoded by DATA_VENDOR.
+Currently this only makes sense for "CCH" as DATA_VENDOR.
 
 =cut
 
 
 =cut
 
-sub search {
-  my ($class, $params) = @_;
+=item cust_status
 
 
-  my $dbh = dbh;
+=item status
 
 
-  my @where = ();
-  my $orderby;
+Returns a status string for this customer, currently:
 
 
-  ##
-  # parse agent
-  ##
+=over 4
 
 
-  if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
-    push @where,
-      "cust_main.agentnum = $1";
-  }
+=item prospect
 
 
-  ##
-  # do the same for user
-  ##
+No packages have ever been ordered.  Displayed as "No packages".
 
 
-  if ( $params->{'usernum'} =~ /^(\d+)$/ and $1 ) {
-    push @where,
-      "cust_main.usernum = $1";
-  }
+=item ordered
 
 
-  ##
-  # parse status
-  ##
+Recurring packages all are new (not yet billed).
 
 
-  #prospect active inactive suspended cancelled
-  if ( grep { $params->{'status'} eq $_ } FS::cust_main->statuses() ) {
-    my $method = $params->{'status'}. '_sql';
-    #push @where, $class->$method();
-    push @where, FS::cust_main->$method();
-  }
-  
-  ##
-  # parse cancelled package checkbox
-  ##
+=item active
 
 
-  my $pkgwhere = "";
+One or more recurring packages is active.
 
 
-  $pkgwhere .= "AND (cancel = 0 or cancel is null)"
-    unless $params->{'cancelled_pkgs'};
+=item inactive
 
 
-  ##
-  # parse without census tract checkbox
-  ##
+No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled).
 
 
-  push @where, "(censustract = '' or censustract is null)"
-    if $params->{'no_censustract'};
+=item suspended
 
 
-  ##
-  # dates
-  ##
+All non-cancelled recurring packages are suspended.
 
 
-  foreach my $field (qw( signupdate )) {
+=item cancelled
 
 
-    next unless exists($params->{$field});
+All recurring packages are cancelled.
 
 
-    my($beginning, $ending, $hour) = @{$params->{$field}};
+=back
 
 
-    push @where,
-      "cust_main.$field IS NOT NULL",
-      "cust_main.$field >= $beginning",
-      "cust_main.$field <= $ending";
+Behavior of inactive vs. cancelled edge cases can be adjusted with the
+cust_main-status_module configuration option.
 
 
-    # XXX: do this for mysql and/or pull it out of here
-    if(defined $hour) {
-      if ($dbh->{Driver}->{Name} eq 'Pg') {
-        push @where, "extract(hour from to_timestamp(cust_main.$field)) = $hour";
-      }
-      else {
-        warn "search by time of day not supported on ".$dbh->{Driver}->{Name}." databases";
-      }
-    }
+=cut
 
 
-    $orderby ||= "ORDER BY cust_main.$field";
+sub status { shift->cust_status(@_); }
 
 
+sub cust_status {
+  my $self = shift;
+  for my $status ( FS::cust_main->statuses() ) {
+    my $method = $status.'_sql';
+    my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
+    my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
+    $sth->execute( ($self->custnum) x $numnum )
+      or die "Error executing 'SELECT $sql': ". $sth->errstr;
+    return $status if $sth->fetchrow_arrayref->[0];
   }
   }
+}
 
 
-  ###
-  # classnum
-  ###
-
-  if ( $params->{'classnum'} ) {
+=item is_status_delay_cancel
 
 
-    my @classnum = ref( $params->{'classnum'} )
-                     ? @{ $params->{'classnum'} }
-                     :  ( $params->{'classnum'} );
+Returns true if customer status is 'suspended'
+and all suspended cust_pkg return true for
+cust_pkg->is_status_delay_cancel.
 
 
-    @classnum = grep /^(\d*)$/, @classnum;
+This is not a real status, this only meant for hacking display 
+values, because otherwise treating the customer as suspended is 
+really the whole point of the delay_cancel option.
 
 
-    if ( @classnum ) {
-      push @where, '( '. join(' OR ', map {
-                                            $_ ? "cust_main.classnum = $_"
-                                               : "cust_main.classnum IS NULL"
-                                          }
-                                          @classnum
-                             ).
-                   ' )';
-    }
+=cut
 
 
+sub is_status_delay_cancel {
+  my ($self) = @_;
+  return 0 unless $self->status eq 'suspended';
+  foreach my $cust_pkg ($self->ncancelled_pkgs) {
+    return 0 unless $cust_pkg->is_status_delay_cancel;
   }
   }
+  return 1;
+}
 
 
-  ###
-  # payby
-  ###
-
-  if ( $params->{'payby'} ) {
-
-    my @payby = ref( $params->{'payby'} )
-                  ? @{ $params->{'payby'} }
-                  :  ( $params->{'payby'} );
+=item ucfirst_cust_status
 
 
-    @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
+=item ucfirst_status
 
 
-    push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )'
-      if @payby;
+Deprecated, use the cust_status_label method instead.
 
 
-  }
+Returns the status with the first character capitalized.
 
 
-  ###
-  # paydate_year / paydate_month
-  ###
-
-  if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
-    my $year = $1;
-    $params->{'paydate_month'} =~ /^(\d\d?)$/
-      or die "paydate_year without paydate_month?";
-    my $month = $1;
-
-    push @where,
-      'paydate IS NOT NULL',
-      "paydate != ''",
-      "CAST(paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
-;
-  }
+=cut
 
 
-  ###
-  # invoice terms
-  ###
+sub ucfirst_status {
+  carp "ucfirst_status deprecated, use cust_status_label" unless $ucfirst_nowarn;
+  local($ucfirst_nowarn) = 1;
+  shift->ucfirst_cust_status(@_);
+}
 
 
-  if ( $params->{'invoice_terms'} =~ /^([\w ]+)$/ ) {
-    my $terms = $1;
-    if ( $1 eq 'NULL' ) {
-      push @where,
-        "( cust_main.invoice_terms IS NULL OR cust_main.invoice_terms = '' )";
-    } else {
-      push @where,
-        "cust_main.invoice_terms IS NOT NULL",
-        "cust_main.invoice_terms = '$1'";
-    }
-  }
+sub ucfirst_cust_status {
+  carp "ucfirst_cust_status deprecated, use cust_status_label" unless $ucfirst_nowarn;
+  my $self = shift;
+  ucfirst($self->cust_status);
+}
 
 
-  ##
-  # amounts
-  ##
+=item cust_status_label
 
 
-  if ( $params->{'current_balance'} ) {
+=item status_label
 
 
-    #my $balance_sql = $class->balance_sql();
-    my $balance_sql = FS::cust_main->balance_sql();
+Returns the display label for this status.
 
 
-    my @current_balance =
-      ref( $params->{'current_balance'} )
-      ? @{ $params->{'current_balance'} }
-      :  ( $params->{'current_balance'} );
+=cut
 
 
-    push @where, map { s/current_balance/$balance_sql/; $_ }
-                     @current_balance;
+sub status_label { shift->cust_status_label(@_); }
 
 
-  }
+sub cust_status_label {
+  my $self = shift;
+  __PACKAGE__->statuslabels->{$self->cust_status};
+}
 
 
-  ##
-  # custbatch
-  ##
+=item statuscolor
 
 
-  if ( $params->{'custbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
-    push @where,
-      "cust_main.custbatch = '$1'";
-  }
+Returns a hex triplet color string for this customer's status.
 
 
-  ##
-  # setup queries, subs, etc. for the search
-  ##
+=cut
 
 
-  $orderby ||= 'ORDER BY custnum';
+sub statuscolor { shift->cust_statuscolor(@_); }
 
 
-  # here is the agent virtualization
-  push @where, $FS::CurrentUser::CurrentUser->agentnums_sql;
+sub cust_statuscolor {
+  my $self = shift;
+  __PACKAGE__->statuscolors->{$self->cust_status};
+}
 
 
-  my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
+=item tickets [ STATUS ]
 
 
-  my $addl_from = 'LEFT JOIN cust_pkg USING ( custnum  ) ';
+Returns an array of hashes representing the customer's RT tickets.
 
 
-  my $count_query = "SELECT COUNT(*) FROM cust_main $extra_sql";
+An optional status (or arrayref or hashref of statuses) may be specified.
 
 
-  my $select = join(', ', 
-                 'cust_main.custnum',
-                 FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
-               );
+=cut
 
 
-  my(@extra_headers) = ();
-  my(@extra_fields)  = ();
+sub tickets {
+  my $self = shift;
+  my $status = ( @_ && $_[0] ) ? shift : '';
 
 
-  if ($params->{'flattened_pkgs'}) {
+  my $num = $conf->config('cust_main-max_tickets') || 10;
+  my @tickets = ();
 
 
-    if ($dbh->{Driver}->{Name} eq 'Pg') {
+  if ( $conf->config('ticket_system') ) {
+    unless ( $conf->config('ticket_system-custom_priority_field') ) {
 
 
-      $select .= ", array_to_string(array(select pkg from cust_pkg left join part_pkg using ( pkgpart ) where cust_main.custnum = cust_pkg.custnum $pkgwhere),'|') as magic";
+      @tickets = @{ FS::TicketSystem->customer_tickets( $self->custnum,
+                                                        $num,
+                                                        undef,
+                                                        $status,
+                                                      )
+                  };
 
 
-    }elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) {
-      $select .= ", GROUP_CONCAT(pkg SEPARATOR '|') as magic";
-      $addl_from .= " LEFT JOIN part_pkg using ( pkgpart )";
-    }else{
-      warn "warning: unknown database type ". $dbh->{Driver}->{Name}. 
-           "omitting packing information from report.";
-    }
+    } else {
 
 
-    my $header_query = "SELECT COUNT(cust_pkg.custnum = cust_main.custnum) AS count FROM cust_main $addl_from $extra_sql $pkgwhere group by cust_main.custnum order by count desc limit 1";
-
-    my $sth = dbh->prepare($header_query) or die dbh->errstr;
-    $sth->execute() or die $sth->errstr;
-    my $headerrow = $sth->fetchrow_arrayref;
-    my $headercount = $headerrow ? $headerrow->[0] : 0;
-    while($headercount) {
-      unshift @extra_headers, "Package ". $headercount;
-      unshift @extra_fields, eval q!sub {my $c = shift;
-                                         my @a = split '\|', $c->magic;
-                                         my $p = $a[!.--$headercount. q!];
-                                         $p;
-                                        };!;
+      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,
+                                                 $status,
+                                               )
+           };
+      }
     }
     }
-
   }
   }
-
-  my $sql_query = {
-    'table'         => 'cust_main',
-    'select'        => $select,
-    'hashref'       => {},
-    'extra_sql'     => $extra_sql,
-    'order_by'      => $orderby,
-    'count_query'   => $count_query,
-    'extra_headers' => \@extra_headers,
-    'extra_fields'  => \@extra_fields,
-  };
-
+  (@tickets);
 }
 
 }
 
-=item email_search_result HASHREF
-
-(Class method)
-
-Emails a notice to the specified customers.
+=item appointments [ STATUS ]
 
 
-Valid parameters are those of the L<search> method, plus the following:
+Returns an array of hashes representing the customer's RT tickets which
+are appointments.
 
 
-=over 4
-
-=item from
+=cut
 
 
-From: address
+sub appointments {
+  my $self = shift;
+  my $status = ( @_ && $_[0] ) ? shift : '';
 
 
-=item subject
+  return () unless $conf->config('ticket_system');
 
 
-Email Subject:
+  my $queueid = $conf->config('ticket_system-appointment-queueid');
 
 
-=item html_body
+  @{ FS::TicketSystem->customer_tickets( $self->custnum,
+                                         99,
+                                         undef,
+                                         $status,
+                                         $queueid,
+                                       )
+  };
+}
 
 
-HTML body
+# Return services representing svc_accts in customer support packages
+sub support_services {
+  my $self = shift;
+  my %packages = map { $_ => 1 } $conf->config('support_packages');
 
 
-=item text_body
+  grep { $_->pkg_svc && $_->pkg_svc->primary_svc eq 'Y' }
+    grep { $_->part_svc->svcdb eq 'svc_acct' }
+    map { $_->cust_svc }
+    grep { exists $packages{ $_->pkgpart } }
+    $self->ncancelled_pkgs;
 
 
-Text body
+}
 
 
-=item job
+# Return a list of latitude/longitude for one of the services (if any)
+sub service_coordinates {
+  my $self = shift;
 
 
-Optional job queue job for status updates.
+  my @svc_X = 
+    grep { $_->latitude && $_->longitude }
+    map { $_->svc_x }
+    map { $_->cust_svc }
+    $self->ncancelled_pkgs;
 
 
-=back
+  scalar(@svc_X) ? ( $svc_X[0]->latitude, $svc_X[0]->longitude ) : ()
+}
 
 
-Returns an error message, or false for success.
+=item masked FIELD
 
 
-If an error occurs during any email, stops the enture send and returns that
-error.  Presumably if you're getting SMTP errors aborting is better than 
-retrying everything.
+Returns a masked version of the named field
 
 =cut
 
 
 =cut
 
-sub email_search_result {
-  my($class, $params) = @_;
+sub masked {
+my ($self,$field) = @_;
 
 
-  my $from = delete $params->{from};
-  my $subject = delete $params->{subject};
-  my $html_body = delete $params->{html_body};
-  my $text_body = delete $params->{text_body};
+# Show last four
 
 
-  my $job = delete $params->{'job'};
+'x'x(length($self->getfield($field))-4).
+  substr($self->getfield($field), (length($self->getfield($field))-4));
 
 
-  $params->{'payby'} = [ split(/\0/, $params->{'payby'}) ]
-    unless ref($params->{'payby'});
+}
 
 
-  my $sql_query = $class->search($params);
+=item payment_history
 
 
-  my $count_query   = delete($sql_query->{'count_query'});
-  my $count_sth = dbh->prepare($count_query)
-    or die "Error preparing $count_query: ". dbh->errstr;
-  $count_sth->execute
-    or die "Error executing $count_query: ". $count_sth->errstr;
-  my $count_arrayref = $count_sth->fetchrow_arrayref;
-  my $num_cust = $count_arrayref->[0];
+Returns an array of hashrefs standardizing information from cust_bill, cust_pay,
+cust_credit and cust_refund objects.  Each hashref has the following fields:
 
 
-  #my @extra_headers = @{ delete($sql_query->{'extra_headers'}) };
-  #my @extra_fields  = @{ delete($sql_query->{'extra_fields'})  };
+I<type> - one of 'Line item', 'Invoice', 'Payment', 'Credit', 'Refund' or 'Previous'
 
 
+I<date> - value of _date field, unix timestamp
 
 
-  my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
+I<date_pretty> - user-friendly date
 
 
-  #eventually order+limit magic to reduce memory use?
-  foreach my $cust_main ( qsearch($sql_query) ) {
+I<description> - user-friendly description of item
 
 
-    my $to = $cust_main->invoicing_list_emailonly_scalar;
-    next unless $to;
+I<amount> - impact of item on user's balance 
+(positive for Invoice/Refund/Line item, negative for Payment/Credit.)
+Not to be confused with the native 'amount' field in cust_credit, see below.
 
 
-    my $error = send_email(
-      generate_email(
-        'from'      => $from,
-        'to'        => $to,
-        'subject'   => $subject,
-        'html_body' => $html_body,
-        'text_body' => $text_body,
-      )
-    );
-    return $error if $error;
+I<amount_pretty> - includes money char
 
 
-    if ( $job ) { #progressbar foo
-      $num++;
-      if ( time - $min_sec > $last ) {
-        my $error = $job->update_statustext(
-          int( 100 * $num / $num_cust )
-        );
-        die $error if $error;
-        $last = time;
-      }
-    }
+I<balance> - customer balance, chronologically as of this item
 
 
-  }
+I<balance_pretty> - includes money char
 
 
-  return '';
-}
+I<charged> - amount charged for cust_bill (Invoice or Line item) records, undef for other types
 
 
-use Storable qw(thaw);
-use Data::Dumper;
-use MIME::Base64;
-sub process_email_search_result {
-  my $job = shift;
-  #warn "$me process_re_X $method for job $job\n" if $DEBUG;
+I<paid> - amount paid for cust_pay records, undef for other types
 
 
-  my $param = thaw(decode_base64(shift));
-  warn Dumper($param) if $DEBUG;
+I<credit> - amount credited for cust_credit records, undef for other types.
+Literally the 'amount' field from cust_credit, renamed here to avoid confusion.
 
 
-  $param->{'job'} = $job;
+I<refund> - amount refunded for cust_refund records, undef for other types
 
 
-  $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
-    unless ref($param->{'payby'});
+The four table-specific keys always have positive values, whether they reflect charges or payments.
 
 
-  my $error = FS::cust_main->email_search_result( $param );
-  die $error if $error;
+The following options may be passed to this method:
 
 
-}
+I<line_items> - if true, returns charges ('Line item') rather than invoices
 
 
-=item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
+I<start_date> - unix timestamp, only include records on or after.
+If specified, an item of type 'Previous' will also be included.
+It does not have table-specific fields.
 
 
-Performs a fuzzy (approximate) search and returns the matching FS::cust_main
-records.  Currently, I<first>, I<last>, I<company> and/or I<address1> may be
-specified (the appropriate ship_ field is also searched).
+I<end_date> - unix timestamp, only include records before
 
 
-Additional options are the same as FS::Record::qsearch
+I<reverse_sort> - order from newest to oldest (default is oldest to newest)
 
 
-=cut
+I<conf> - optional already-loaded FS::Conf object.
 
 
-sub fuzzy_search {
-  my( $self, $fuzzy, $hash, @opt) = @_;
-  #$self
-  $hash ||= {};
-  my @cust_main = ();
+=cut
 
 
-  check_and_rebuild_fuzzyfiles();
-  foreach my $field ( keys %$fuzzy ) {
+# Caution: this gets used by FS::ClientAPI::MyAccount::billing_history,
+# and also for sending customer statements, which should both be kept customer-friendly.
+# If you add anything that shouldn't be passed on through the API or exposed 
+# to customers, add a new option to include it, don't include it by default
+sub payment_history {
+  my $self = shift;
+  my $opt = ref($_[0]) ? $_[0] : { @_ };
+
+  my $conf = $$opt{'conf'} || new FS::Conf;
+  my $money_char = $conf->config("money_char") || '$',
+
+  #first load entire history, 
+  #need previous to calculate previous balance
+  #loading after end_date shouldn't hurt too much?
+  my @history = ();
+  if ( $$opt{'line_items'} ) {
+
+    foreach my $cust_bill ( $self->cust_bill ) {
+
+      push @history, {
+        'type'        => 'Line item',
+        'description' => $_->desc( $self->locale ).
+                           ( $_->sdate && $_->edate
+                               ? ' '. time2str('%d-%b-%Y', $_->sdate).
+                                 ' To '. time2str('%d-%b-%Y', $_->edate)
+                               : ''
+                           ),
+        'amount'      => sprintf('%.2f', $_->setup + $_->recur ),
+        'charged'     => sprintf('%.2f', $_->setup + $_->recur ),
+        'date'        => $cust_bill->_date,
+        'date_pretty' => $self->time2str_local('short', $cust_bill->_date ),
+      }
+        foreach $cust_bill->cust_bill_pkg;
 
 
-    my $all = $self->all_X($field);
-    next unless scalar(@$all);
+    }
 
 
-    my %match = ();
-    $match{$_}=1 foreach ( amatch( $fuzzy->{$field}, ['i'], @$all ) );
+  } else {
 
 
-    my @fcust = ();
-    foreach ( keys %match ) {
-      push @fcust, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
-      push @fcust, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt);
+    push @history, {
+                     'type'        => 'Invoice',
+                     'description' => 'Invoice #'. $_->display_invnum,
+                     'amount'      => sprintf('%.2f', $_->charged ),
+                     'charged'     => sprintf('%.2f', $_->charged ),
+                     'date'        => $_->_date,
+                     'date_pretty' => $self->time2str_local('short', $_->_date ),
+                   }
+      foreach $self->cust_bill;
+
+  }
+
+  push @history, {
+                   'type'        => 'Payment',
+                   'description' => 'Payment', #XXX type
+                   'amount'      => sprintf('%.2f', 0 - $_->paid ),
+                   'paid'        => sprintf('%.2f', $_->paid ),
+                   'date'        => $_->_date,
+                   'date_pretty' => $self->time2str_local('short', $_->_date ),
+                 }
+    foreach $self->cust_pay;
+
+  push @history, {
+                   'type'        => 'Credit',
+                   'description' => 'Credit', #more info?
+                   'amount'      => sprintf('%.2f', 0 -$_->amount ),
+                   'credit'      => sprintf('%.2f', $_->amount ),
+                   'date'        => $_->_date,
+                   'date_pretty' => $self->time2str_local('short', $_->_date ),
+                 }
+    foreach $self->cust_credit;
+
+  push @history, {
+                   'type'        => 'Refund',
+                   'description' => 'Refund', #more info?  type, like payment?
+                   'amount'      => $_->refund,
+                   'refund'      => $_->refund,
+                   'date'        => $_->_date,
+                   'date_pretty' => $self->time2str_local('short', $_->_date ),
+                 }
+    foreach $self->cust_refund;
+
+  #put it all in chronological order
+  @history = sort { $a->{'date'} <=> $b->{'date'} } @history;
+
+  #calculate balance, filter items outside date range
+  my $previous = 0;
+  my $balance = 0;
+  my @out = ();
+  foreach my $item (@history) {
+    last if $$opt{'end_date'} && ($$item{'date'} >= $$opt{'end_date'});
+    $balance += $$item{'amount'};
+    if ($$opt{'start_date'} && ($$item{'date'} < $$opt{'start_date'})) {
+      $previous += $$item{'amount'};
+      next;
+    }
+    $$item{'balance'} = sprintf("%.2f",$balance);
+    foreach my $key ( qw(amount balance) ) {
+      $$item{$key.'_pretty'} = money_pretty($$item{$key});
     }
     }
-    my %fsaw = ();
-    push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
+    push(@out,$item);
   }
 
   }
 
-  # we want the components of $fuzzy ANDed, not ORed, but still don't want dupes
-  my %saw = ();
-  @cust_main = grep { ++$saw{$_->custnum} == scalar(keys %$fuzzy) } @cust_main;
+  # start with previous balance, if there was one
+  if ($previous) {
+    my $item = {
+      'type'        => 'Previous',
+      'description' => 'Previous balance',
+      'amount'      => sprintf("%.2f",$previous),
+      'balance'     => sprintf("%.2f",$previous),
+      'date'        => $$opt{'start_date'},
+      'date_pretty' => $self->time2str_local('short', $$opt{'start_date'} ),
+    };
+    #false laziness with above
+    foreach my $key ( qw(amount balance) ) {
+      $$item{$key.'_pretty'} = $$item{$key};
+      $$item{$key.'_pretty'} =~ s/^(-?)/$1$money_char/;
+    }
+    unshift(@out,$item);
+  }
 
 
-  @cust_main;
+  @out = reverse @history if $$opt{'reverse_sort'};
 
 
+  return @out;
 }
 
 }
 
-=item masked FIELD
+=item save_cust_payby
 
 
-Returns a masked version of the named field
+Saves a new cust_payby for this customer, replacing an existing entry only
+in select circumstances.  Does not validate input.
 
 
-=cut
+If auto is specified, marks this as the customer's primary method, or the 
+specified weight.  Existing payment methods have their weight incremented as
+appropriate.
 
 
-sub masked {
-my ($self,$field) = @_;
+If bill_location is specified with auto, also sets location in cust_main.
 
 
-# Show last four
+Will not insert complete duplicates of existing records, or records in which the
+only difference from an existing record is to turn off automatic payment (will
+return without error.)  Will replace existing records in which the only difference 
+is to add a value to a previously empty preserved field and/or turn on automatic payment.
+Fields marked as preserved are optional, and existing values will not be overwritten with 
+blanks when replacing.
 
 
-'x'x(length($self->getfield($field))-4).
-  substr($self->getfield($field), (length($self->getfield($field))-4));
+Accepts the following named parameters:
 
 
-}
+=over 4
 
 
-=back
+=item payment_payby
 
 
-=head1 SUBROUTINES
+either CARD or CHEK
 
 
-=over 4
+=item auto
 
 
-=item smart_search OPTION => VALUE ...
+save as an automatic payment type (CARD/CHEK if true, DCRD/DCHK if false)
 
 
-Accepts the following options: I<search>, the string to search for.  The string
-will be searched for as a customer number, phone number, name or company name,
-as an exact, or, in some cases, a substring or fuzzy match (see the source code
-for the exact heuristics used); I<no_fuzzy_on_exact>, causes smart_search to
-skip fuzzy matching when an exact match is found.
+=item weight
 
 
-Any additional options are treated as an additional qualifier on the search
-(i.e. I<agentnum>).
+optional, set higher than 1 for secondary, etc.
 
 
-Returns a (possibly empty) array of FS::cust_main objects.
+=item payinfo
 
 
-=cut
+required
 
 
-sub smart_search {
-  my %options = @_;
+=item paymask
 
 
-  #here is the agent virtualization
-  my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
+optional, but should be specified for anything that might be tokenized, will be preserved when replacing
 
 
-  my @cust_main = ();
+=item payname
 
 
-  my $skip_fuzzy = delete $options{'no_fuzzy_on_exact'};
-  my $search = delete $options{'search'};
-  ( my $alphanum_search = $search ) =~ s/\W//g;
-  
-  if ( $alphanum_search =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { #phone# search
-
-    #false laziness w/Record::ut_phone
-    my $phonen = "$1-$2-$3";
-    $phonen .= " x$4" if $4;
-
-    push @cust_main, qsearch( {
-      'table'   => 'cust_main',
-      'hashref' => { %options },
-      'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
-                     ' ( '.
-                         join(' OR ', map "$_ = '$phonen'",
-                                          qw( daytime night fax
-                                              ship_daytime ship_night ship_fax )
-                             ).
-                     ' ) '.
-                     " AND $agentnums_sql", #agent virtualization
-    } );
+required
 
 
-    unless ( @cust_main || $phonen =~ /x\d+$/ ) { #no exact match
-      #try looking for matches with extensions unless one was specified
-
-      push @cust_main, qsearch( {
-        'table'   => 'cust_main',
-        'hashref' => { %options },
-        'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
-                       ' ( '.
-                           join(' OR ', map "$_ LIKE '$phonen\%'",
-                                            qw( daytime night
-                                                ship_daytime ship_night )
-                               ).
-                       ' ) '.
-                       " AND $agentnums_sql", #agent virtualization
-      } );
+=item payip
 
 
-    }
+optional, will be preserved when replacing
 
 
-  # custnum search (also try agent_custid), with some tweaking options if your
-  # legacy cust "numbers" have letters
-  } 
+=item paydate
 
 
-  if ( $search =~ /^\s*(\d+)\s*$/
-         || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
-              && $search =~ /^\s*(\w\w?\d+)\s*$/
-            )
-         || ( $conf->exists('address1-search' )
-              && $search =~ /^\s*(\d+\-?\w*)\s*$/ #i.e. 1234A or 9432-D
-            )
-     )
-  {
+CARD only, required
 
 
-    my $num = $1;
+=item bill_location
 
 
-    if ( $num =~ /^(\d+)$/ && $num <= 2147483647 ) { #need a bigint custnum? wow
-      push @cust_main, qsearch( {
-        'table'     => 'cust_main',
-        'hashref'   => { 'custnum' => $num, %options },
-        'extra_sql' => " AND $agentnums_sql", #agent virtualization
-      } );
-    }
+CARD only, required, FS::cust_location object
 
 
-    push @cust_main, qsearch( {
-      'table'     => 'cust_main',
-      'hashref'   => { 'agent_custid' => $num, %options },
-      'extra_sql' => " AND $agentnums_sql", #agent virtualization
-    } );
+=item paystart_month
 
 
-    if ( $conf->exists('address1-search') ) {
-      my $len = length($num);
-      $num = lc($num);
-      foreach my $prefix ( '', 'ship_' ) {
-        push @cust_main, qsearch( {
-          'table'     => 'cust_main',
-          'hashref'   => { %options, },
-          'extra_sql' => 
-            ( keys(%options) ? ' AND ' : ' WHERE ' ).
-            " LOWER(SUBSTRING(${prefix}address1 FROM 1 FOR $len)) = '$num' ".
-            " AND $agentnums_sql",
-        } );
-      }
-    }
+CARD only, optional, will be preserved when replacing
 
 
-  } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
-
-    my($company, $last, $first) = ( $1, $2, $3 );
-
-    # "Company (Last, First)"
-    #this is probably something a browser remembered,
-    #so just do an exact search (but case-insensitive, so USPS standardization
-    #doesn't throw a wrench in the works)
-
-    foreach my $prefix ( '', 'ship_' ) {
-      push @cust_main, qsearch( {
-        'table'     => 'cust_main',
-        'hashref'   => { %options },
-        'extra_sql' => 
-          ( keys(%options) ? ' AND ' : ' WHERE ' ).
-          join(' AND ',
-            " LOWER(${prefix}first)   = ". dbh->quote(lc($first)),
-            " LOWER(${prefix}last)    = ". dbh->quote(lc($last)),
-            " LOWER(${prefix}company) = ". dbh->quote(lc($company)),
-            $agentnums_sql,
-          ),
-      } );
-    }
+=item paystart_year
 
 
-  } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
-                                              # try (ship_){last,company}
+CARD only, optional, will be preserved when replacing
 
 
-    my $value = lc($1);
+=item payissue
 
 
-    # # remove "(Last, First)" in "Company (Last, First)", otherwise the
-    # # full strings the browser remembers won't work
-    # $value =~ s/\([\w \,\.\-\']*\)$//; #false laziness w/Record::ut_name
+CARD only, optional, will be preserved when replacing
 
 
-    use Lingua::EN::NameParse;
-    my $NameParse = new Lingua::EN::NameParse(
-             auto_clean     => 1,
-             allow_reversed => 1,
-    );
+=item paycvv
 
 
-    my($last, $first) = ( '', '' );
-    #maybe disable this too and just rely on NameParse?
-    if ( $value =~ /^(.+),\s*([^,]+)$/ ) { # Last, First
-    
-      ($last, $first) = ( $1, $2 );
-    
-    #} elsif  ( $value =~ /^(.+)\s+(.+)$/ ) {
-    } elsif ( ! $NameParse->parse($value) ) {
+CARD only, only used if conf cvv-save is set appropriately
 
 
-      my %name = $NameParse->components;
-      $first = $name{'given_name_1'};
-      $last  = $name{'surname_1'};
+=item paytype
 
 
-    }
+CHEK only
 
 
-    if ( $first && $last ) {
+=item paystate
 
 
-      my($q_last, $q_first) = ( dbh->quote($last), dbh->quote($first) );
+CHEK only
 
 
-      #exact
-      my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
-      $sql .= "
-        (     ( LOWER(last) = $q_last AND LOWER(first) = $q_first )
-           OR ( LOWER(ship_last) = $q_last AND LOWER(ship_first) = $q_first )
-        )";
+=back
 
 
-      push @cust_main, qsearch( {
-        'table'     => 'cust_main',
-        'hashref'   => \%options,
-        'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
-      } );
+=cut
 
 
-      # or it just be something that was typed in... (try that in a sec)
+#The code for this option is in place, but it's not currently used
+#
+# =item replace
+#
+# existing cust_payby object to be replaced (must match custnum)
 
 
-    }
+# stateid/stateid_state/ss are not currently supported in cust_payby,
+# might not even work properly in 4.x, but will need to work here if ever added
 
 
-    my $q_value = dbh->quote($value);
-
-    #exact
-    my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
-    $sql .= " (    LOWER(last)          = $q_value
-                OR LOWER(company)       = $q_value
-                OR LOWER(ship_last)     = $q_value
-                OR LOWER(ship_company)  = $q_value
-            ";
-    $sql .= "   OR LOWER(address1)      = $q_value
-                OR LOWER(ship_address1) = $q_value
-            "
-      if $conf->exists('address1-search');
-    $sql .= " )";
-
-    push @cust_main, qsearch( {
-      'table'     => 'cust_main',
-      'hashref'   => \%options,
-      'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
-    } );
+sub save_cust_payby {
+  my $self = shift;
+  my %opt = @_;
 
 
-    #no exact match, trying substring/fuzzy
-    #always do substring & fuzzy (unless they're explicity config'ed off)
-    #getting complaints searches are not returning enough
-    unless ( @cust_main  && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
+  my $old = $opt{'replace'};
+  my $new = new FS::cust_payby { $old ? $old->hash : () };
+  return "Customer number does not match" if $new->custnum and $new->custnum != $self->custnum;
+  $new->set( 'custnum' => $self->custnum );
 
 
-      #still some false laziness w/search (was search/cust_main.cgi)
+  my $payby = $opt{'payment_payby'};
+  return "Bad payby" unless grep(/^$payby$/,('CARD','CHEK'));
 
 
-      #substring
+  # don't allow turning off auto when replacing
+  $opt{'auto'} ||= 1 if $old and $old->payby !~ /^D/;
 
 
-      my @hashrefs = (
-        { 'company'      => { op=>'ILIKE', value=>"%$value%" }, },
-        { 'ship_company' => { op=>'ILIKE', value=>"%$value%" }, },
-      );
+  my @check_existing; # payby relevant to this payment_payby
 
 
-      if ( $first && $last ) {
+  # set payby based on auto
+  if ( $payby eq 'CARD' ) { 
+    $new->set( 'payby' => ( $opt{'auto'} ? 'CARD' : 'DCRD' ) );
+    @check_existing = qw( CARD DCRD );
+  } elsif ( $payby eq 'CHEK' ) {
+    $new->set( 'payby' => ( $opt{'auto'} ? 'CHEK' : 'DCHK' ) );
+    @check_existing = qw( CHEK DCHK );
+  }
 
 
-        push @hashrefs,
-          { 'first'        => { op=>'ILIKE', value=>"%$first%" },
-            'last'         => { op=>'ILIKE', value=>"%$last%" },
-          },
-          { 'ship_first'   => { op=>'ILIKE', value=>"%$first%" },
-            'ship_last'    => { op=>'ILIKE', value=>"%$last%" },
-          },
-        ;
+  $new->set( 'weight' => $opt{'auto'} ? $opt{'weight'} : '' );
 
 
-      } else {
+  # basic fields
+  $new->payinfo($opt{'payinfo'}); # sets default paymask, but not if it's already tokenized
+  $new->paymask($opt{'paymask'}) if $opt{'paymask'}; # in case it's been tokenized, override with loaded paymask
+  $new->set( 'payname' => $opt{'payname'} );
+  $new->set( 'payip' => $opt{'payip'} ); # will be preserved below
 
 
-        push @hashrefs,
-          { 'last'         => { op=>'ILIKE', value=>"%$value%" }, },
-          { 'ship_last'    => { op=>'ILIKE', value=>"%$value%" }, },
-        ;
-      }
+  my $conf = new FS::Conf;
 
 
-      if ( $conf->exists('address1-search') ) {
-        push @hashrefs,
-          { 'address1'      => { op=>'ILIKE', value=>"%$value%" }, },
-          { 'ship_address1' => { op=>'ILIKE', value=>"%$value%" }, },
-        ;
-      }
+  # compare to FS::cust_main::realtime_bop - check both to make sure working correctly
+  if ( $payby eq 'CARD' &&
+       grep { $_ eq cardtype($opt{'payinfo'}) } $conf->config('cvv-save') ) {
+    $new->set( 'paycvv' => $opt{'paycvv'} );
+  } else {
+    $new->set( 'paycvv' => '');
+  }
 
 
-      foreach my $hashref ( @hashrefs ) {
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
 
 
-        push @cust_main, qsearch( {
-          'table'     => 'cust_main',
-          'hashref'   => { %$hashref,
-                           %options,
-                         },
-          'extra_sql' => " AND $agentnums_sql", #agent virtualizaiton
-        } );
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
 
 
+  # set fields specific to payment_payby
+  if ( $payby eq 'CARD' ) {
+    if ($opt{'bill_location'}) {
+      $opt{'bill_location'}->set('custnum' => $self->custnum);
+      my $error = $opt{'bill_location'}->find_or_insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
       }
       }
+      $new->set( 'locationnum' => $opt{'bill_location'}->locationnum );
+    }
+    foreach my $field ( qw( paydate paystart_month paystart_year payissue ) ) {
+      $new->set( $field => $opt{$field} );
+    }
+  } else {
+    foreach my $field ( qw(paytype paystate) ) {
+      $new->set( $field => $opt{$field} );
+    }
+  }
 
 
-      #fuzzy
-      my @fuzopts = (
-        \%options,                #hashref
-        '',                       #select
-        " AND $agentnums_sql",    #extra_sql  #agent virtualization
-      );
-
-      if ( $first && $last ) {
-        push @cust_main, FS::cust_main->fuzzy_search(
-          { 'last'   => $last,    #fuzzy hashref
-            'first'  => $first }, #
-          @fuzopts
-        );
+  # other cust_payby to compare this to
+  my @existing = $self->cust_payby(@check_existing);
+
+  # fields that can overwrite blanks with values, but not values with blanks
+  my @preserve = qw( paymask locationnum paystart_month paystart_year payissue payip );
+
+  my $skip_cust_payby = 0; # true if we don't need to save or reweight cust_payby
+  unless ($old) {
+    # generally, we don't want to overwrite existing cust_payby with this,
+    # but we can replace if we're only marking it auto or adding a preserved field
+    # and we can avoid saving a total duplicate or merely turning off auto
+PAYBYLOOP:
+    foreach my $cust_payby (@existing) {
+      # check fields that absolutely should not change
+      foreach my $field ($new->fields) {
+        next if grep(/^$field$/, qw( custpaybynum payby weight ) );
+        next if grep(/^$field$/, @preserve );
+        next PAYBYLOOP unless $new->get($field) eq $cust_payby->get($field);
+      }
+      # now check fields that can replace if one value is blank
+      my $replace = 0;
+      foreach my $field (@preserve) {
+        if (
+          ( $new->get($field) and !$cust_payby->get($field) ) or
+          ( $cust_payby->get($field) and !$new->get($field) )
+        ) {
+          # prevention of overwriting values with blanks happens farther below
+          $replace = 1;
+        } elsif ( $new->get($field) ne $cust_payby->get($field) ) {
+          next PAYBYLOOP;
+        }
       }
       }
-      foreach my $field ( 'last', 'company' ) {
-        push @cust_main,
-          FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
+      unless ( $replace ) {
+        # nearly identical, now check weight
+        if ($new->get('weight') eq $cust_payby->get('weight') or !$new->get('weight')) {
+          # ignore identical cust_payby, and ignore attempts to turn off auto
+          # no need to save or re-weight cust_payby (but still need to update/commit $self)
+          $skip_cust_payby = 1;
+          last PAYBYLOOP;
+        }
+        # otherwise, only change is to mark this as primary
       }
       }
-      if ( $conf->exists('address1-search') ) {
-        push @cust_main,
-          FS::cust_main->fuzzy_search( { 'address1' => $value }, @fuzopts );
+      # if we got this far, we're definitely replacing
+      $old = $cust_payby;
+      last PAYBYLOOP;
+    } #PAYBYLOOP
+  }
+
+  if ($old) {
+    $new->set( 'custpaybynum' => $old->custpaybynum );
+    # don't turn off automatic payment (but allow it to be turned on)
+    if ($new->payby =~ /^D/ and $new->payby ne $old->payby) {
+      $opt{'auto'} = 1;
+      $new->set( 'payby' => $old->payby );
+      $new->set( 'weight' => 1 );
+    }
+    # make sure we're not overwriting values with blanks
+    foreach my $field (@preserve) {
+      if ( $old->get($field) and !$new->get($field) ) {
+        $new->set( $field => $old->get($field) );
       }
       }
+    }
+  }
+
+  # only overwrite cust_main bill_location if auto
+  if ($opt{'auto'} && $opt{'bill_location'}) {
+    $self->set('bill_location' => $opt{'bill_location'});
+    my $error = $self->replace;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
 
 
+  # done with everything except reweighting and saving cust_payby
+  # still need to commit changes to cust_main and cust_location
+  if ($skip_cust_payby) {
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    return '';
+  }
+
+  # re-weight existing primary cust_pay for this payby
+  if ($opt{'auto'}) {
+    foreach my $cust_payby (@existing) {
+      # relies on cust_payby return order
+      last unless $cust_payby->payby !~ /^D/;
+      last if $cust_payby->weight > 1;
+      next if $new->custpaybynum eq $cust_payby->custpaybynum;
+      next if $cust_payby->weight < ($opt{'weight'} || 1);
+      $cust_payby->weight( $cust_payby->weight + 1 );
+      my $error = $cust_payby->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error reweighting cust_payby: $error";
+      }
     }
     }
+  }
 
 
+  # finally, save cust_payby
+  my $error = $old ? $new->replace($old) : $new->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
   }
 
   }
 
-  #eliminate duplicates
-  my %saw = ();
-  @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
 
 
-  @cust_main;
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item statuses
+
+Class method that returns the list of possible status strings for customers
+(see L<the status method|/status>).  For example:
+
+  @statuses = FS::cust_main->statuses();
 
 
+=cut
+
+sub statuses {
+  my $self = shift;
+  keys %{ $self->statuscolors };
 }
 
 }
 
-=item email_search
+=item cust_status_sql
 
 
-Accepts the following options: I<email>, the email address to search for.  The
-email address will be searched for as an email invoice destination and as an
-svc_acct account.
+Returns an SQL fragment to determine the status of a cust_main record, as a 
+string.
 
 
-#Any additional options are treated as an additional qualifier on the search
-#(i.e. I<agentnum>).
+=cut
 
 
-Returns a (possibly empty) array of FS::cust_main objects (but usually just
-none or one).
+sub cust_status_sql {
+  my $sql = 'CASE';
+  for my $status ( FS::cust_main->statuses() ) {
+    my $method = $status.'_sql';
+    $sql .= ' WHEN ('.FS::cust_main->$method.") THEN '$status'";
+  }
+  $sql .= ' END';
+  return $sql;
+}
 
 
-=cut
 
 
-sub email_search {
-  my %options = @_;
+=item prospect_sql
 
 
-  local($DEBUG) = 1;
+Returns an SQL expression identifying prospective cust_main records (customers
+with no packages ever ordered)
 
 
-  my $email = delete $options{'email'};
+=cut
 
 
-  #we're only being used by RT at the moment... no agent virtualization yet
-  #my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
+use vars qw($select_count_pkgs);
+$select_count_pkgs =
+  "SELECT COUNT(*) FROM cust_pkg
+    WHERE cust_pkg.custnum = cust_main.custnum";
 
 
-  my @cust_main = ();
+sub select_count_pkgs_sql {
+  $select_count_pkgs;
+}
 
 
-  if ( $email =~ /([^@]+)\@([^@]+)/ ) {
+sub prospect_sql {
+  " 0 = ( $select_count_pkgs ) ";
+}
 
 
-    my ( $user, $domain ) = ( $1, $2 );
+=item ordered_sql
 
 
-    warn "$me smart_search: searching for $user in domain $domain"
-      if $DEBUG;
+Returns an SQL expression identifying ordered cust_main records (customers with
+no active packages, but recurring packages not yet setup or one time charges
+not yet billed).
 
 
-    push @cust_main,
-      map $_->cust_main,
-          qsearch( {
-                     'table'     => 'cust_main_invoice',
-                     'hashref'   => { 'dest' => $email },
-                   }
-                 );
+=cut
 
 
-    push @cust_main,
-      map  $_->cust_main,
-      grep $_,
-      map  $_->cust_svc->cust_pkg,
-          qsearch( {
-                     'table'     => 'svc_acct',
-                     'hashref'   => { 'username' => $user, },
-                     'extra_sql' =>
-                       'AND ( SELECT domain FROM svc_domain
-                                WHERE svc_acct.domsvc = svc_domain.svcnum
-                            ) = '. dbh->quote($domain),
-                   }
-                 );
-  }
+sub ordered_sql {
+  FS::cust_main->none_active_sql.
+  " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->not_yet_billed_sql. " ) ";
+}
 
 
-  my %saw = ();
-  @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
+=item active_sql
 
 
-  warn "$me smart_search: found ". scalar(@cust_main). " unique customers"
-    if $DEBUG;
+Returns an SQL expression identifying active cust_main records (customers with
+active recurring packages).
 
 
-  @cust_main;
+=cut
 
 
+sub active_sql {
+  " 0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) ";
 }
 
 }
 
-=item check_and_rebuild_fuzzyfiles
+=item none_active_sql
+
+Returns an SQL expression identifying cust_main records with no active
+recurring packages.  This includes customers of status prospect, ordered,
+inactive, and suspended.
 
 =cut
 
 
 =cut
 
-sub check_and_rebuild_fuzzyfiles {
-  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
-  rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
+sub none_active_sql {
+  " 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) ";
 }
 
 }
 
-=item rebuild_fuzzyfiles
+=item inactive_sql
+
+Returns an SQL expression identifying inactive cust_main records (customers with
+no active recurring packages, but otherwise unsuspended/uncancelled).
 
 =cut
 
 
 =cut
 
-sub rebuild_fuzzyfiles {
+sub inactive_sql {
+  FS::cust_main->none_active_sql.
+  " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " ) ";
+}
 
 
-  use Fcntl qw(:flock);
+=item susp_sql
+=item suspended_sql
 
 
-  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
-  mkdir $dir, 0700 unless -d $dir;
+Returns an SQL expression identifying suspended cust_main records.
 
 
-  foreach my $fuzzy ( @fuzzyfields ) {
+=cut
 
 
-    open(LOCK,">>$dir/cust_main.$fuzzy")
-      or die "can't open $dir/cust_main.$fuzzy: $!";
-    flock(LOCK,LOCK_EX)
-      or die "can't lock $dir/cust_main.$fuzzy: $!";
 
 
-    open (CACHE,">$dir/cust_main.$fuzzy.tmp")
-      or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
+sub suspended_sql { susp_sql(@_); }
+sub susp_sql {
+  FS::cust_main->none_active_sql.
+  " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " ) ";
+}
 
 
-    foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
-      my $sth = dbh->prepare("SELECT $field FROM cust_main".
-                             " WHERE $field != '' AND $field IS NOT NULL");
-      $sth->execute or die $sth->errstr;
+=item cancel_sql
+=item cancelled_sql
 
 
-      while ( my $row = $sth->fetchrow_arrayref ) {
-        print CACHE $row->[0]. "\n";
-      }
+Returns an SQL expression identifying cancelled cust_main records.
 
 
-    } 
+=cut
 
 
-    close CACHE or die "can't close $dir/cust_main.$fuzzy.tmp: $!";
-  
-    rename "$dir/cust_main.$fuzzy.tmp", "$dir/cust_main.$fuzzy";
-    close LOCK;
-  }
+sub cancel_sql { shift->cancelled_sql(@_); }
 
 
-}
+=item uncancel_sql
+=item uncancelled_sql
 
 
-=item all_X
+Returns an SQL expression identifying un-cancelled cust_main records.
 
 =cut
 
 
 =cut
 
-sub all_X {
-  my( $self, $field ) = @_;
-  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
-  open(CACHE,"<$dir/cust_main.$field")
-    or die "can't open $dir/cust_main.$field: $!";
-  my @array = map { chomp; $_; } <CACHE>;
-  close CACHE;
-  \@array;
-}
+sub uncancelled_sql { uncancel_sql(@_); }
+sub uncancel_sql { "
+  ( 0 < ( $select_count_pkgs
+                   AND ( cust_pkg.cancel IS NULL
+                         OR cust_pkg.cancel = 0
+                       )
+        )
+    OR 0 = ( $select_count_pkgs )
+  )
+"; }
+
+=item balance_sql
 
 
-=item append_fuzzyfiles FIRSTNAME LASTNAME COMPANY ADDRESS1
+Returns an SQL fragment to retreive the balance.
 
 =cut
 
 
 =cut
 
-sub append_fuzzyfiles {
-  #my( $first, $last, $company ) = @_;
+sub balance_sql { "
+    ( SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill
+        WHERE cust_bill.custnum   = cust_main.custnum     )
+  - ( SELECT COALESCE( SUM(paid),    0 ) FROM cust_pay
+        WHERE cust_pay.custnum    = cust_main.custnum     )
+  - ( SELECT COALESCE( SUM(amount),  0 ) FROM cust_credit
+        WHERE cust_credit.custnum = cust_main.custnum     )
+  + ( SELECT COALESCE( SUM(refund),  0 ) FROM cust_refund
+        WHERE cust_refund.custnum = cust_main.custnum     )
+"; }
 
 
-  &check_and_rebuild_fuzzyfiles;
+=item balance_date_sql [ START_TIME [ END_TIME [ OPTION => VALUE ... ] ] ]
 
 
-  use Fcntl qw(:flock);
+Returns an SQL fragment to retreive the balance for this customer, optionally
+considering invoices with date earlier than START_TIME, and not
+later than END_TIME (total_owed_date minus total_unapplied_credits minus
+total_unapplied_payments).
 
 
-  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+Times are specified as SQL fragments or numeric
+UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
+L<Date::Parse> for conversion functions.  The empty string can be passed
+to disable that time constraint completely.
 
 
-  foreach my $field (@fuzzyfields) {
-    my $value = shift;
+Available options are:
 
 
-    if ( $value ) {
+=over 4
 
 
-      open(CACHE,">>$dir/cust_main.$field")
-        or die "can't open $dir/cust_main.$field: $!";
-      flock(CACHE,LOCK_EX)
-        or die "can't lock $dir/cust_main.$field: $!";
+=item unapplied_date
 
 
-      print CACHE "$value\n";
+set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
 
 
-      flock(CACHE,LOCK_UN)
-        or die "can't unlock $dir/cust_main.$field: $!";
-      close CACHE;
-    }
+=item total
 
 
-  }
+(unused.  obsolete?)
+set to true to remove all customer comparison clauses, for totals
 
 
-  1;
-}
+=item where
 
 
-=item batch_charge
+(unused.  obsolete?)
+WHERE clause hashref (elements "AND"ed together) (typically used with the total option)
 
 
-=cut
+=item join
 
 
-sub batch_charge {
-  my $param = shift;
-  #warn join('-',keys %$param);
-  my $fh = $param->{filehandle};
-  my @fields = @{$param->{fields}};
+(unused.  obsolete?)
+JOIN clause (typically used with the total option)
 
 
-  eval "use Text::CSV_XS;";
-  die $@ if $@;
+=item cutoff
 
 
-  my $csv = new Text::CSV_XS;
-  #warn $csv;
-  #warn $fh;
+An absolute cutoff time.  Payments, credits, and refunds I<applied> after this 
+time will be ignored.  Note that START_TIME and END_TIME only limit the date 
+range for invoices and I<unapplied> payments, credits, and refunds.
 
 
-  my $imported = 0;
-  #my $columns;
+=back
 
 
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
+=cut
 
 
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-  
-  #while ( $columns = $csv->getline($fh) ) {
-  my $line;
-  while ( defined($line=<$fh>) ) {
+sub balance_date_sql {
+  my( $class, $start, $end, %opt ) = @_;
 
 
-    $csv->parse($line) or do {
-      $dbh->rollback if $oldAutoCommit;
-      return "can't parse: ". $csv->error_input();
-    };
+  my $cutoff = $opt{'cutoff'};
 
 
-    my @columns = $csv->fields();
-    #warn join('-',@columns);
+  my $owed         = FS::cust_bill->owed_sql($cutoff);
+  my $unapp_refund = FS::cust_refund->unapplied_sql($cutoff);
+  my $unapp_credit = FS::cust_credit->unapplied_sql($cutoff);
+  my $unapp_pay    = FS::cust_pay->unapplied_sql($cutoff);
 
 
-    my %row = ();
-    foreach my $field ( @fields ) {
-      $row{$field} = shift @columns;
-    }
+  my $j = $opt{'join'} || '';
 
 
-    my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
-    unless ( $cust_main ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "unknown custnum $row{'custnum'}";
-    }
+  my $owed_wh   = $class->_money_table_where( 'cust_bill',   $start,$end,%opt );
+  my $refund_wh = $class->_money_table_where( 'cust_refund', $start,$end,%opt );
+  my $credit_wh = $class->_money_table_where( 'cust_credit', $start,$end,%opt );
+  my $pay_wh    = $class->_money_table_where( 'cust_pay',    $start,$end,%opt );
 
 
-    if ( $row{'amount'} > 0 ) {
-      my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
-      $imported++;
-    } elsif ( $row{'amount'} < 0 ) {
-      my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
-                                      $row{'pkg'}                         );
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
-      $imported++;
-    } else {
-      #hmm?
-    }
+  "   ( SELECT COALESCE(SUM($owed),         0) FROM cust_bill   $j $owed_wh   )
+    + ( SELECT COALESCE(SUM($unapp_refund), 0) FROM cust_refund $j $refund_wh )
+    - ( SELECT COALESCE(SUM($unapp_credit), 0) FROM cust_credit $j $credit_wh )
+    - ( SELECT COALESCE(SUM($unapp_pay),    0) FROM cust_pay    $j $pay_wh    )
+  ";
 
 
-  }
+}
 
 
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+=item unapplied_payments_date_sql START_TIME [ END_TIME ]
 
 
-  return "Empty file!" unless $imported;
+Returns an SQL fragment to retreive the total unapplied payments for this
+customer, only considering payments with date earlier than START_TIME, and
+optionally not later than END_TIME.
 
 
-  ''; #no error
+Times are specified as SQL fragments or numeric
+UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
+L<Date::Parse> for conversion functions.  The empty string can be passed
+to disable that time constraint completely.
 
 
-}
+Available options are:
+
+=cut
 
 
-=item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
+sub unapplied_payments_date_sql {
+  my( $class, $start, $end, %opt ) = @_;
 
 
-Sends a templated email notification to the customer (see L<Text::Template>).
+  my $cutoff = $opt{'cutoff'};
 
 
-OPTIONS is a hash and may include
+  my $unapp_pay    = FS::cust_pay->unapplied_sql($cutoff);
 
 
-I<from> - the email sender (default is invoice_from)
+  my $pay_where = $class->_money_table_where( 'cust_pay', $start, $end,
+                                                          'unapplied_date'=>1 );
 
 
-I<to> - comma-separated scalar or arrayref of recipients 
-   (default is invoicing_list)
+  " ( SELECT COALESCE(SUM($unapp_pay), 0) FROM cust_pay $pay_where ) ";
+}
 
 
-I<subject> - The subject line of the sent email notification
-   (default is "Notice from company_name")
+=item _money_table_where TABLE START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
 
 
-I<extra_fields> - a hashref of name/value pairs which will be substituted
-   into the template
+Helper method for balance_date_sql; name (and usage) subject to change
+(suggestions welcome).
 
 
-The following variables are vavailable in the template.
+Returns a WHERE clause for the specified monetary TABLE (cust_bill,
+cust_refund, cust_credit or cust_pay).
 
 
-I<$first> - the customer first name
-I<$last> - the customer last name
-I<$company> - the customer company
-I<$payby> - a description of the method of payment for the customer
-            # would be nice to use FS::payby::shortname
-I<$payinfo> - the account information used to collect for this customer
-I<$expdate> - the expiration of the customer payment in seconds from epoch
+If TABLE is "cust_bill" or the unapplied_date option is true, only
+considers records with date earlier than START_TIME, and optionally not
+later than END_TIME .
 
 =cut
 
 
 =cut
 
-sub notify {
-  my ($self, $template, %options) = @_;
+sub _money_table_where {
+  my( $class, $table, $start, $end, %opt ) = @_;
 
 
-  return unless $conf->exists($template);
+  my @where = ();
+  push @where, "cust_main.custnum = $table.custnum" unless $opt{'total'};
+  if ( $table eq 'cust_bill' || $opt{'unapplied_date'} ) {
+    push @where, "$table._date <= $start" if defined($start) && length($start);
+    push @where, "$table._date >  $end"   if defined($end)   && length($end);
+  }
+  push @where, @{$opt{'where'}} if $opt{'where'};
+  my $where = scalar(@where) ? 'WHERE '. join(' AND ', @where ) : '';
 
 
-  my $from = $conf->config('invoice_from', $self->agentnum)
-    if $conf->exists('invoice_from', $self->agentnum);
-  $from = $options{from} if exists($options{from});
+  $where;
 
 
-  my $to = join(',', $self->invoicing_list_emailonly);
-  $to = $options{to} if exists($options{to});
-  
-  my $subject = "Notice from " . $conf->config('company_name', $self->agentnum)
-    if $conf->exists('company_name', $self->agentnum);
-  $subject = $options{subject} if exists($options{subject});
-
-  my $notify_template = new Text::Template (TYPE => 'ARRAY',
-                                            SOURCE => [ map "$_\n",
-                                              $conf->config($template)]
-                                           )
-    or die "can't create new Text::Template object: Text::Template::ERROR";
-  $notify_template->compile()
-    or die "can't compile template: Text::Template::ERROR";
+}
 
 
-  $FS::notify_template::_template::company_name =
-    $conf->config('company_name', $self->agentnum);
-  $FS::notify_template::_template::company_address =
-    join("\n", $conf->config('company_address', $self->agentnum) ). "\n";
-
-  my $paydate = $self->paydate || '2037-12-31';
-  $FS::notify_template::_template::first = $self->first;
-  $FS::notify_template::_template::last = $self->last;
-  $FS::notify_template::_template::company = $self->company;
-  $FS::notify_template::_template::payinfo = $self->mask_payinfo;
-  my $payby = $self->payby;
-  my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
-  my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
-
-  #credit cards expire at the end of the month/year of their exp date
-  if ($payby eq 'CARD' || $payby eq 'DCRD') {
-    $FS::notify_template::_template::payby = 'credit card';
-    ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
-    $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
-    $expire_time--;
-  }elsif ($payby eq 'COMP') {
-    $FS::notify_template::_template::payby = 'complimentary account';
-  }else{
-    $FS::notify_template::_template::payby = 'current method';
-  }
-  $FS::notify_template::_template::expdate = $expire_time;
+#for dyanmic FS::$table->search in httemplate/misc/email_customers.html
+use FS::cust_main::Search;
+sub search {
+  my $class = shift;
+  FS::cust_main::Search->search(@_);
+}
 
 
-  for (keys %{$options{extra_fields}}){
-    no strict "refs";
-    ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
-  }
+=back
 
 
-  send_email(from => $from,
-             to => $to,
-             subject => $subject,
-             body => $notify_template->fill_in( PACKAGE =>
-                                                'FS::notify_template::_template'                                              ),
-            );
+=head1 SUBROUTINES
 
 
-}
+=over 4
 
 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
 
 
 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
 
@@ -8598,25 +4770,29 @@ I<extra_fields> - a hashref of name/value pairs which will be substituted
    into the template.  These values may override values mentioned below
    and those from the customer record.
 
    into the template.  These values may override values mentioned below
    and those from the customer record.
 
+I<template_text> - if present, ignores TEMPLATE_NAME and uses the provided text
+
 The following variables are available in the template instead of or in addition
 to the fields of the customer record.
 
 The following variables are available in the template instead of or in addition
 to the fields of the customer record.
 
-I<$payby> - a description of the method of payment for the customer
-            # would be nice to use FS::payby::shortname
-I<$payinfo> - the masked account information used to collect for this customer
-I<$expdate> - the expiration of the customer payment method in seconds from epoch
 I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or company_address
 
 =cut
 
 I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or company_address
 
 =cut
 
+# a lot like cust_bill::print_latex
 sub generate_letter {
   my ($self, $template, %options) = @_;
 
 sub generate_letter {
   my ($self, $template, %options) = @_;
 
-  return unless $conf->exists($template);
+  warn "Template $template does not exist" && return
+    unless $conf->exists($template) || $options{'template_text'};
+
+  my $template_source = $options{'template_text'} 
+                        ? [ $options{'template_text'} ] 
+                        : [ map "$_\n", $conf->config($template) ];
 
   my $letter_template = new Text::Template
                         ( TYPE       => 'ARRAY',
 
   my $letter_template = new Text::Template
                         ( TYPE       => 'ARRAY',
-                          SOURCE     => [ map "$_\n", $conf->config($template)],
+                          SOURCE     => $template_source,
                           DELIMITERS => [ '[@--', '--@]' ],
                         )
     or die "can't create new Text::Template object: Text::Template::ERROR";
                           DELIMITERS => [ '[@--', '--@]' ],
                         )
     or die "can't create new Text::Template object: Text::Template::ERROR";
@@ -8625,27 +4801,6 @@ sub generate_letter {
     or die "can't compile template: Text::Template::ERROR";
 
   my %letter_data = map { $_ => $self->$_ } $self->fields;
     or die "can't compile template: Text::Template::ERROR";
 
   my %letter_data = map { $_ => $self->$_ } $self->fields;
-  $letter_data{payinfo} = $self->mask_payinfo;
-
-  #my $paydate = $self->paydate || '2037-12-31';
-  my $paydate = $self->paydate =~ /^\S+$/ ? $self->paydate : '2037-12-31';
-
-  my $payby = $self->payby;
-  my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
-  my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
-
-  #credit cards expire at the end of the month/year of their exp date
-  if ($payby eq 'CARD' || $payby eq 'DCRD') {
-    $letter_data{payby} = 'credit card';
-    ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
-    $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
-    $expire_time--;
-  }elsif ($payby eq 'COMP') {
-    $letter_data{payby} = 'complimentary account';
-  }else{
-    $letter_data{payby} = 'current method';
-  }
-  $letter_data{expdate} = $expire_time;
 
   for (keys %{$options{extra_fields}}){
     $letter_data{$_} = $options{extra_fields}->{$_};
 
   for (keys %{$options{extra_fields}}){
     $letter_data{$_} = $options{extra_fields}->{$_};
@@ -8659,8 +4814,13 @@ sub generate_letter {
       $letter_data{returnaddress} = $retadd;
     } elsif ( grep /\S/, $conf->config('company_address', $self->agentnum) ) {
       $letter_data{returnaddress} =
       $letter_data{returnaddress} = $retadd;
     } elsif ( grep /\S/, $conf->config('company_address', $self->agentnum) ) {
       $letter_data{returnaddress} =
-        join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg,
-                          $conf->config('company_address', $self->agentnum)
+        join( "\n", map { s/( {2,})/'~' x length($1)/eg;
+                          s/$/\\\\\*/;
+                          $_;
+                        }
+                    ( $conf->config('company_name', $self->agentnum),
+                      $conf->config('company_address', $self->agentnum),
+                    )
         );
     } else {
       $letter_data{returnaddress} = '~';
         );
     } else {
       $letter_data{returnaddress} = '~';
@@ -8672,6 +4832,17 @@ sub generate_letter {
   $letter_data{company_name} = $conf->config('company_name', $self->agentnum);
 
   my $dir = $FS::UID::conf_dir."/cache.". $FS::UID::datasrc;
   $letter_data{company_name} = $conf->config('company_name', $self->agentnum);
 
   my $dir = $FS::UID::conf_dir."/cache.". $FS::UID::datasrc;
+
+  my $lh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
+                           DIR      => $dir,
+                           SUFFIX   => '.eps',
+                           UNLINK   => 0,
+                         ) or die "can't open temp file: $!\n";
+  print $lh $conf->config_binary('logo.eps', $self->agentnum)
+    or die "can't write temp file: $!\n";
+  close $lh;
+  $letter_data{'logo_file'} = $lh->filename;
+
   my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
                            DIR      => $dir,
                            SUFFIX   => '.tex',
   my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
                            DIR      => $dir,
                            SUFFIX   => '.tex',
@@ -8681,7 +4852,8 @@ sub generate_letter {
   $letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data );
   close $fh;
   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
   $letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data );
   close $fh;
   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
-  return $1;
+  return ($1, $letter_data{'logo_file'});
+
 }
 
 =item print_ps TEMPLATE 
 }
 
 =item print_ps TEMPLATE 
@@ -8692,8 +4864,12 @@ Returns an postscript letter filled in from TEMPLATE, as a scalar.
 
 sub print_ps {
   my $self = shift;
 
 sub print_ps {
   my $self = shift;
-  my $file = $self->generate_letter(@_);
-  FS::Misc::generate_ps($file);
+  my($file, $lfile) = $self->generate_letter(@_);
+  my $ps = FS::Misc::generate_ps($file);
+  unlink($file.'.tex');
+  unlink($lfile);
+
+  $ps;
 }
 
 =item print TEMPLATE
 }
 
 =item print TEMPLATE
@@ -8708,15 +4884,20 @@ sub queueable_print {
   my %opt = @_;
 
   my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
   my %opt = @_;
 
   my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
-    or die "invalid customer number: " . $opt{custvnum};
+    or die "invalid customer number: " . $opt{custnum};
 
 
-  my $error = $self->print( $opt{template} );
+#do not backport this change to 3.x
+#  my $error = $self->print( { 'template' => $opt{template} } );
+  my $error = $self->print( $opt{'template'} );
   die $error if $error;
 }
 
 sub print {
   my ($self, $template) = (shift, shift);
   die $error if $error;
 }
 
 sub print {
   my ($self, $template) = (shift, shift);
-  do_print [ $self->print_ps($template) ];
+  do_print(
+    [ $self->print_ps($template) ],
+    'agentnum' => $self->agentnum,
+  );
 }
 
 #these three subs should just go away once agent stuff is all config overrides
 }
 
 #these three subs should just go away once agent stuff is all config overrides
@@ -8774,7 +4955,7 @@ sub _agent_plandata {
         " ORDER BY
            CASE WHEN part_event_condition_option.optionname IS NULL
            THEN -1
         " ORDER BY
            CASE WHEN part_event_condition_option.optionname IS NULL
            THEN -1
-          ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue').
+           ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue').
         " END
           , part_event.weight".
         " LIMIT 1"
         " END
           , part_event.weight".
         " LIMIT 1"
@@ -8790,24 +4971,150 @@ sub _agent_plandata {
 
 }
 
 
 }
 
+sub process_o2m_qsearch {
+  my $self = shift;
+  my $table = shift;
+  return qsearch($table, @_) unless $table eq 'contact';
+
+  my $hashref = shift;
+  my %hash = %$hashref;
+  ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/
+    or die 'guru meditation #4343';
+
+  qsearch({ 'table'     => 'contact',
+            'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )',
+            'hashref'   => \%hash,
+            'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ).
+                           " cust_contact.custnum = $custnum "
+         });                
+}
+
+sub process_o2m_qsearchs {
+  my $self = shift;
+  my $table = shift;
+  return qsearchs($table, @_) unless $table eq 'contact';
+
+  my $hashref = shift;
+  my %hash = %$hashref;
+  ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/
+    or die 'guru meditation #2121';
+
+  qsearchs({ 'table'     => 'contact',
+             'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )',
+             'hashref'   => \%hash,
+             'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ).
+                            " cust_contact.custnum = $custnum "
+          });                
+}
+
+=item queued_bill 'custnum' => CUSTNUM [ , OPTION => VALUE ... ]
+
+Subroutine (not a method), designed to be called from the queue.
+
+Takes a list of options and values.
+
+Pulls up the customer record via the custnum option and calls bill_and_collect.
+
+=cut
+
 sub queued_bill {
 sub queued_bill {
-  ## actual sub, not a method, designed to be called from the queue.
-  ## sets up the customer, and calls the bill_and_collect
   my (%args) = @_; #, ($time, $invoice_time, $check_freq, $resetup) = @_;
   my (%args) = @_; #, ($time, $invoice_time, $check_freq, $resetup) = @_;
+
   my $cust_main = qsearchs( 'cust_main', { custnum => $args{'custnum'} } );
   my $cust_main = qsearchs( 'cust_main', { custnum => $args{'custnum'} } );
-      $cust_main->bill_and_collect(
-        %args,
-      );
+  warn 'bill_and_collect custnum#'. $cust_main->custnum. "\n";#log custnum w/pid
+
+  #without this errors don't get rolled back
+  $args{'fatal'} = 1; # runs from job queue, will be caught
+
+  $cust_main->bill_and_collect( %args );
+}
+
+=item queued_collect 'custnum' => CUSTNUM [ , OPTION => VALUE ... ]
+
+Like queued_bill, but instead of C<bill_and_collect>, just runs the 
+C<collect> part.  This is used in batch tax calculation, where invoice 
+generation and collection events have to be completely separated.
+
+=cut
+
+sub queued_collect {
+  my (%args) = @_;
+  my $cust_main = FS::cust_main->by_key($args{'custnum'});
+  
+  $cust_main->collect(%args);
 }
 
 }
 
+sub process_bill_and_collect {
+  my $job = shift;
+  my $param = shift;
+  my $cust_main = qsearchs( 'cust_main', { custnum => $param->{'custnum'} } )
+      or die "custnum '$param->{custnum}' not found!\n";
+  $param->{'job'}   = $job;
+  $param->{'fatal'} = 1; # runs from job queue, will be caught
+  $param->{'retry'} = 1;
+
+  $cust_main->bill_and_collect( %$param );
+}
+
+#starting to take quite a while for big dbs
+#   (JRNL: journaled so it only happens once per database)
+# - seq scan of h_cust_main (yuck), but not going to index paycvv, so
+# JRNL seq scan of cust_main on signupdate... index signupdate?  will that help?
+# JRNL seq scan of cust_main on paydate... index on substrings?  maybe set an
+# JRNL seq scan of cust_main on payinfo.. certainly not going toi ndex that...
+# JRNL leading/trailing spaces in first, last, company
+# JRNL migrate to cust_payby
+# - otaker upgrade?  journal and call it good?  (double check to make sure
+#    we're not still setting otaker here)
+#
+#only going to get worse with new location stuff...
+
 sub _upgrade_data { #class method
   my ($class, %opts) = @_;
 
 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;
+  my @statements = ();
+
+  #this seems to be the only expensive one.. why does it take so long?
+  unless ( FS::upgrade_journal->is_done('cust_main__signupdate') ) {
+    push @statements,
+      'UPDATE cust_main SET signupdate = (SELECT signupdate FROM h_cust_main WHERE signupdate IS NOT NULL AND h_cust_main.custnum = cust_main.custnum ORDER BY historynum DESC LIMIT 1) WHERE signupdate IS NULL';
+    FS::upgrade_journal->set_done('cust_main__signupdate');
+  }
+
+  my $t = time;
+  foreach my $sql ( @statements ) {
+    my $sth = dbh->prepare($sql) or die dbh->errstr;
+    $sth->execute or die $sth->errstr;
+    #warn ( (time - $t). " seconds\n" );
+    #$t = time;
+  }
 
   local($ignore_expired_card) = 1;
 
   local($ignore_expired_card) = 1;
+  local($ignore_banned_card) = 1;
+  local($skip_fuzzyfiles) = 1;
+  local($import) = 1; #prevent automatic geocoding (need its own variable?)
+
+  FS::cust_main::Location->_upgrade_data(%opts);
+
+  unless ( FS::upgrade_journal->is_done('cust_main__trimspaces') ) {
+
+    foreach my $cust_main ( qsearch({
+      'table'     => 'cust_main', 
+      'hashref'   => {},
+      'extra_sql' => 'WHERE '.
+                       join(' OR ',
+                         map "$_ LIKE ' %' OR $_ LIKE '% ' OR $_ LIKE '%  %'",
+                           qw( first last company )
+                       ),
+    }) ) {
+      my $error = $cust_main->replace;
+      die $error if $error;
+    }
+
+    FS::upgrade_journal->set_done('cust_main__trimspaces');
+
+  }
+
   $class->_upgrade_otaker(%opts);
 
 }
   $class->_upgrade_otaker(%opts);
 
 }
@@ -8829,13 +5136,8 @@ card types.
 
 No multiple currency support (probably a larger project than just this module).
 
 
 No multiple currency support (probably a larger project than just this module).
 
-payinfo_masked false laziness with cust_pay.pm and cust_refund.pm
-
 Birthdates rely on negative epoch values.
 
 Birthdates rely on negative epoch values.
 
-The payby for card/check batches is broken.  With mixed batching, bad
-things will happen.
-
 B<collect> I<invoice_time> should be renamed I<time>, like B<bill>.
 
 =head1 SEE ALSO
 B<collect> I<invoice_time> should be renamed I<time>, like B<bill>.
 
 =head1 SEE ALSO