Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / cust_main.pm
index 0c109ce..cb5181d 100644 (file)
@@ -11,7 +11,7 @@ use base qw( FS::cust_main::Packages
              FS::cust_main::Credit_Limit
              FS::cust_main::Merge
              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
@@ -28,10 +28,11 @@ use Date::Format;
 #use Date::Manip;
 use File::Temp; #qw( tempfile );
 use Business::CreditCard 0.28;
+use List::Util qw(min);
 use FS::UID qw( dbh driver_name );
 use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
 use FS::Cursor;
-use FS::Misc qw( generate_ps do_print money_pretty );
+use FS::Misc qw( generate_ps do_print money_pretty card_types );
 use FS::Msgcat qw(gettext);
 use FS::CurrentUser;
 use FS::TicketSystem;
@@ -97,12 +98,15 @@ our @encrypted_fields = ('payinfo', 'paycvv');
 sub nohistory_fields { ('payinfo', 'paycvv'); }
 
 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;
-  #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 {
@@ -468,7 +472,8 @@ sub insert {
   $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";
@@ -1354,6 +1359,14 @@ sub replace {
          || $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
@@ -1759,7 +1772,7 @@ sub check {
     || $self->ut_flag('message_noemail')
     || $self->ut_enum('locale', [ '', FS::Locales->locales ])
     || $self->ut_currencyn('currency')
-    || $self->ut_alphan('po_number')
+    || $self->ut_textn('po_number')
     || $self->ut_enum('complimentary', [ '', 'Y' ])
     || $self->ut_flag('invoice_ship_address')
     || $self->ut_flag('invoice_dest')
@@ -1842,238 +1855,6 @@ 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('');
-  }
-
-  $error =    $self->ut_numbern('paystart_month')
-           || $self->ut_numbern('paystart_year')
-           || $self->ut_numbern('payissue')
-           || $self->ut_textn('paytype')
-  ;
-  return $error if $error;
-
-  if ( $self->payip eq '' ) {
-    $self->payip('');
-  } else {
-    $error = $self->ut_ip('payip');
-    return $error if $error;
-  }
-
-  # 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' ) {
-
-    $error = $self->ut_textn('payinfo');
-    return "Illegal P.O. number: ". $self->payinfo if $error;
-    $self->paycvv('');
-
-  } elsif ( $self->payby eq 'COMP' ) {
-
-    my $curuser = $FS::CurrentUser::CurrentUser;
-    if (    ! $self->custnum
-         && ! $curuser->access_right('Complimentary customer')
-       )
-    {
-      return "You are not permitted to create complimentary accounts."
-    }
-
-    $error = $self->ut_textn('payinfo');
-    return "Illegal comp account issuer: ". $self->payinfo if $error;
-    $self->paycvv('');
-
-  } elsif ( $self->payby eq 'PREPAY' ) {
-
-    my $payinfo = $self->payinfo;
-    $payinfo =~ s/\W//g; #anything else would just confuse things
-    $self->payinfo($payinfo);
-    $error = $self->ut_alpha('payinfo');
-    return "Illegal prepayment identifier: ". $self->payinfo if $error;
-    return "Unknown prepayment identifier"
-      unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
-    $self->paycvv('');
-
-  }
-
-  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?
-      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 ) );
-  }
-
-  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);
-    }
-
-  }
-
-  ### end of stuff moved to cust_payby
-
   return "Please select an invoicing locale"
     if ! $self->locale
     && ! $self->custnum
@@ -2092,6 +1873,25 @@ sub check {
   $self->SUPER::check;
 }
 
+sub check_payinfo_cardtype {
+  my $self = shift;
+
+  return '' unless $self->payby =~ /^(CARD|DCRD)$/;
+
+  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.
@@ -2154,8 +1954,13 @@ Returns all locations (see L<FS::cust_location>) for this customer.
 
 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
@@ -2186,7 +1991,9 @@ sub cust_payby {
     'hashref'  => { 'custnum' => $self->custnum },
     'order_by' => "ORDER BY payby IN ('CARD','CHEK') DESC, weight ASC",
   };
-  $search->{'extra_sql'} = ' AND payby IN ( ' . join(',', map { dbh->quote($_) } @payby) . ' ) '
+  $search->{'extra_sql'} = ' AND payby IN ( '.
+                               join(',', map dbh->quote($_), @payby).
+                             ' ) '
     if @payby;
 
   qsearch($search);
@@ -2382,6 +2189,8 @@ sub cancel {
 }
 
 sub _banned_pay_hashref {
+  die 'cust_main->_banned_pay_hashref deprecated';
+
   my $self = shift;
 
   my %payby2ban = (
@@ -2529,9 +2338,12 @@ Removes the I<paycvv> field from the database directly.
 
 If there is an error, returns the error, otherwise returns false.
 
+DEPRECATED.  Use L</remove_cvv_from_cust_payby> instead.
+
 =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;
@@ -2858,6 +2670,7 @@ For electronic check transactions:
 
 =cut
 
+#XXX i need to be updated for 4.x+
 sub payment_info {
   my $self = shift;
 
@@ -2901,62 +2714,34 @@ sub payment_info {
 
 =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;
-  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
 
-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
 
-# 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;
-  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 ) = @_;
 
@@ -3096,6 +2881,73 @@ sub invoicing_list_emailonly_scalar {
   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,
+        $contact->firstlast . ' <' . $contact_email->emailaddress . '>';
+    }
+  }
+  @emails;
+}
+
 =item referral_custnum_cust_main
 
 Returns the customer who referred this customer (or the empty string, if
@@ -3591,9 +3443,12 @@ Returns all the credits (see L<FS::cust_credit>) for this customer.
 
 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
@@ -3803,34 +3658,16 @@ cust_main-default_agent_custid is set and it has a value, custnum otherwise.
 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) || '';
-  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 . 
-           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;
   }
@@ -4037,13 +3874,17 @@ sub status { shift->cust_status(@_); }
 
 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;
-    return $status if $sth->fetchrow_arrayref->[0];
+    if ( $sth->fetchrow_arrayref->[0] ) {
+      $self->hashref->{cust_status} = $status;
+      return $status;
+    }
   }
 }
 
@@ -4407,8 +4248,10 @@ sub payment_history {
 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 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
@@ -4420,39 +4263,77 @@ blanks when replacing.
 
 Accepts the following named parameters:
 
-payment_payby - either CARD or CHEK
+=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
 
-auto - save as an automatic payment type (CARD/CHEK if true, DCRD/DCHK if false)
+=item payip
 
-payinfo - required
+optional, will be preserved when replacing
 
-paymask - optional, but should be specified for anything that might be tokenized, will be preserved when replacing
+=item paydate
 
-payname - required
+CARD only, required
 
-payip - optional, will be preserved when replacing
+=item bill_location
 
-paydate - CARD only, required
+CARD only, required, FS::cust_location object
 
-bill_location - CARD only, required, FS::cust_location object
+=item paystart_month
 
-paystart_month - CARD only, optional, will be preserved when replacing
+CARD only, optional, will be preserved when replacing
 
-paystart_year - CARD only, optional, will be preserved when replacing
+=item paystart_year
 
-payissue - CARD only, optional, will be preserved when replacing
+CARD only, optional, will be preserved when replacing
 
-paycvv - CARD only, only used if conf cvv-save is set appropriately
+=item payissue
 
-paytype - CHEK only
+CARD only, optional, will be preserved when replacing
 
-paystate - CHEK only
+=item paycvv
+
+CARD only, only used if conf cvv-save is set appropriately
+
+=item paytype
+
+CHEK only
+
+=item paystate
+
+CHEK only
+
+=back
 
 =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)
+# =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
@@ -4483,8 +4364,7 @@ sub save_cust_payby {
     @check_existing = qw( CHEK DCHK );
   }
 
-  # every automatic payment type added here will be marked primary
-  $new->set( 'weight' => $opt{'auto'} ? 1 : '' );
+  $new->set( 'weight' => $opt{'auto'} ? $opt{'weight'} : '' );
 
   # basic fields
   $new->payinfo($opt{'payinfo'}); # sets default paymask, but not if it's already tokenized
@@ -4496,7 +4376,10 @@ sub save_cust_payby {
 
   # 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') ) {
+       ( (grep { $_ eq cardtype($opt{'payinfo'}) } $conf->config('cvv-save')) 
+         || $conf->exists('business-onlinepayment-verification') 
+       )
+  ) {
     $new->set( 'paycvv' => $opt{'paycvv'} );
   } else {
     $new->set( 'paycvv' => '');
@@ -4578,7 +4461,7 @@ PAYBYLOOP:
       # if we got this far, we're definitely replacing
       $old = $cust_payby;
       last PAYBYLOOP;
-    }
+    } #PAYBYLOOP
   }
 
   if ($old) {
@@ -4621,7 +4504,8 @@ PAYBYLOOP:
       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 );
+      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;
@@ -4642,6 +4526,33 @@ PAYBYLOOP:
 
 }
 
+=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
@@ -4947,103 +4858,6 @@ sub search {
 
 =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->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
 
 Generates a templated notification to the customer (see L<Text::Template>).
@@ -5059,10 +4873,6 @@ 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.
 
-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
@@ -5089,27 +4899,6 @@ sub generate_letter {
     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}->{$_};
@@ -5381,9 +5170,7 @@ sub process_bill_and_collect {
 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') ) {
@@ -5392,29 +5179,6 @@ sub _upgrade_data { #class method
     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;
@@ -5449,71 +5213,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) {
-
-      unless ( $cust_main->payby =~ /^(BILL|COMP)$/ ) {
-
-        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;
-      my $error = $cust_main->replace;
-      die "Error upgradging payment information for custnum ".
-          $cust_main->custnum. ": $error"
-        if $error;
-
-    };
-
-    FS::upgrade_journal->set_done('cust_main__cust_payby');
-  }
-
   $class->_upgrade_otaker(%opts);
 
 }
@@ -5535,13 +5234,8 @@ card types.
 
 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.
 
-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