stray closing /TABLE in the no-ticket case
[freeside.git] / FS / FS / cust_main.pm
index b2949a5..55a31f8 100644 (file)
@@ -4,12 +4,14 @@ use base qw( FS::cust_main::Packages
              FS::cust_main::NationalID
              FS::cust_main::Billing
              FS::cust_main::Billing_Realtime
              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::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::cust_main::API
-             FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
+             FS::otaker_Mixin FS::cust_main_Mixin
              FS::geocode_Mixin FS::Quotable_Mixin FS::Sales_Mixin
              FS::o2m_Common
              FS::Record
              FS::geocode_Mixin FS::Quotable_Mixin FS::Sales_Mixin
              FS::o2m_Common
              FS::Record
@@ -18,20 +20,21 @@ use base qw( FS::cust_main::Packages
 require 5.006;
 use strict;
 use Carp;
 require 5.006;
 use strict;
 use Carp;
+use Try::Tiny;
 use Scalar::Util qw( blessed );
 use Scalar::Util qw( blessed );
-use Time::Local qw(timelocal);
-use Data::Dumper;
+use List::Util qw(min);
 use Tie::IxHash;
 use Tie::IxHash;
-use Digest::MD5 qw(md5_base64);
+use File::Temp; #qw( tempfile );
+use Data::Dumper;
+use Time::Local qw(timelocal);
 use Date::Format;
 #use Date::Manip;
 use Date::Format;
 #use Date::Manip;
-use File::Temp; #qw( tempfile );
+use Email::Address;
 use Business::CreditCard 0.28;
 use Business::CreditCard 0.28;
-use Locale::Country;
 use FS::UID qw( dbh driver_name );
 use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
 use FS::Cursor;
 use FS::UID qw( dbh driver_name );
 use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
 use FS::Cursor;
-use FS::Misc qw( generate_email send_email generate_ps do_print );
+use FS::Misc qw( generate_ps do_print money_pretty card_types );
 use FS::Msgcat qw(gettext);
 use FS::CurrentUser;
 use FS::TicketSystem;
 use FS::Msgcat qw(gettext);
 use FS::CurrentUser;
 use FS::TicketSystem;
@@ -69,11 +72,13 @@ use FS::agent_payment_gateway;
 use FS::banned_pay;
 use FS::cust_main_note;
 use FS::cust_attachment;
 use FS::banned_pay;
 use FS::cust_main_note;
 use FS::cust_attachment;
-use FS::contact;
+use FS::cust_contact;
 use FS::Locales;
 use FS::upgrade_journal;
 use FS::sales;
 use FS::cust_payby;
 use FS::Locales;
 use FS::upgrade_journal;
 use FS::sales;
 use FS::cust_payby;
+use FS::contact;
+use FS::reason;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -90,18 +95,21 @@ our $skip_fuzzyfiles = 0;
 
 our $ucfirst_nowarn = 0;
 
 
 our $ucfirst_nowarn = 0;
 
+#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 @encrypted_fields = ('payinfo', 'paycvv');
 sub nohistory_fields { ('payinfo', 'paycvv'); }
 
-our @paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
-
 our $conf;
 our $conf;
+our $default_agent_custid;
+our $custnum_display_length;
 #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;
 #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');
+  $ignore_invalid_card    = $conf->exists('allow_invalid_cards');
+  $default_agent_custid   = $conf->exists('cust_main-default_agent_custid');
+  $custnum_display_length = $conf->config('cust_main-custnum-display_length');
 };
 
 sub _cache {
 };
 
 sub _cache {
@@ -277,6 +285,10 @@ Allow self-service editing of ticket subjects, empty or 'Y'
 
 Do not call, empty or 'Y'
 
 
 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
@@ -319,17 +331,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:
-
-  $cust_main->insert( {}, [ $email, 'POST' ] );
+INVOICING_LIST_ARYREF: No longer supported.
 
 Currently available options are: I<depend_jobnum>, I<noexport>,
 
 Currently available options are: I<depend_jobnum>, I<noexport>,
-I<tax_exemption> and I<prospectnum>.
+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).
@@ -346,15 +351,27 @@ created and inserted.
 
 If I<prospectnum> is set, moves contacts and locations from that prospect.
 
 
 If I<prospectnum> is set, moves contacts and locations from that prospect.
 
-If I<contact> is set to an arrayref of FS::contact objects, inserts those
-new contacts with this new customer.
+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"
@@ -376,7 +393,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('');
 
@@ -398,10 +415,10 @@ sub insert {
 
     $payby = 'PREP' if $amount;
 
 
     $payby = 'PREP' if $amount;
 
-  } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|PPAL)$/ ) {
+  } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|MCHK|PPAL)$/ ) {
 
     $payby = $1;
 
     $payby = $1;
-    $self->payby('BILL');
+    $self->payby(''); #'BILL');
     $amount = $self->paid;
 
   }
     $amount = $self->paid;
 
   }
@@ -458,7 +475,8 @@ 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";
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     #return "inserting cust_main record (transaction rolled back): $error";
@@ -481,19 +499,6 @@ sub insert {
     }
   }
 
     }
   }
 
-  warn "  setting invoicing list\n"
-    if $DEBUG > 1;
-
-  if ( $invoicing_list ) {
-    $error = $self->check_invoicing_list( $invoicing_list );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      #return "checking invoicing_list (transaction rolled back): $error";
-      return $error;
-    }
-    $self->invoicing_list( $invoicing_list );
-  }
-
   warn "  setting customer tags\n"
     if $DEBUG > 1;
 
   warn "  setting customer tags\n"
     if $DEBUG > 1;
 
@@ -527,11 +532,24 @@ sub insert {
       return $error;
     }
 
       return $error;
     }
 
-    my @contact = $prospect_main->contact;
+    foreach my $prospect_contact ( $prospect_main->prospect_contact ) {
+      my $cust_contact = new FS::cust_contact {
+        'custnum' => $self->custnum,
+        'invoice_dest' => 'Y', # invoice_dest currently not set for prospect contacts
+        map { $_ => $prospect_contact->$_() } qw( contactnum classnum comment )
+      };
+      my $error =  $cust_contact->insert
+                || $prospect_contact->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
     my @cust_location = $prospect_main->cust_location;
     my @qual = $prospect_main->qual;
 
     my @cust_location = $prospect_main->cust_location;
     my @qual = $prospect_main->qual;
 
-    foreach my $r ( @contact, @cust_location, @qual ) {
+    foreach my $r ( @cust_location, @qual ) {
       $r->prospectnum('');
       $r->custnum($self->custnum);
       my $error = $r->replace;
       $r->prospectnum('');
       $r->custnum($self->custnum);
       my $error = $r->replace;
@@ -540,11 +558,67 @@ sub insert {
         return $error;
       }
     }
         return $error;
       }
     }
+    # since we set invoice_dest on all migrated prospect contacts (for now),
+    # don't process invoicing_list.
+    delete $options{'invoicing_list'};
+    $invoicing_list = undef;
+  }
+
+  warn "  setting contacts\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->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;
+        }
+      }
+    }
+
+    if ( $email ) {
+
+      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;
+      }
+
+    }
 
   }
 
 
   }
 
-  my $contact = delete $options{'contact'};
-  if ( $contact ) {
+  if ( my $contact = delete $options{'contact'} ) {
 
     foreach my $c ( @$contact ) {
       $c->custnum($self->custnum);
 
     foreach my $c ( @$contact ) {
       $c->custnum($self->custnum);
@@ -556,6 +630,45 @@ sub insert {
 
     }
 
 
     }
 
+  } 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;
+      }
+    }
+
+  } 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;
+    }
+
   }
 
   warn "  setting cust_main_exemption\n"
   }
 
   warn "  setting cust_main_exemption\n"
@@ -1108,10 +1221,9 @@ sub delete {
 
   #cust_tax_adjustment in financials?
   #cust_pay_pending?  ouch
 
   #cust_tax_adjustment in financials?
   #cust_pay_pending?  ouch
-  #cust_recon?
   foreach my $table (qw(
     cust_main_invoice cust_main_exemption cust_tag cust_attachment contact
   foreach my $table (qw(
     cust_main_invoice cust_main_exemption cust_tag cust_attachment contact
-    cust_location cust_main_note cust_tax_adjustment
+    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 } ) ) {
     cust_pay_void cust_pay_batch queue cust_tax_exempt
   )) {
     foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
@@ -1199,232 +1311,6 @@ sub delete {
 
 }
 
 
 }
 
-=item merge NEW_CUSTNUM [ , OPTION => VALUE ... ]
-
-This merges this customer into the provided new custnum, and then deletes the
-customer.  If there is an error, returns the error, otherwise returns false.
-
-The source customer's name, company name, phone numbers, agent,
-referring customer, customer class, advertising source, order taker, and
-billing information (except balance) are discarded.
-
-All packages are moved to the target customer.  Packages with package locations
-are preserved.  Packages without package locations are moved to a new package
-location with the source customer's service/shipping address.
-
-All invoices, statements, payments, credits and refunds are moved to the target
-customer.  The source customer's balance is added to the target customer.
-
-All notes, attachments, tickets and customer tags are moved to the target
-customer.
-
-Change history is not currently moved.
-
-=cut
-
-sub merge {
-  my( $self, $new_custnum, %opt ) = @_;
-
-  return "Can't merge a customer into self" if $self->custnum == $new_custnum;
-
-  my $new_cust_main = qsearchs( 'cust_main', { 'custnum' => $new_custnum } )
-    or return "Invalid new customer number: $new_custnum";
-
-  return 'Access denied: "Merge customer across agents" access right required to merge into a customer of a different agent'
-    if $self->agentnum != $new_cust_main->agentnum 
-    && ! $FS::CurrentUser::CurrentUser->access_right('Merge customer across agents');
-
-  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 ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) {
-     $dbh->rollback if $oldAutoCommit;
-     return "Can't merge a master agent customer";
-  }
-
-  #use FS::access_user
-  if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) {
-     $dbh->rollback if $oldAutoCommit;
-     return "Can't merge a master employee customer";
-  }
-
-  if ( qsearch('cust_pay_pending', { 'custnum' => $self->custnum,
-                                     'status'  => { op=>'!=', value=>'done' },
-                                   }
-              )
-  ) {
-     $dbh->rollback if $oldAutoCommit;
-     return "Can't merge a customer with pending payments";
-  }
-
-  tie my %financial_tables, 'Tie::IxHash',
-    'cust_bill'         => 'invoices',
-    'cust_bill_void'    => 'voided invoices',
-    'cust_statement'    => 'statements',
-    'cust_credit'       => 'credits',
-    'cust_credit_void'  => 'voided credits',
-    'cust_pay'          => 'payments',
-    'cust_pay_void'     => 'voided payments',
-    'cust_refund'       => 'refunds',
-  ;
-   
-  foreach my $table ( keys %financial_tables ) {
-
-    my @records = $self->$table();
-
-    foreach my $record ( @records ) {
-      $record->custnum($new_custnum);
-      my $error = $record->replace;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "Error merging ". $financial_tables{$table}. ": $error\n";
-      }
-    }
-
-  }
-
-  my $name = $self->ship_name; #?
-
-  my $locationnum = '';
-  foreach my $cust_pkg ( $self->all_pkgs ) {
-    $cust_pkg->custnum($new_custnum);
-
-    unless ( $cust_pkg->locationnum ) {
-      unless ( $locationnum ) {
-        my $cust_location = new FS::cust_location {
-          $self->location_hash,
-          'custnum' => $new_custnum,
-        };
-        my $error = $cust_location->insert;
-        if ( $error ) {
-          $dbh->rollback if $oldAutoCommit;
-          return $error;
-        }
-        $locationnum = $cust_location->locationnum;
-      }
-      $cust_pkg->locationnum($locationnum);
-    }
-
-    my $error = $cust_pkg->replace;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }
-
-    # add customer (ship) name to svc_phone.phone_name if blank
-    my @cust_svc = $cust_pkg->cust_svc;
-    foreach my $cust_svc (@cust_svc) {
-      my($label, $value, $svcdb) = $cust_svc->label;
-      next unless $svcdb eq 'svc_phone';
-      my $svc_phone = $cust_svc->svc_x;
-      next if $svc_phone->phone_name;
-      $svc_phone->phone_name($name);
-      my $error = $svc_phone->replace;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
-    }
-
-  }
-
-  #not considered:
-  # cust_tax_exempt (texas tax exemptions)
-  # cust_recon (some sort of not-well understood thing for OnPac)
-
-  #these are moved over
-  foreach my $table (qw(
-    cust_tag cust_location contact cust_attachment cust_main_note
-    cust_tax_adjustment cust_pay_batch queue
-  )) {
-    foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
-      $record->custnum($new_custnum);
-      my $error = $record->replace;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
-    }
-  }
-
-  #these aren't preserved
-  foreach my $table (qw(
-    cust_main_exemption cust_main_invoice
-  )) {
-    foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
-      my $error = $record->delete;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
-    }
-  }
-
-
-  my $sth = $dbh->prepare(
-    'UPDATE cust_main SET referral_custnum = ? WHERE referral_custnum = ?'
-  ) or do {
-    my $errstr = $dbh->errstr;
-    $dbh->rollback if $oldAutoCommit;
-    return $errstr;
-  };
-  $sth->execute($new_custnum, $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(
-      'UPDATE Links SET Target = ? WHERE Target = ?'
-    ) or do {
-      my $errstr = $ticket_dbh->errstr;
-      $dbh->rollback if $oldAutoCommit;
-      return $errstr;
-    };
-    $ticket_sth->execute('freeside://freeside/cust_main/'.$new_custnum,
-                         'freeside://freeside/cust_main/'.$self->custnum)
-      or do {
-        my $errstr = $ticket_sth->errstr;
-        $dbh->rollback if $oldAutoCommit;
-        return $errstr;
-      };
-
-  }
-
-  #delete the customer record
-
-  my $error = $self->delete;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
-
-}
-
 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
@@ -1434,19 +1320,25 @@ 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.
 
 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 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:
-
-  $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
+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.
 
 
-Currently available options are: I<tax_exemption>.
+Currently available options are: I<tax_exemption>, I<cust_payby_params>, 
+I<contact_params>, I<invoicing_list>.
 
 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 or a hashref
 of tax names and exemption numbers.  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
 
 sub replace {
 =cut
 
 sub replace {
@@ -1462,13 +1354,10 @@ 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)$/
 
   local($ignore_expired_card) = 1
     if $old->payby  =~ /^(CARD|DCRD)$/
@@ -1480,6 +1369,14 @@ sub replace {
          || $old->payby  =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
 
          || $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
   return "Invoicing locale is required"
     if $old->locale
     && ! $self->locale
@@ -1497,8 +1394,8 @@ sub replace {
   my $dbh = dbh;
 
   for my $l (qw(bill_location ship_location)) {
   my $dbh = dbh;
 
   for my $l (qw(bill_location ship_location)) {
-    my $old_loc = $old->$l;
-    my $new_loc = $self->$l;
+    #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);
 
     # find the existing location if there is one
     $new_loc->set('custnum' => $self->custnum);
@@ -1510,43 +1407,147 @@ sub replace {
     $self->set($l.'num', $new_loc->locationnum);
   } #for $l
 
     $self->set($l.'num', $new_loc->locationnum);
   } #for $l
 
-  # replace the customer record
-  my $error = $self->SUPER::replace($old);
-
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
+  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;
   }
 
   }
 
-  # 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;
+  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;
+      }
     }
     }
-  }
-  # 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;
+
+    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;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return $error;
+      return "$error (adding email address $email)";
     }
     }
+
   }
 
   }
 
-  if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
-    my $invoicing_list = shift @param;
-    $error = $self->check_invoicing_list( $invoicing_list );
+  # replace the customer record
+  my $error = $self->SUPER::replace($old);
+
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  # 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 );
   }
 
   if ( $self->exists('tagnum') ) { #so we don't delete these on edit by accident
   }
 
   if ( $self->exists('tagnum') ) { #so we don't delete these on edit by accident
@@ -1571,8 +1572,6 @@ sub replace {
 
   }
 
 
   }
 
-  my %options = @param;
-
   my $tax_exemption = delete $options{'tax_exemption'};
   if ( $tax_exemption ) {
 
   my $tax_exemption = delete $options{'tax_exemption'};
   if ( $tax_exemption ) {
 
@@ -1615,21 +1614,37 @@ sub replace {
 
   }
 
 
   }
 
-  if ( $self->payby =~ /^(CARD|CHEK|LECB)$/
-       && ( ( $self->get('payinfo') ne $old->get('payinfo')
-              && $self->get('payinfo') !~ /^99\d{14}$/ 
-            )
-            || grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
-          )
-     )
-  {
+  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
 
 
-    # card/check/lec info has changed, want to retry realtime_ invoice events
-    my $error = $self->retry_realtime;
+    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 ) {
@@ -1761,12 +1776,17 @@ sub check {
     || $self->ut_floatn('credit_limit')
     || $self->ut_numbern('billday')
     || $self->ut_numbern('prorate_day')
     || $self->ut_floatn('credit_limit')
     || $self->ut_numbern('billday')
     || $self->ut_numbern('prorate_day')
+    || $self->ut_flag('force_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_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_textn('po_number')
+    || $self->ut_enum('complimentary', [ '', 'Y' ])
+    || $self->ut_flag('invoice_ship_address')
+    || $self->ut_flag('invoice_dest')
   ;
 
   foreach (qw(company ship_company)) {
   ;
 
   foreach (qw(company ship_company)) {
@@ -1811,6 +1831,11 @@ 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('');
+  }
+
   # cust_main_county verification now handled by cust_location check
 
   $error =
   # cust_main_county verification now handled by cust_location check
 
   $error =
@@ -1841,249 +1866,60 @@ sub check {
   
   }
 
   
   }
 
-  ### start of stuff moved to cust_payby
-  # then mostly kept here to support upgrades (can remove in 5.x)
-  #  but modified to allow everything to be empty
-
-  if ( $self->payby ) {
-    FS::payby->can_payby($self->table, $self->payby)
-      or return "Illegal payby: ". $self->payby;
-  } else {
-    $self->payby('');
-  }
+  return "Please select an invoicing locale"
+    if ! $self->locale
+    && ! $self->custnum
+    && $conf->exists('cust_main-require_locale');
 
 
-  $error =    $self->ut_numbern('paystart_month')
-           || $self->ut_numbern('paystart_year')
-           || $self->ut_numbern('payissue')
-           || $self->ut_textn('paytype')
-  ;
-  return $error if $error;
+  return "Please select a customer class"
+    if ! $self->classnum
+    && $conf->exists('cust_main-require_classnum');
 
 
-  if ( $self->payip eq '' ) {
-    $self->payip('');
-  } else {
-    $error = $self->ut_ip('payip');
-    return $error if $error;
+  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 it is encrypted and the private key is not availaible then we can't
-  # check the credit card.
-  my $check_payinfo = ! $self->is_encrypted($self->payinfo);
-
-  # Need some kind of global flag to accept invalid cards, for testing
-  # on scrubbed data.
-  if ( !$import && !$ignore_invalid_card && $check_payinfo && 
-    $self->payby =~ /^(CARD|DCRD)$/ ) {
-
-    my $payinfo = $self->payinfo;
-    $payinfo =~ s/\D//g;
-    $payinfo =~ /^(\d{13,16}|\d{8,9})$/
-      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 $self->payinfo !~ /^99\d{14}$/ #token
-      && cardtype($self->payinfo) eq "Unknown";
-
-    unless ( $ignore_banned_card ) {
-      my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
-      if ( $ban ) {
-        if ( $ban->bantype eq 'warn' ) {
-          #or others depending on value of $ban->reason ?
-          return '_duplicate_card'.
-                 ': disabled from'. time2str('%a %h %o at %r', $ban->_date).
-                 ' until '.         time2str('%a %h %o at %r', $ban->_end_date).
-                 ' (ban# '. $ban->bannum. ')'
-            unless $self->override_ban_warn;
-        } else {
-          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;
-
-      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 ( !$ignore_invalid_card && $check_payinfo && 
-    $self->payby =~ /^(CHEK|DCHK)$/ ) {
-
-    my $payinfo = $self->payinfo;
-    $payinfo =~ s/[^\d\@\.]//g;
-    if ( $conf->config('echeck-country') eq 'CA' ) {
-      $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/
-        or return 'invalid echeck account@branch.bank';
-      $payinfo = "$1\@$2.$3";
-    } elsif ( $conf->config('echeck-country') eq 'US' ) {
-      $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
-      $payinfo = "$1\@$2";
-    } else {
-      $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
-      $payinfo = "$1\@$2";
-    }
-    $self->payinfo($payinfo);
-    $self->paycvv('');
-
-    unless ( $ignore_banned_card ) {
-      my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
-      if ( $ban ) {
-        if ( $ban->bantype eq 'warn' ) {
-          #or others depending on value of $ban->reason ?
-          return '_duplicate_ach' unless $self->override_ban_warn;
-        } else {
-          return 'Banned ACH account: banned on '.
-                 time2str('%a %h %o at %r', $ban->_date).
-                 ' by '. $ban->otaker.
-                 ' (ban# '. $ban->bannum. ')';
-        }
-      }
-    }
-
-  } elsif ( $self->payby eq 'LECB' ) {
-
-    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('');
-
-  } elsif ( $self->payby eq 'BILL' ) {
+  $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
 
 
-    $error = $self->ut_textn('payinfo');
-    return "Illegal P.O. number: ". $self->payinfo if $error;
-    $self->paycvv('');
+  warn "$me check AFTER: \n". $self->_dump
+    if $DEBUG > 2;
 
 
-  } elsif ( $self->payby eq 'COMP' ) {
+  $self->SUPER::check;
+}
 
 
-    my $curuser = $FS::CurrentUser::CurrentUser;
-    if (    ! $self->custnum
-         && ! $curuser->access_right('Complimentary customer')
-       )
-    {
-      return "You are not permitted to create complimentary accounts."
-    }
+sub check_payinfo_cardtype {
+  my $self = shift;
 
 
-    $error = $self->ut_textn('payinfo');
-    return "Illegal comp account issuer: ". $self->payinfo if $error;
-    $self->paycvv('');
+  return '' unless $self->payby =~ /^(CARD|DCRD)$/;
 
 
-  } elsif ( $self->payby eq 'PREPAY' ) {
+  my $payinfo = $self->payinfo;
+  $payinfo =~ s/\D//g;
 
 
-    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('');
+  return '' if $self->tokenized($payinfo); #token
 
 
-  }
+  my %bop_card_types = map { $_=>1 } values %{ card_types() };
+  my $cardtype = cardtype($payinfo);
 
 
-  if ( $self->paydate eq '' || $self->paydate eq '-' ) {
-    return "Expiration date required"
-      # shouldn't payinfo_check do this?
-      unless ! $self->payby
-            || $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD|PPAL)$/;
-    $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;
-    }
-    $m = sprintf('%02d',$m);
-    $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 ) );
-  }
+  return "$cardtype not accepted" unless $bop_card_types{$cardtype};
 
 
-  if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
-       ( ! $conf->exists('require_cardname')
-         || $self->payby !~ /^(CARD|DCRD)$/  ) 
-  ) {
-    $self->payname( $self->first. " ". $self->getfield('last') );
-  } else {
+  '';
 
 
-    if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
-      $self->payname =~ /^([\w \,\.\-\']*)$/
-        or return gettext('illegal_name'). " payname: ". $self->payname;
-      $self->payname($1);
-    } else {
-      $self->payname =~ /^([\w \,\.\-\'\&]*)$/
-        or return gettext('illegal_name'). " payname: ". $self->payname;
-      $self->payname($1);
-    }
+}
 
 
-  }
+=item replace_check
 
 
-  ### end of stuff moved to cust_payby
+Additional checks for replace only.
 
 
-  return "Please select an invoicing locale"
-    if ! $self->locale
-    && ! $self->custnum
-    && $conf->exists('cust_main-require_locale');
+=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->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
-
-  warn "$me check AFTER: \n". $self->_dump
-    if $DEBUG > 2;
-
-  $self->SUPER::check;
+  return '';
 }
 
 =item addr_fields 
 }
 
 =item addr_fields 
@@ -2133,35 +1969,66 @@ Returns all locations (see L<FS::cust_location>) for this customer.
 
 sub cust_location {
   my $self = shift;
 
 sub cust_location {
   my $self = shift;
-  qsearch('cust_location', { 'custnum'     => $self->custnum,
-                             'prospectnum' => '' } );
+  qsearch({
+    'table'   => 'cust_location',
+    'hashref' => { 'custnum'     => $self->custnum,
+                   'prospectnum' => '',
+                 },
+    'order_by' => 'ORDER BY country, LOWER(state), LOWER(city), LOWER(county), LOWER(address1), LOWER(address2)',
+  });
 }
 
 =item cust_contact
 
 }
 
 =item cust_contact
 
-Returns all contacts (see L<FS::contact>) for this customer.
+Returns all contact associations (see L<FS::cust_contact>) for this customer.
 
 =cut
 
 
 =cut
 
-#already used :/ sub contact {
 sub cust_contact {
   my $self = shift;
 sub cust_contact {
   my $self = shift;
-  qsearch('contact', { 'custnum' => $self->custnum } );
+  qsearch('cust_contact', { 'custnum' => $self->custnum } );
 }
 
 }
 
-=item cust_payby
+=item cust_payby PAYBY
 
 Returns all payment methods (see L<FS::cust_payby>) 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
 
 sub cust_payby {
   my $self = shift;
 =cut
 
 sub cust_payby {
   my $self = shift;
-  qsearch({
+  my @payby = @_;
+  my $search = {
     'table'    => 'cust_payby',
     'hashref'  => { 'custnum' => $self->custnum },
     'table'    => 'cust_payby',
     'hashref'  => { 'custnum' => $self->custnum },
-    'order_by' => 'ORDER BY weight ASC',
-  });
+    'order_by' => "ORDER BY payby IN ('CARD','CHEK') DESC, weight ASC",
+  };
+  $search->{'extra_sql'} = ' AND payby IN ( '.
+                               join(',', map dbh->quote($_), @payby).
+                             ' ) '
+    if @payby;
+
+  qsearch($search);
+}
+
+=item has_cust_payby_auto
+
+Returns true if customer has an automatic payment method ('CARD' or 'CHEK')
+
+=cut
+
+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',
+  }) );
+
 }
 
 =item unsuspend
 }
 
 =item unsuspend
@@ -2271,67 +2138,179 @@ sub suspend_unless_pkgpart {
 =item cancel [ OPTION => VALUE ... ]
 
 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
 =item cancel [ OPTION => VALUE ... ]
 
 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
+The cancellation time will be now.
 
 
-Available options are:
+=back
+
+Always returns a list: an empty list on success or a list of errors.
+
+=cut
+
+sub cancel {
+  my $self = shift;
+  my %opt = @_;
+  warn "$me cancel called on customer ". $self->custnum. " with options ".
+       join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n"
+    if $DEBUG;
+  my @pkgs = $self->ncancelled_pkgs;
+
+  $self->cancel_pkgs( %opt, 'cust_pkg' => \@pkgs );
+}
+
+=item cancel_pkgs OPTIONS
+
+Cancels a specified list of packages. OPTIONS can include:
 
 =over 4
 
 
 =over 4
 
+=item cust_pkg - an arrayref of the packages. Required.
+
+=item time - the cancellation time, used to calculate final bills and
+unused-time credits if any. Will be passed through to the bill() and
+FS::cust_pkg::cancel() methods.
+
 =item quiet - can be set true to supress email cancellation notices.
 
 =item quiet - can be set true to supress email cancellation notices.
 
-=item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason.  The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
+=item reason - can be set to a cancellation reason (see L<FS:reason>), either a
+reasonnum of an existing reason, or passing a hashref will create a new reason.
+The hashref should have the following keys:
+typenum - Reason type (see L<FS::reason_type>)
+reason - Text of the new reason.
+
+=item cust_pkg_reason - can be an arrayref of L<FS::cust_pkg_reason> objects
+for the individual packages, parallel to the C<cust_pkg> argument. The
+reason and reason_otaker arguments will be taken from those objects.
 
 =item ban - can be set true to ban this customer's credit card or ACH information, if present.
 
 =item nobill - can be set true to skip billing if it might otherwise be done.
 
 
 =item ban - can be set true to ban this customer's credit card or ACH information, if present.
 
 =item nobill - can be set true to skip billing if it might otherwise be done.
 
-=back
-
-Always returns a list: an empty list on success or a list of errors.
-
 =cut
 
 =cut
 
-# nb that dates are not specified as valid options to this method
-
-sub cancel {
+sub cancel_pkgs {
   my( $self, %opt ) = @_;
 
   my( $self, %opt ) = @_;
 
-  warn "$me cancel called on customer ". $self->custnum. " with options ".
-       join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n"
-    if $DEBUG;
+  # we're going to cancel services, which is not reversible
+  die "cancel_pkgs cannot be run inside a transaction"
+    if $FS::UID::AutoCommit == 0;
+
+  local $FS::UID::AutoCommit = 0;
 
   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'} ) {
 
 
-    #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);
+    foreach my $cust_payby ( $self->cust_payby ) {
 
 
-    my $ban = new FS::banned_pay $self->_new_banned_pay_hashref;
-    my $error = $ban->insert;
-    return ( $error ) if $error;
+      #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);
+
+      my $ban = new FS::banned_pay $cust_payby->_new_banned_pay_hashref;
+      my $error = $ban->insert;
+      if ($error) {
+        dbh->rollback;
+        return ( $error );
+      }
+
+    }
 
   }
 
 
   }
 
-  my @pkgs = $self->ncancelled_pkgs;
+  my @pkgs = @{ delete $opt{'cust_pkg'} };
+  my $cancel_time = $opt{'time'} || time;
 
 
+  # bill all packages first, so we don't lose usage, service counts for
+  # bulk billing, etc.
   if ( !$opt{nobill} && $conf->exists('bill_usage_on_cancel') ) {
     $opt{nobill} = 1;
   if ( !$opt{nobill} && $conf->exists('bill_usage_on_cancel') ) {
     $opt{nobill} = 1;
-    my $error = $self->bill( pkg_list => [ @pkgs ], cancel => 1 );
-    warn "Error billing during cancel, custnum ". $self->custnum. ": $error"
-      if $error;
+    my $error = $self->bill( 'pkg_list' => [ @pkgs ],
+                             'cancel'   => 1,
+                             'time'     => $cancel_time );
+    if ($error) {
+      warn "Error billing during cancel, custnum ". $self->custnum. ": $error";
+      dbh->rollback;
+      return ( "Error billing during cancellation: $error" );
+    }
+  }
+  dbh->commit;
+
+  my @errors;
+  # try to cancel each service, the same way we would for individual packages,
+  # but in cancel weight order.
+  my @cust_svc = map { $_->cust_svc } @pkgs;
+  my @sorted_cust_svc =
+    map  { $_->[0] }
+    sort { $a->[1] <=> $b->[1] }
+    map  { [ $_, $_->svc_x ? $_->svc_x->table_info->{'cancel_weight'} : -1 ]; } @cust_svc
+  ;
+  warn "$me removing ".scalar(@sorted_cust_svc)." service(s) for customer ".
+    $self->custnum."\n"
+    if $DEBUG;
+  foreach my $cust_svc (@sorted_cust_svc) {
+    my $part_svc = $cust_svc->part_svc;
+    next if ( defined($part_svc) and $part_svc->preserve );
+    # immediate cancel, no date option
+    # transactionize individually
+    my $error = try { $cust_svc->cancel } catch { $_ };
+    if ( $error ) {
+      dbh->rollback;
+      push @errors, $error;
+    } else {
+      dbh->commit;
+    }
+  }
+  if (@errors) {
+    return @errors;
   }
 
   }
 
-  warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/".
-       scalar(@pkgs). " packages for customer ". $self->custnum. "\n"
+  warn "$me cancelling ". scalar(@pkgs) ." package(s) for customer ".
+    $self->custnum. "\n"
     if $DEBUG;
 
     if $DEBUG;
 
-  grep { $_ } map { $_->cancel(%opt) } $self->ncancelled_pkgs;
+  my @cprs;
+  if ($opt{'cust_pkg_reason'}) {
+    @cprs = @{ delete $opt{'cust_pkg_reason'} };
+  }
+  my $null_reason;
+  foreach (@pkgs) {
+    my %lopt = %opt;
+    if (@cprs) {
+      my $cpr = shift @cprs;
+      if ( $cpr ) {
+        $lopt{'reason'}        = $cpr->reasonnum;
+        $lopt{'reason_otaker'} = $cpr->otaker;
+      } else {
+        warn "no reason found when canceling package ".$_->pkgnum."\n";
+        # we're not actually required to pass a reason to cust_pkg::cancel,
+        # but if we're getting to this point, something has gone awry.
+        $null_reason ||= FS::reason->new_or_existing(
+          reason  => 'unknown reason',
+          type    => 'Cancel Reason',
+          class   => 'C',
+        );
+        $lopt{'reason'} = $null_reason->reasonnum;
+        $lopt{'reason_otaker'} = $FS::CurrentUser::CurrentUser->username;
+      }
+    }
+    my $error = $_->cancel(%lopt);
+    if ( $error ) {
+      dbh->rollback;
+      push @errors, 'pkgnum '.$_->pkgnum.': '.$error;
+    } else {
+      dbh->commit;
+    }
+  }
+
+  return @errors;
 }
 
 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 = (
@@ -2348,13 +2327,6 @@ sub _banned_pay_hashref {
   };
 }
 
   };
 }
 
-sub _new_banned_pay_hashref {
-  my $self = shift;
-  my $hr = $self->_banned_pay_hashref;
-  $hr->{payinfo} = md5_base64($hr->{payinfo});
-  $hr;
-}
-
 =item notes
 
 Returns all notes (see L<FS::cust_main_note>) for this customer.
 =item notes
 
 Returns all notes (see L<FS::cust_main_note>) for this customer.
@@ -2363,8 +2335,8 @@ Returns all notes (see L<FS::cust_main_note>) for this customer.
 
 sub notes {
   my($self,$orderby_classnum) = (shift,shift);
 
 sub notes {
   my($self,$orderby_classnum) = (shift,shift);
-  my $orderby = "_DATE DESC";
-  $orderby = "CLASSNUM ASC, $orderby" if $orderby_classnum;
+  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 },
            '',
@@ -2486,9 +2458,12 @@ Removes the I<paycvv> field from the database directly.
 
 If there is an error, returns the error, otherwise returns false.
 
 
 If there is an error, returns the error, otherwise returns false.
 
+DEPRECATED.  Use L</remove_cvv_from_cust_payby> instead.
+
 =cut
 
 sub remove_cvv {
 =cut
 
 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;
   my $self = shift;
   my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?")
     or return dbh->errstr;
@@ -2498,196 +2473,43 @@ sub remove_cvv {
   '';
 }
 
   '';
 }
 
-=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.
-
-Options may include:
-
-B<amount>: the amount to be paid; defaults to the customer's balance minus
-any payments in transit.
+=item total_owed
 
 
-B<payby>: the payment method; defaults to cust_main.payby
+Returns the total owed for this customer on all invoices
+(see L<FS::cust_bill/owed>).
 
 
-B<realtime>: runs this as a realtime payment instead of adding it to a 
-batch.  Deprecated.
+=cut
 
 
-B<invnum>: sets cust_pay_batch.invnum.
+sub total_owed {
+  my $self = shift;
+  $self->total_owed_date(2145859200); #12/31/2037
+}
 
 
-B<address1>, B<address2>, B<city>, B<state>, B<zip>, B<country>: sets 
-the billing address for the payment; defaults to the customer's billing
-location.
+=item total_owed_date TIME
 
 
-B<payinfo>, B<paydate>, B<payname>: sets the payment account, expiration
-date, and name; defaults to those fields in cust_main.
+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
 
 
 =cut
 
-sub batch_card {
-  my ($self, %options) = @_;
+sub total_owed_date {
+  my $self = shift;
+  my $time = shift;
 
 
-  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{payby} || $self->payby;  #still dubious
-
-  if ($options{'realtime'}) {
-    return $self->realtime_bop( FS::payby->payby2bop($self->payby),
-                                $amount,
-                                %options,
-                              );
-  }
+  my $custnum = $self->custnum;
 
 
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
+  my $owed_sql = FS::cust_bill->owed_sql;
 
 
-  #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 $sql = "
+    SELECT SUM($owed_sql) FROM cust_bill
+      WHERE custnum = $custnum
+        AND _date <= $time
+  ";
 
 
-  my %pay_batch = (
-    'status' => 'O',
-    'payby'  => FS::payby->payby2payment($payby),
-  );
-  $pay_batch{agentnum} = $self->agentnum if $conf->exists('batch-spoolagent');
+  sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
 
 
-  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 latitude longitude
-               payby payinfo paydate payname ))
-  {
-    $options{$_} = '' unless exists($options{$_});
-  }
-
-  my $loc = $self->bill_location;
-
-  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} || $loc->address1,
-    'address2' => $options{address2} || $loc->address2,
-    'city'     => $options{city}     || $loc->city,
-    'state'    => $options{state}    || $loc->state,
-    'zip'      => $options{zip}      || $loc->zip,
-    'country'  => $options{country}  || $loc->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 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
-  ";
-
-  sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
-
-}
+}
 
 =item total_owed_pkgnum PKGNUM
 
 
 =item total_owed_pkgnum PKGNUM
 
@@ -2928,29 +2750,6 @@ sub balance_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.
 =item payment_info
 
 Returns a hash of useful information for making a payment.
@@ -2991,6 +2790,7 @@ For electronic check transactions:
 
 =cut
 
 
 =cut
 
+#XXX i need to be updated for 4.x+
 sub payment_info {
   my $self = shift;
 
 sub payment_info {
   my $self = shift;
 
@@ -3032,82 +2832,36 @@ sub payment_info {
 
 }
 
 
 }
 
-=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 paydate_epoch
 
 =item paydate_epoch
 
-Returns the exact time in seconds corresponding to the payment method 
-expiration date.  For CARD/DCRD customers this is the end of the month;
-for others (COMP is the only other payby that uses paydate) it's the start.
-Returns 0 if the paydate is empty or set to the far future.
+Returns the next payment expiration date for this customer. If they have no
+payment methods that will expire, returns 0.
 
 =cut
 
 sub paydate_epoch {
   my $self = shift;
 
 =cut
 
 sub paydate_epoch {
   my $self = shift;
-  my ($month, $year) = $self->paydate_monthyear;
-  return 0 if !$year or $year >= 2037;
-  if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) {
-    $month++;
-    if ( $month == 13 ) {
-      $month = 1;
-      $year++;
-    }
-    return timelocal(0,0,0,1,$month-1,$year) - 1;
-  }
-  else {
-    return timelocal(0,0,0,1,$month-1,$year);
-  }
+  # 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;
 }
 
 =item paydate_epoch_sql
 
 }
 
 =item paydate_epoch_sql
 
-Class method.  Returns an SQL expression to obtain the payment expiration date
-as a number of seconds.
+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.
 
 =cut
 
 
 =cut
 
-# Special expiration date behavior for non-CARD/DCRD customers has been 
-# carefully preserved.  Do we really use that?
 sub paydate_epoch_sql {
   my $class = shift;
 sub paydate_epoch_sql {
   my $class = shift;
-  my $table = shift || 'cust_main';
-  my ($case1, $case2);
-  if ( driver_name eq 'Pg' ) {
-    $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1";
-    $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )";
-  }
-  elsif ( lc(driver_name) eq 'mysql' ) {
-    $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1";
-    $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )";
-  }
-  else { return '' }
-  return "CASE WHEN $table.payby IN('CARD','DCRD') 
-  THEN ($case1)
-  ELSE ($case2)
-  END"
+  my $paydate = FS::cust_payby->paydate_epoch_sql;
+  "(SELECT COALESCE(MIN($paydate), 2143260000) FROM cust_payby WHERE cust_payby.custnum = cust_main.custnum)";
 }
 
 }
 
-=item tax_exemption TAXNAME
-
-=cut
-
 sub tax_exemption {
   my( $self, $taxname ) = @_;
 
 sub tax_exemption {
   my( $self, $taxname ) = @_;
 
@@ -3119,18 +2873,10 @@ sub tax_exemption {
 
 =item cust_main_exemption
 
 
 =item cust_main_exemption
 
-=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.
+=item invoicing_list
 
 
-This interface may change in the future.
+Returns a list of email addresses (with svcnum entries expanded), and the word
+'POST' if the customer receives postal invoices.
 
 =cut
 
 
 =cut
 
@@ -3138,47 +2884,13 @@ sub invoicing_list {
   my( $self, $arrayref ) = @_;
 
   if ( $arrayref ) {
   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;
-    }
+    warn "FS::cust_main::invoicing_list(ARRAY) is no longer supported.";
   }
   
   }
   
-  if ( $self->custnum ) {
-    map { $_->address }
-      qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
-  } else {
-    ();
-  }
+  my @emails = $self->invoicing_list_emailonly;
+  push @emails, 'POST' if $self->get('postal_invoice');
 
 
+  @emails;
 }
 
 =item check_invoicing_list ARRAYREF
 }
 
 =item check_invoicing_list ARRAYREF
@@ -3216,18 +2928,6 @@ sub check_invoicing_list {
   '';
 }
 
   '';
 }
 
-=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.
 =item all_emails
 
 Returns the email addresses of all accounts provisioned for this customer.
@@ -3257,10 +2957,11 @@ to receive postal invoices, does nothing.
 
 sub invoicing_list_addpost {
   my $self = shift;
 
 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);
+  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
+  }
 }
 
 =item invoicing_list_emailonly
 }
 
 =item invoicing_list_emailonly
@@ -3274,7 +2975,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
@@ -3291,6 +3001,74 @@ sub invoicing_list_emailonly_scalar {
   join(', ', $self->invoicing_list_emailonly);
 }
 
   join(', ', $self->invoicing_list_emailonly);
 }
 
+=item contact_list [ CLASSNUM, ... ]
+
+Returns a list of contacts (L<FS::contact> objects) for the customer. If
+a list of contact classnums is given, returns only contacts in those
+classes. If the pseudo-classnum 'invoice' is given, returns contacts that
+are marked as invoice destinations. If '0' is given, also returns contacts
+with no class.
+
+If no arguments are given, returns all contacts for the customer.
+
+=cut
+
+sub contact_list {
+  my $self = shift;
+  my $search = {
+    table       => 'contact',
+    select      => 'contact.*, cust_contact.invoice_dest',
+    addl_from   => ' JOIN cust_contact USING (contactnum)',
+    extra_sql   => ' WHERE cust_contact.custnum = '.$self->custnum,
+  };
+
+  my @orwhere;
+  my @classnums;
+  foreach (@_) {
+    if ( $_ eq 'invoice' ) {
+      push @orwhere, 'cust_contact.invoice_dest = \'Y\'';
+    } elsif ( $_ eq '0' ) {
+      push @orwhere, 'cust_contact.classnum is null';
+    } elsif ( /^\d+$/ ) {
+      push @classnums, $_;
+    } else {
+      die "bad classnum argument '$_'";
+    }
+  }
+
+  if (@classnums) {
+    push @orwhere, 'cust_contact.classnum IN ('.join(',', @classnums).')';
+  }
+  if (@orwhere) {
+    $search->{extra_sql} .= ' AND (' .
+                            join(' OR ', map "( $_ )", @orwhere) .
+                            ')';
+  }
+
+  qsearch($search);
+}
+
+=item contact_list_email [ CLASSNUM, ... ]
+
+Same as L</contact_list>, but returns email destinations instead of contact
+objects.
+
+=cut
+
+sub contact_list_email {
+  my $self = shift;
+  my @contacts = $self->contact_list(@_);
+  my @emails;
+  foreach my $contact (@contacts) {
+    foreach my $contact_email ($contact->contact_email) {
+      push @emails,  Email::Address->new( $contact->firstlast,
+                                          $contact_email->emailaddress
+                     )->format;
+    }
+  }
+  @emails;
+}
+
 =item referral_custnum_cust_main
 
 Returns the customer who referred this customer (or the empty string, if
 =item referral_custnum_cust_main
 
 Returns the customer who referred this customer (or the empty string, if
@@ -3474,18 +3252,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 $locationnum;
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
   my $cust_pkg_ref = '';
   my ( $bill_now, $invoice_terms ) = ( 0, '' );
   my $locationnum;
   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} : '';
@@ -3502,8 +3283,10 @@ sub charge {
     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
     $locationnum = $_[0]->{locationnum} || $self->ship_locationnum;
     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
     $locationnum = $_[0]->{locationnum} || $self->ship_locationnum;
-  } else {
+    $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';
@@ -3534,6 +3317,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->[$_] ) }
@@ -3568,6 +3352,7 @@ 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,
   } );
 
     'locationnum'=> $locationnum,
   } );
 
@@ -3779,9 +3564,12 @@ Returns all the credits (see L<FS::cust_credit>) for this customer.
 
 sub cust_credit {
   my $self = shift;
 
 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 } )
+
+  #return $self->num_cust_credit unless wantarray;
+
+  map { $_ } #behavior of sort undefined in scalar context
+    sort { $a->_date <=> $b->_date }
+      qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
 }
 
 =item cust_credit_pkgnum
 }
 
 =item cust_credit_pkgnum
@@ -3896,31 +3684,6 @@ sub cust_pay_void {
     qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
 }
 
     qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
 }
 
-=item cust_pay_batch [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
-
-Returns all batched payments (see L<FS::cust_pay_batch>) for this customer.
-
-Optionally, a list or hashref of additional arguments to the qsearch call can
-be passed.
-
-=cut
-
-sub cust_pay_batch {
-  my $self = shift;
-  my $opt = ref($_[0]) ? shift : { @_ };
-
-  #return $self->num_cust_statement unless wantarray || keys %$opt;
-
-  $opt->{'table'} = 'cust_pay_batch';
-  $opt->{'hashref'} ||= {}; #i guess it would autovivify anyway...
-  $opt->{'hashref'}{'custnum'} = $self->custnum;
-  $opt->{'order_by'} ||= 'ORDER BY paybatchnum ASC';
-
-  map { $_ } #behavior of sort undefined in scalar context
-    sort { $a->paybatchnum <=> $b->paybatchnum }
-      qsearch($opt);
-}
-
 =item cust_pay_pending
 
 Returns all pending payments (see L<FS::cust_pay_pending>) for this customer
 =item cust_pay_pending
 
 Returns all pending payments (see L<FS::cust_pay_pending>) for this customer
@@ -4016,34 +3779,16 @@ cust_main-default_agent_custid is set and it has a value, custnum otherwise.
 sub display_custnum {
   my $self = shift;
 
 sub display_custnum {
   my $self = shift;
 
+  return $self->agent_custid
+    if $default_agent_custid && $self->agent_custid;
+
   my $prefix = $conf->config('cust_main-custnum-display_prefix', $self->agentnum) || '';
   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
-  }
 
 
-  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);
+  if ( $prefix ) {
     return $prefix . 
     return $prefix . 
-           sprintf('%0'.$length.'d', $self->custnum)
-  } elsif ( $length ) {
-    return sprintf('%0'.$length.'d', $self->custnum);
+           sprintf('%0'.($custnum_display_length||8).'d', $self->custnum)
+  } elsif ( $custnum_display_length ) {
+    return sprintf('%0'.$custnum_display_length.'d', $self->custnum);
   } else {
     return $self->custnum;
   }
   } else {
     return $self->custnum;
   }
@@ -4077,9 +3822,11 @@ sub 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 $classnum = $self->scalar_sql(
       'SELECT classnum FROM contact_class WHERE classname = \'Service\''
     ) || 0; #if it's zero, qsearchs will return nothing
-    $self->{service_contact} = qsearchs('contact', { 
-        'classnum' => $classnum, 'custnum' => $self->custnum
-      }) || undef;
+    my $cust_contact = qsearchs('cust_contact', { 
+        'classnum' => $classnum,
+        'custnum'  => $self->custnum,
+    });
+    $self->{service_contact} = $cust_contact->contact if $cust_contact;
   }
   $self->{service_contact};
 }
   }
   $self->{service_contact};
 }
@@ -4170,26 +3917,14 @@ sub ship_contact_firstlast {
   $contact->get('first') . ' '. $contact->get('last');
 }
 
   $contact->get('first') . ' '. $contact->get('last');
 }
 
-#XXX this doesn't work in 3.x+
-#=item country_full
-#
-#Returns this customer's full country name
-#
-#=cut
-#
-#sub country_full {
-#  my $self = shift;
-#  code2country($self->country);
-#}
-
 sub bill_country_full {
   my $self = shift;
 sub bill_country_full {
   my $self = shift;
-  code2country($self->bill_location->country);
+  $self->bill_location->country_full;
 }
 
 sub ship_country_full {
   my $self = shift;
 }
 
 sub ship_country_full {
   my $self = shift;
-  code2country($self->ship_location->country);
+  $self->ship_location->country_full;
 }
 
 =item county_state_county [ PREFIX ]
 }
 
 =item county_state_county [ PREFIX ]
@@ -4260,16 +3995,41 @@ sub status { shift->cust_status(@_); }
 
 sub cust_status {
   my $self = shift;
 
 sub cust_status {
   my $self = shift;
+  return $self->hashref->{cust_status} if $self->hashref->{cust_status};
   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;
   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];
+    if ( $sth->fetchrow_arrayref->[0] ) {
+      $self->hashref->{cust_status} = $status;
+      return $status;
+    }
   }
 }
 
   }
 }
 
+=item is_status_delay_cancel
+
+Returns true if customer status is 'suspended'
+and all suspended cust_pkg return true for
+cust_pkg->is_status_delay_cancel.
+
+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.
+
+=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;
+}
+
 =item ucfirst_cust_status
 
 =item ucfirst_status
 =item ucfirst_cust_status
 
 =item ucfirst_status
@@ -4294,10 +4054,14 @@ sub ucfirst_cust_status {
 
 =item cust_status_label
 
 
 =item cust_status_label
 
+=item status_label
+
 Returns the display label for this status.
 
 =cut
 
 Returns the display label for this status.
 
 =cut
 
+sub status_label { shift->cust_status_label(@_); }
+
 sub cust_status_label {
   my $self = shift;
   __PACKAGE__->statuslabels->{$self->cust_status};
 sub cust_status_label {
   my $self = shift;
   __PACKAGE__->statuslabels->{$self->cust_status};
@@ -4360,6 +4124,30 @@ sub tickets {
   (@tickets);
 }
 
   (@tickets);
 }
 
+=item appointments [ STATUS ]
+
+Returns an array of hashes representing the customer's RT tickets which
+are appointments.
+
+=cut
+
+sub appointments {
+  my $self = shift;
+  my $status = ( @_ && $_[0] ) ? shift : '';
+
+  return () unless $conf->config('ticket_system');
+
+  my $queueid = $conf->config('ticket_system-appointment-queueid');
+
+  @{ FS::TicketSystem->customer_tickets( $self->custnum,
+                                         99,
+                                         undef,
+                                         $status,
+                                         $queueid,
+                                       )
+  };
+}
+
 # Return services representing svc_accts in customer support packages
 sub support_services {
   my $self = shift;
 # Return services representing svc_accts in customer support packages
 sub support_services {
   my $self = shift;
@@ -4402,6 +4190,497 @@ my ($self,$field) = @_;
 
 }
 
 
 }
 
+=item payment_history
+
+Returns an array of hashrefs standardizing information from cust_bill, cust_pay,
+cust_credit and cust_refund objects.  Each hashref has the following fields:
+
+I<type> - one of 'Line item', 'Invoice', 'Payment', 'Credit', 'Refund' or 'Previous'
+
+I<date> - value of _date field, unix timestamp
+
+I<date_pretty> - user-friendly date
+
+I<description> - user-friendly description of item
+
+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.
+
+I<amount_pretty> - includes money char
+
+I<balance> - customer balance, chronologically as of this item
+
+I<balance_pretty> - includes money char
+
+I<charged> - amount charged for cust_bill (Invoice or Line item) records, undef for other types
+
+I<paid> - amount paid for cust_pay records, undef for other types
+
+I<credit> - amount credited for cust_credit records, undef for other types.
+Literally the 'amount' field from cust_credit, renamed here to avoid confusion.
+
+I<refund> - amount refunded for cust_refund records, undef for other types
+
+The four table-specific keys always have positive values, whether they reflect charges or payments.
+
+The following options may be passed to this method:
+
+I<line_items> - if true, returns charges ('Line item') rather than invoices
+
+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.
+
+I<end_date> - unix timestamp, only include records before
+
+I<reverse_sort> - order from newest to oldest (default is oldest to newest)
+
+I<conf> - optional already-loaded FS::Conf object.
+
+=cut
+
+# 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;
+
+    }
+
+  } else {
+
+    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});
+    }
+    push(@out,$item);
+  }
+
+  # 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);
+  }
+
+  @out = reverse @history if $$opt{'reverse_sort'};
+
+  return @out;
+}
+
+=item save_cust_payby
+
+Saves a new cust_payby for this customer, replacing an existing entry only
+in select circumstances.  Does not validate input.
+
+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.
+
+If bill_location is specified with auto, also sets location in cust_main.
+
+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.
+
+Accepts the following named parameters:
+
+=over 4
+
+=item payment_payby
+
+either CARD or CHEK
+
+=item auto
+
+save as an automatic payment type (CARD/CHEK if true, DCRD/DCHK if false)
+
+=item weight
+
+optional, set higher than 1 for secondary, etc.
+
+=item payinfo
+
+required
+
+=item paymask
+
+optional, but should be specified for anything that might be tokenized, will be preserved when replacing
+
+=item payname
+
+required
+
+=item payip
+
+optional, will be preserved when replacing
+
+=item paydate
+
+CARD only, required
+
+=item bill_location
+
+CARD only, required, FS::cust_location object
+
+=item paystart_month
+
+CARD only, optional, will be preserved when replacing
+
+=item paystart_year
+
+CARD only, optional, will be preserved when replacing
+
+=item payissue
+
+CARD only, optional, will be preserved when replacing
+
+=item paycvv
+
+CARD only, only used if conf cvv-save is set appropriately
+
+=item paytype
+
+CHEK only
+
+=item paystate
+
+CHEK only
+
+=item saved_cust_payby
+
+scalar reference, for returning saved object
+
+=back
+
+=cut
+
+#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
+
+sub save_cust_payby {
+  my $self = shift;
+  my %opt = @_;
+
+  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 );
+
+  my $payby = $opt{'payment_payby'};
+  return "Bad payby" unless grep(/^$payby$/,('CARD','CHEK'));
+
+  # don't allow turning off auto when replacing
+  $opt{'auto'} ||= 1 if $old and $old->payby !~ /^D/;
+
+  my @check_existing; # payby relevant to this payment_payby
+
+  # 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 );
+  }
+
+  $new->set( 'weight' => $opt{'auto'} ? $opt{'weight'} : '' );
+
+  # 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
+
+  my $conf = new FS::Conf;
+
+  # 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')) 
+         || $conf->exists('business-onlinepayment-verification') 
+       )
+  ) {
+    $new->set( 'paycvv' => $opt{'paycvv'} );
+  } else {
+    $new->set( 'paycvv' => '');
+  }
+
+  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;
+
+  # 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} );
+    }
+  }
+
+  # 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;
+        }
+      }
+      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 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;
+  }
+
+  ${$opt{'saved_cust_payby'}} = $new
+    if $opt{'saved_cust_payby'};
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+=item remove_cvv_from_cust_payby PAYINFO
+
+Removes paycvv from associated cust_payby with matching PAYINFO.
+
+=cut
+
+sub remove_cvv_from_cust_payby {
+  my ($self,$payinfo) = @_;
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  foreach my $cust_payby ( qsearch('cust_payby',{ custnum => $self->custnum }) ) {
+    next unless $cust_payby->payinfo eq $payinfo; # can't qsearch on payinfo
+    $cust_payby->paycvv('');
+    my $error = $cust_payby->replace;
+    if ($error) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+}
+
 =back
 
 =head1 CLASS METHODS
 =back
 
 =head1 CLASS METHODS
@@ -4539,15 +4818,10 @@ Returns an SQL expression identifying un-cancelled cust_main records.
 =cut
 
 sub uncancelled_sql { uncancel_sql(@_); }
 =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 uncancel_sql {
+  my $self = shift;
+  "( NOT (".$self->cancelled_sql.") )"; #sensitive to cust_main-status_module
+}
 
 =item balance_sql
 
 
 =item balance_sql
 
@@ -4707,103 +4981,6 @@ sub search {
 
 =over 4
 
 
 =over 4
 
-=item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
-
-Deprecated.  Use event notification and message templates 
-(L<FS::msg_template>) instead.
-
-Sends a templated email notification to the customer (see L<Text::Template>).
-
-OPTIONS is a hash and may include
-
-I<from> - the email sender (default is invoice_from)
-
-I<to> - comma-separated scalar or arrayref of recipients 
-   (default is invoicing_list)
-
-I<subject> - The subject line of the sent email notification
-   (default is "Notice from company_name")
-
-I<extra_fields> - a hashref of name/value pairs which will be substituted
-   into the template
-
-The following variables are vavailable in the template.
-
-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
-
-=cut
-
-sub notify {
-  my ($self, $template, %options) = @_;
-
-  return unless $conf->exists($template);
-
-  my $from = $conf->config('invoice_from', $self->agentnum)
-    if $conf->exists('invoice_from', $self->agentnum);
-  $from = $options{from} if exists($options{from});
-
-  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 (keys %{$options{extra_fields}}){
-    no strict "refs";
-    ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
-  }
-
-  send_email(from => $from,
-             to => $to,
-             subject => $subject,
-             body => $notify_template->fill_in( PACKAGE =>
-                                                'FS::notify_template::_template'                                              ),
-            );
-
-}
-
 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
 
 Generates a templated notification to the customer (see L<Text::Template>).
 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
 
 Generates a templated notification to the customer (see L<Text::Template>).
@@ -4814,13 +4991,11 @@ 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
@@ -4829,11 +5004,16 @@ I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or
 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";
@@ -4842,27 +5022,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}->{$_};
@@ -4948,7 +5107,9 @@ sub queueable_print {
   my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
     or die "invalid customer number: " . $opt{custnum};
 
   my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
     or die "invalid customer number: " . $opt{custnum};
 
-  my $error = $self->print( { 'template' => $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;
 }
 
   die $error if $error;
 }
 
@@ -5031,6 +5192,42 @@ 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.
 =item queued_bill 'custnum' => CUSTNUM [ , OPTION => VALUE ... ]
 
 Subroutine (not a method), designed to be called from the queue.
@@ -5096,9 +5293,7 @@ sub process_bill_and_collect {
 sub _upgrade_data { #class method
   my ($class, %opts) = @_;
 
 sub _upgrade_data { #class method
   my ($class, %opts) = @_;
 
-  my @statements = (
-    'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL',
-  );
+  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') ) {
 
   #this seems to be the only expensive one.. why does it take so long?
   unless ( FS::upgrade_journal->is_done('cust_main__signupdate') ) {
@@ -5107,29 +5302,6 @@ sub _upgrade_data { #class method
     FS::upgrade_journal->set_done('cust_main__signupdate');
   }
 
     FS::upgrade_journal->set_done('cust_main__signupdate');
   }
 
-  unless ( FS::upgrade_journal->is_done('cust_main__paydate') ) {
-
-    # fix yyyy-m-dd formatted paydates
-    if ( driver_name =~ /^mysql/i ) {
-      push @statements,
-      "UPDATE cust_main SET paydate = CONCAT( SUBSTRING(paydate FROM 1 FOR 5), '0', SUBSTRING(paydate FROM 6) ) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'";
-    } else { # the SQL standard
-      push @statements, 
-      "UPDATE cust_main SET paydate = SUBSTRING(paydate FROM 1 FOR 5) || '0' || SUBSTRING(paydate FROM 6) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'";
-    }
-    FS::upgrade_journal->set_done('cust_main__paydate');
-  }
-
-  unless ( FS::upgrade_journal->is_done('cust_main__payinfo') ) {
-
-    push @statements, #fix the weird BILL with a cc# in payinfo problem
-      #DCRD to be safe
-      "UPDATE cust_main SET payby = 'DCRD' WHERE payby = 'BILL' and length(payinfo) = 16 and payinfo ". regexp_sql. q( '^[0-9]*$' );
-
-    FS::upgrade_journal->set_done('cust_main__payinfo');
-    
-  }
-
   my $t = time;
   foreach my $sql ( @statements ) {
     my $sth = dbh->prepare($sql) or die dbh->errstr;
   my $t = time;
   foreach my $sql ( @statements ) {
     my $sth = dbh->prepare($sql) or die dbh->errstr;
@@ -5164,44 +5336,6 @@ sub _upgrade_data { #class method
 
   }
 
 
   }
 
-  unless ( FS::upgrade_journal->is_done('cust_main__cust_payby') ) {
-
-    #we don't want to decrypt them, just stuff them as-is into cust_payby
-    local(@encrypted_fields) = ();
-
-    local($FS::cust_payby::ignore_expired_card) = 1;
-    local($FS::cust_payby::ignore_banned_card) = 1;
-
-    my @payfields = qw( payby payinfo paycvv paymask
-                        paydate paystart_month paystart_year payissue
-                        payname paystate paytype payip
-                      );
-
-    my $search = new FS::Cursor {
-      'table'     => 'cust_main',
-      'extra_sql' => " WHERE ( payby IS NOT NULL AND payby != '' ) ",
-    };
-
-    while (my $cust_main = $search->fetch) {
-
-      my $cust_payby = new FS::cust_payby {
-        'custnum' => $cust_main->custnum,
-        'weight'  => 1,
-        map { $_ => $cust_main->$_(); } @payfields
-      };
-
-      my $error = $cust_payby->insert;
-      die $error if $error;
-
-      $cust_main->setfield($_, '') foreach @payfields;
-      $error = $cust_main->replace;
-      die $error if $error;
-
-    };
-
-    FS::upgrade_journal->set_done('cust_main__cust_payby');
-  }
-
   $class->_upgrade_otaker(%opts);
 
 }
   $class->_upgrade_otaker(%opts);
 
 }
@@ -5223,13 +5357,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