respect card-types config in backend (new customer payment types / 3.x), RT#39861
[freeside.git] / FS / FS / cust_main.pm
index cd675f9..9e0db29 100644 (file)
@@ -24,16 +24,14 @@ use Scalar::Util qw( blessed );
 use Time::Local qw(timelocal);
 use Data::Dumper;
 use Tie::IxHash;
 use Time::Local qw(timelocal);
 use Data::Dumper;
 use Tie::IxHash;
-use Digest::MD5 qw(md5_base64);
 use Date::Format;
 #use Date::Manip;
 use File::Temp; #qw( tempfile );
 use Business::CreditCard 0.28;
 use Date::Format;
 #use Date::Manip;
 use File::Temp; #qw( tempfile );
 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;
@@ -76,6 +74,7 @@ use FS::Locales;
 use FS::upgrade_journal;
 use FS::sales;
 use FS::cust_payby;
 use FS::upgrade_journal;
 use FS::sales;
 use FS::cust_payby;
+use FS::contact;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -92,11 +91,11 @@ 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;
 #ask FS::UID to run this stuff for us later
 #$FS::UID::callback{'FS::cust_main'} = sub { 
 our $conf;
 #ask FS::UID to run this stuff for us later
 #$FS::UID::callback{'FS::cust_main'} = sub { 
@@ -279,6 +278,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
@@ -321,17 +324,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).
@@ -348,15 +344,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"
@@ -378,7 +386,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('');
 
@@ -403,7 +411,7 @@ sub insert {
   } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|MCHK|PPAL)$/ ) {
 
     $payby = $1;
   } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|MCHK|PPAL)$/ ) {
 
     $payby = $1;
-    $self->payby('BILL');
+    $self->payby(''); #'BILL');
     $amount = $self->paid;
 
   }
     $amount = $self->paid;
 
   }
@@ -460,7 +468,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";
@@ -483,19 +492,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;
 
@@ -557,8 +553,58 @@ sub insert {
 
   }
 
 
   }
 
-  my $contact = delete $options{'contact'};
-  if ( $contact ) {
+  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->contactnum ?
+                        $cust_contact->replace : $cust_contact->insert;
+          if ( $error ) {
+            $dbh->rollback if $oldAutoCommit;
+            return "$error (linking to email address $dest)";
+          }
+
+        } else {
+          # this email address is not yet linked to any contact
+          $email .= ',' if length($email);
+          $email .= $dest;
+        }
+      }
+    }
+
+    my $contact = FS::contact->new({
+      'custnum'       => $self->get('custnum'),
+      'last'          => $self->get('last'),
+      'first'         => $self->get('first'),
+      'emailaddress'  => $email,
+      'invoice_dest'  => 'Y', # yes, you can set this via the contact
+    });
+    my $error = $contact->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+  }
+
+  if ( my $contact = delete $options{'contact'} ) {
 
     foreach my $c ( @$contact ) {
       $c->custnum($self->custnum);
 
     foreach my $c ( @$contact ) {
       $c->custnum($self->custnum);
@@ -570,6 +616,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"
@@ -1122,10 +1207,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 } ) ) {
@@ -1222,19 +1306,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 {
@@ -1250,13 +1340,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)$/
@@ -1268,6 +1355,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
@@ -1285,8 +1380,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);
@@ -1298,6 +1393,120 @@ sub replace {
     $self->set($l.'num', $new_loc->locationnum);
   } #for $l
 
     $self->set($l.'num', $new_loc->locationnum);
   } #for $l
 
+  my $invoicing_list;
+  if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
+    warn "cust_main::replace: using deprecated invoicing list argument";
+    $invoicing_list = shift @param;
+  }
+
+  my %options = @param;
+
+  $invoicing_list ||= $options{invoicing_list};
+
+  my @contacts = map { $_->contact } $self->cust_contact;
+  # find a contact that matches the customer's name
+  my ($implicit_contact) = grep { $_->first eq $old->get('first')
+                              and $_->last  eq $old->get('last') }
+                            @contacts;
+  $implicit_contact ||= FS::contact->new({
+      'custnum'       => $self->custnum,
+      'locationnum'   => $self->get('bill_locationnum'),
+  });
+
+  # for any of these that are already contact emails, link to the existing
+  # contact
+  if ( $invoicing_list ) {
+    my $email = '';
+
+    # kind of like process_m2m on these, except:
+    # - the other side is two tables in a join
+    # - and we might have to create new contact_emails
+    # - and possibly a new contact
+    # 
+    # Find existing invoice emails that aren't on the implicit contact.
+    # Any of these that are not on the new invoicing list will be removed.
+    my %old_email_cust_contact;
+    foreach my $cust_contact ($self->cust_contact) {
+      next if !$cust_contact->invoice_dest;
+      next if $cust_contact->contactnum == ($implicit_contact->contactnum || 0);
+
+      foreach my $contact_email ($cust_contact->contact->contact_email) {
+        $old_email_cust_contact{ $contact_email->emailaddress } = $cust_contact;
+      }
+    }
+
+    foreach my $dest (@$invoicing_list) {
+
+      if ($dest eq 'POST') {
+
+        $self->set('postal_invoice', 'Y');
+
+      } elsif ( exists($old_email_cust_contact{$dest}) ) {
+
+        delete $old_email_cust_contact{$dest}; # don't need to remove it, then
+
+      } else {
+
+        # See if it belongs to some other contact; if so, link it.
+        my $contact_email = qsearchs('contact_email', { emailaddress => $dest });
+        if ( $contact_email
+             and $contact_email->contactnum != ($implicit_contact->contactnum || 0) ) {
+          my $cust_contact = qsearchs('cust_contact', {
+              contactnum  => $contact_email->contactnum,
+              custnum     => $self->custnum,
+          }) || FS::cust_contact->new({
+              contactnum    => $contact_email->contactnum,
+              custnum       => $self->custnum,
+          });
+          $cust_contact->set('invoice_dest', 'Y');
+          my $error = $cust_contact->custcontactnum ?
+                        $cust_contact->replace : $cust_contact->insert;
+          if ( $error ) {
+            $dbh->rollback if $oldAutoCommit;
+            return "$error (linking to email address $dest)";
+          }
+
+        } else {
+          # This email address is not yet linked to any contact, so it will
+          # be added to the implicit contact.
+          $email .= ',' if length($email);
+          $email .= $dest;
+        }
+      }
+    }
+
+    foreach my $remove_dest (keys %old_email_cust_contact) {
+      my $cust_contact = $old_email_cust_contact{$remove_dest};
+      # These were not in the list of requested destinations, so take them off.
+      $cust_contact->set('invoice_dest', '');
+      my $error = $cust_contact->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "$error (unlinking email address $remove_dest)";
+      }
+    }
+
+    # make sure it keeps up with the changed customer name, if any
+    $implicit_contact->set('last', $self->get('last'));
+    $implicit_contact->set('first', $self->get('first'));
+    $implicit_contact->set('emailaddress', $email);
+    $implicit_contact->set('invoice_dest', 'Y');
+    $implicit_contact->set('custnum', $self->custnum);
+
+    my $error;
+    if ( $implicit_contact->contactnum ) {
+      $error = $implicit_contact->replace;
+    } elsif ( length($email) ) { # don't create a new contact if not needed
+      $error = $implicit_contact->insert;
+    }
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "$error (adding email address $email)";
+    }
+
+  }
+
   # replace the customer record
   my $error = $self->SUPER::replace($old);
 
   # replace the customer record
   my $error = $self->SUPER::replace($old);
 
@@ -1327,16 +1536,6 @@ sub replace {
     }
   }
 
     }
   }
 
-  if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
-    my $invoicing_list = shift @param;
-    $error = $self->check_invoicing_list( $invoicing_list );
-    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
 
     #this could be more efficient than deleting and re-inserting, if it matters
   if ( $self->exists('tagnum') ) { #so we don't delete these on edit by accident
 
     #this could be more efficient than deleting and re-inserting, if it matters
@@ -1359,8 +1558,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 ) {
 
@@ -1403,21 +1600,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'} ) {
 
 
-    # card/check/lec info has changed, want to retry realtime_ invoice events
-    my $error = $self->retry_realtime;
+    my $error = $self->process_o2m(
+      'table'         => 'cust_payby',
+      'fields'        => FS::cust_payby->cgi_cust_payby_fields,
+      'params'        => $cust_payby_params,
+      'hash_callback' => \&FS::cust_payby::cgi_hash_callback,
+    );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
+
+  }
+
+  if ( my $contact_params = delete $options{'contact_params'} ) {
+
+    # this can potentially replace contacts that were created by the
+    # invoicing list argument, but the UI shouldn't allow both of them
+    # to be specified
+
+    my $error = $self->process_o2m(
+      'table'         => 'contact',
+      'fields'        => FS::contact->cgi_contact_fields,
+      'params'        => $contact_params,
+    );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
   }
 
   unless ( $import || $skip_fuzzyfiles ) {
   }
 
   unless ( $import || $skip_fuzzyfiles ) {
@@ -1555,6 +1768,10 @@ sub check {
     || $self->ut_flag('message_noemail')
     || $self->ut_enum('locale', [ '', FS::Locales->locales ])
     || $self->ut_currencyn('currency')
     || $self->ut_flag('message_noemail')
     || $self->ut_enum('locale', [ '', FS::Locales->locales ])
     || $self->ut_currencyn('currency')
+    || $self->ut_alphan('po_number')
+    || $self->ut_enum('complimentary', [ '', 'Y' ])
+    || $self->ut_flag('invoice_ship_address')
+    || $self->ut_flag('invoice_dest')
   ;
 
   foreach (qw(company ship_company)) {
   ;
 
   foreach (qw(company ship_company)) {
@@ -1599,6 +1816,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 =
@@ -1809,6 +2031,11 @@ sub check {
 
   }
 
 
   }
 
+  return "You are not permitted to create complimentary accounts."
+    if ! $self->custnum
+    && $self->complimentary eq 'Y'
+    && ! $FS::CurrentUser::CurrentUser->access_right('Complimentary customer');
+
   if ( $self->paydate eq '' || $self->paydate eq '-' ) {
     return "Expiration date required"
       # shouldn't payinfo_check do this?
   if ( $self->paydate eq '' || $self->paydate eq '-' ) {
     return "Expiration date required"
       # shouldn't payinfo_check do this?
@@ -1874,6 +2101,40 @@ sub check {
   $self->SUPER::check;
 }
 
   $self->SUPER::check;
 }
 
+sub check_payinfo_cardtype {
+  my $self = shift;
+
+  return '' unless $self->payby =~ /^(CARD|CHEK)$/;
+
+  my $payinfo = $self->payinfo;
+  $payinfo =~ s/\D//g;
+
+  return '' if $payinfo =~ /^99\d{14}$/; #token
+
+  my %bop_card_types = map { $_=>1 } values %{ card_types() };
+  my $cardtype = cardtype($payinfo);
+
+  return "$cardtype not accepted" unless $bop_card_types{$cardtype};
+
+  '';
+
+}
+
+=item replace_check
+
+Additional checks for replace only.
+
+=cut
+
+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);
+  }
+  return '';
+}
+
 =item addr_fields 
 
 Returns a list of fields which have ship_ duplicates.
 =item addr_fields 
 
 Returns a list of fields which have ship_ duplicates.
@@ -1936,19 +2197,44 @@ sub cust_contact {
   qsearch('cust_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
@@ -2089,16 +2375,21 @@ sub cancel {
   return ( 'access denied' )
     unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
 
   return ( 'access denied' )
     unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
 
-  if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
+  if ( $opt{'ban'} ) {
 
 
-    #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;
+      return ( $error ) if $error;
+
+    }
 
   }
 
 
   }
 
@@ -2135,13 +2426,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.
@@ -2643,24 +2927,6 @@ 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
 
 Returns the exact time in seconds corresponding to the payment method 
 =item paydate_epoch
 
 Returns the exact time in seconds corresponding to the payment method 
@@ -2730,18 +2996,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.
+=item invoicing_list
 
 
-Returns a list of email addresses (with svcnum entries expanded).
-
-Note: You can clear the invoicing list by passing an empty ARRAYREF.  You can
-check it without disturbing anything by passing nothing.
-
-This interface may change in the future.
+Returns a list of email addresses (with svcnum entries expanded), and the word
+'POST' if the customer receives postal invoices.
 
 =cut
 
 
 =cut
 
@@ -2749,47 +3007,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
@@ -2827,18 +3051,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.
@@ -2868,10 +3080,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
@@ -2885,7 +3098,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
@@ -3093,6 +3315,7 @@ sub charge {
   my ( $setuptax, $taxclass );   #internal taxes
   my ( $taxproduct, $override ); #vendor (CCH) taxes
   my $no_auto = '';
   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;
   my $cust_pkg_ref = '';
   my ( $bill_now, $invoice_terms ) = ( 0, '' );
   my $locationnum;
@@ -3115,7 +3338,8 @@ 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;
     $setup_cost = '';
     $quantity   = 1;
     $amount     = shift;
     $setup_cost = '';
     $quantity   = 1;
@@ -3183,6 +3407,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,
   } );
 
@@ -3762,26 +3987,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 ]
@@ -3862,6 +4075,27 @@ sub cust_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
@@ -3956,6 +4190,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;
@@ -3998,6 +4256,420 @@ 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 (weight 1) 
+and changes existing primary methods for that payby to secondary methods (weight 2.)
+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:
+
+payment_payby - either CARD or CHEK
+
+auto - save as an automatic payment type (CARD/CHEK if true, DCRD/DCHK if false)
+
+payinfo - required
+
+paymask - optional, but should be specified for anything that might be tokenized, will be preserved when replacing
+
+payname - required
+
+payip - optional, will be preserved when replacing
+
+paydate - CARD only, required
+
+bill_location - CARD only, required, FS::cust_location object
+
+paystart_month - CARD only, optional, will be preserved when replacing
+
+paystart_year - CARD only, optional, will be preserved when replacing
+
+payissue - CARD only, optional, will be preserved when replacing
+
+paycvv - CARD only, only used if conf cvv-save is set appropriately
+
+paytype - CHEK only
+
+paystate - CHEK only
+
+=cut
+
+#The code for this option is in place, but it's not currently used
+#
+# 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 );
+  }
+
+  # every automatic payment type added here will be marked primary
+  $new->set( 'weight' => $opt{'auto'} ? 1 : '' );
+
+  # 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') ) {
+    $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;
+    }
+  }
+
+  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;
+      $cust_payby->set( 'weight' => 2 );
+      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;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
 =back
 
 =head1 CLASS METHODS
 =back
 
 =head1 CLASS METHODS
@@ -4303,102 +4975,102 @@ 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
+#=item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
 
 
-The following variables are vavailable in the template.
+#Deprecated.  Use event notification and message templates 
+#(L<FS::msg_template>) instead.
 
 
-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
+#Sends a templated email notification to the customer (see L<Text::Template>).
 
 
-=cut
+#OPTIONS is a hash and may include
 
 
-sub notify {
-  my ($self, $template, %options) = @_;
+#I<from> - the email sender (default is invoice_from)
 
 
-  return unless $conf->exists($template);
+#I<to> - comma-separated scalar or arrayref of recipients 
+#   (default is invoicing_list)
 
 
-  my $from = $conf->invoice_from_full($self->agentnum)
-    if $conf->exists('invoice_from', $self->agentnum);
-  $from = $options{from} if exists($options{from});
+#I<subject> - The subject line of the sent email notification
+#   (default is "Notice from company_name")
 
 
-  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";
+#I<extra_fields> - a hashref of name/value pairs which will be substituted
+#   into the template
 
 
-  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);
+#The following variables are vavailable in the template.
 
 
-  #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;
+#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
 
 
-  for (keys %{$options{extra_fields}}){
-    no strict "refs";
-    ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
-  }
+#=cut
 
 
-  send_email(from => $from,
-             to => $to,
-             subject => $subject,
-             body => $notify_template->fill_in( PACKAGE =>
-                                                'FS::notify_template::_template'                                              ),
-            );
+#sub notify {
+#  my ($self, $template, %options) = @_;
+
+#  return unless $conf->exists($template);
+
+#  my $from = $conf->invoice_from_full($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
 
 
 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
 
@@ -4410,6 +5082,8 @@ 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.
 
@@ -4425,11 +5099,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";
@@ -4544,7 +5223,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;
 }
 
@@ -4816,18 +5497,45 @@ sub _upgrade_data { #class method
 
     while (my $cust_main = $search->fetch) {
 
 
     while (my $cust_main = $search->fetch) {
 
-      my $cust_payby = new FS::cust_payby {
-        'custnum' => $cust_main->custnum,
-        'weight'  => 1,
-        map { $_ => $cust_main->$_(); } @payfields
-      };
+      unless ( $cust_main->payby =~ /^(BILL|COMP)$/ ) {
 
 
-      my $error = $cust_payby->insert;
-      die $error if $error;
+        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;
+
+      }
+
+      # at the time we do this, also migrate paytype into cust_pay_batch
+      # so that batches that are open before the migration can still be 
+      # processed
+      my @cust_pay_batch = qsearch('cust_pay_batch', {
+          'custnum' => $cust_main->custnum,
+          'payby'   => 'CHEK',
+          'paytype' => '',
+      });
+      foreach my $cust_pay_batch (@cust_pay_batch) {
+        $cust_pay_batch->set('paytype', $cust_main->get('paytype'));
+        my $error = $cust_pay_batch->replace;
+        die "$error (setting cust_pay_batch.paytype)" if $error;
+      }
+
+      $cust_main->complimentary('Y') if $cust_main->payby eq 'COMP';
+
+      $cust_main->invoice_attn( $cust_main->payname )
+        if $cust_main->payby eq 'BILL' && $cust_main->payname;
+      $cust_main->po_number( $cust_main->payinfo )
+        if $cust_main->payby eq 'BILL' && $cust_main->payinfo;
 
       $cust_main->setfield($_, '') foreach @payfields;
 
       $cust_main->setfield($_, '') foreach @payfields;
-      $error = $cust_main->replace;
-      die $error if $error;
+      my $error = $cust_main->replace;
+      die "Error upgradging payment information for custnum ".
+          $cust_main->custnum. ": $error"
+        if $error;
 
     };
 
 
     };