RT#34078: Payment History Report / Statement [various fixes, integrated with selfservice]
[freeside.git] / FS / FS / cust_main.pm
index 294546c..46df3ff 100644 (file)
@@ -24,7 +24,6 @@ use Scalar::Util qw( blessed );
 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 );
@@ -71,11 +70,12 @@ use FS::agent_payment_gateway;
 use FS::banned_pay;
 use FS::cust_main_note;
 use FS::cust_attachment;
-use FS::contact;
+use FS::cust_contact;
 use FS::Locales;
 use FS::upgrade_journal;
 use FS::sales;
 use FS::cust_payby;
+use FS::contact;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -92,11 +92,11 @@ our $skip_fuzzyfiles = 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 @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 { 
@@ -279,6 +279,10 @@ Allow self-service editing of ticket subjects, 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
@@ -331,7 +335,7 @@ invoicing_list destination to the newly-created svc_acct.  Here's an example:
   $cust_main->insert( {}, [ $email, 'POST' ] );
 
 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).
@@ -351,6 +355,14 @@ 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_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 {
@@ -378,7 +390,7 @@ sub insert {
   my $payby = '';
   if ( $self->payby eq 'PREPAY' ) {
 
-    $self->payby('BILL');
+    $self->payby(''); #'BILL');
     $prepay_identifier = $self->payinfo;
     $self->payinfo('');
 
@@ -400,10 +412,10 @@ sub insert {
 
     $payby = 'PREP' if $amount;
 
-  } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|PPAL)$/ ) {
+  } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|MCHK|PPAL)$/ ) {
 
     $payby = $1;
-    $self->payby('BILL');
+    $self->payby(''); #'BILL');
     $amount = $self->paid;
 
   }
@@ -529,11 +541,23 @@ sub insert {
       return $error;
     }
 
-    my @contact = $prospect_main->contact;
+    foreach my $prospect_contact ( $prospect_main->prospect_contact ) {
+      my $cust_contact = new FS::cust_contact {
+        'custnum' => $self->custnum,
+        map { $_ => $prospect_contact->$_() } qw( contactnum classnum comment )
+      };
+      my $error =  $cust_contact->insert
+                || $prospect_contact->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
     my @cust_location = $prospect_main->cust_location;
     my @qual = $prospect_main->qual;
 
-    foreach my $r ( @contact, @cust_location, @qual ) {
+    foreach my $r ( @cust_location, @qual ) {
       $r->prospectnum('');
       $r->custnum($self->custnum);
       my $error = $r->replace;
@@ -545,8 +569,10 @@ sub insert {
 
   }
 
-  my $contact = delete $options{'contact'};
-  if ( $contact ) {
+  warn "  setting contacts\n"
+    if $DEBUG > 1;
+
+  if ( my $contact = delete $options{'contact'} ) {
 
     foreach my $c ( @$contact ) {
       $c->custnum($self->custnum);
@@ -558,6 +584,34 @@ 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 ( 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"
@@ -1110,10 +1164,9 @@ sub delete {
 
   #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
-    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 } ) ) {
@@ -1238,13 +1291,10 @@ sub replace {
     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)$/
@@ -1273,8 +1323,8 @@ sub replace {
   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);
@@ -1391,21 +1441,19 @@ 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;
     }
+
   }
 
   unless ( $import || $skip_fuzzyfiles ) {
@@ -1543,6 +1591,9 @@ 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_enum('complimentary', [ '', 'Y' ])
+    || $self->ut_flag('invoice_ship_address')
   ;
 
   foreach (qw(company ship_company)) {
@@ -1587,6 +1638,11 @@ sub check {
     $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 =
@@ -1797,6 +1853,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?
@@ -1862,6 +1923,21 @@ sub check {
   $self->SUPER::check;
 }
 
+=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.
@@ -1915,14 +1991,13 @@ sub cust_location {
 
 =item cust_contact
 
-Returns all contacts (see L<FS::contact>) for this customer.
+Returns all contact associations (see L<FS::cust_contact>) for this customer.
 
 =cut
 
-#already used :/ sub contact {
 sub cust_contact {
   my $self = shift;
-  qsearch('contact', { 'custnum' => $self->custnum } );
+  qsearch('cust_contact', { 'custnum' => $self->custnum } );
 }
 
 =item cust_payby
@@ -1936,10 +2011,21 @@ sub cust_payby {
   qsearch({
     'table'    => 'cust_payby',
     'hashref'  => { 'custnum' => $self->custnum },
-    'order_by' => 'ORDER BY weight ASC',
+    'order_by' => "ORDER BY payby IN ('CARD','CHEK') DESC, weight ASC",
   });
 }
 
+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
 
 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
@@ -2078,16 +2164,21 @@ sub cancel {
   return ( 'access denied' )
     unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
 
-  if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
+  if ( $opt{'ban'} ) {
+
+    foreach my $cust_payby ( $self->cust_payby ) {
 
-    #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);
+      #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 $self->_new_banned_pay_hashref;
-    my $error = $ban->insert;
-    return ( $error ) if $error;
+      my $ban = new FS::banned_pay $cust_payby->_new_banned_pay_hashref;
+      my $error = $ban->insert;
+      return ( $error ) if $error;
+
+    }
 
   }
 
@@ -2124,13 +2215,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.
@@ -2139,8 +2223,8 @@ Returns all notes (see L<FS::cust_main_note>) for this customer.
 
 sub notes {
   my($self,$orderby_classnum) = (shift,shift);
-  my $orderby = "_DATE DESC";
-  $orderby = "CLASSNUM ASC, $orderby" if $orderby_classnum;
+  my $orderby = "sticky DESC, _date DESC";
+  $orderby = "classnum ASC, $orderby" if $orderby_classnum;
   qsearch( 'cust_main_note',
            { 'custnum' => $self->custnum },
            '',
@@ -3074,6 +3158,7 @@ Old-style:
 
 =cut
 
+#super false laziness w/quotation::charge
 sub charge {
   my $self = shift;
   my ( $amount, $setup_cost, $quantity, $start_date, $classnum );
@@ -3081,6 +3166,7 @@ sub charge {
   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;
@@ -3103,7 +3189,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;
-  } else {
+    $separate_bill = $_[0]->{separate_bill} || '';
+  } else { # yuck
     $amount     = shift;
     $setup_cost = '';
     $quantity   = 1;
@@ -3171,6 +3258,7 @@ sub charge {
     'quantity'   => $quantity,
     'start_date' => $start_date,
     'no_auto'    => $no_auto,
+    'separate_bill' => $separate_bill,
     'locationnum'=> $locationnum,
   } );
 
@@ -3655,9 +3743,11 @@ sub service_contact {
     my $classnum = $self->scalar_sql(
       'SELECT classnum FROM contact_class WHERE classname = \'Service\''
     ) || 0; #if it's zero, qsearchs will return nothing
-    $self->{service_contact} = qsearchs('contact', { 
-        'classnum' => $classnum, 'custnum' => $self->custnum
-      }) || undef;
+    my $cust_contact = qsearchs('cust_contact', { 
+        'classnum' => $classnum,
+        'custnum'  => $self->custnum,
+    });
+    $self->{service_contact} = $cust_contact->contact if $cust_contact;
   }
   $self->{service_contact};
 }
@@ -3848,6 +3938,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
@@ -3984,6 +4095,179 @@ 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'} = $$item{$key};
+      $$item{$key.'_pretty'} =~ s/^(-?)/$1$money_char/;
+    }
+    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),
+    };
+    #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;
+}
+
 =back
 
 =head1 CLASS METHODS
@@ -4326,7 +4610,7 @@ sub notify {
 
   return unless $conf->exists($template);
 
-  my $from = $conf->config('invoice_from', $self->agentnum)
+  my $from = $conf->invoice_from_full($self->agentnum)
     if $conf->exists('invoice_from', $self->agentnum);
   $from = $options{from} if exists($options{from});
 
@@ -4613,6 +4897,42 @@ sub _agent_plandata {
 
 }
 
+sub process_o2m_qsearch {
+  my $self = shift;
+  my $table = shift;
+  return qsearch($table, @_) unless $table eq 'contact';
+
+  my $hashref = shift;
+  my %hash = %$hashref;
+  ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/
+    or die 'guru meditation #4343';
+
+  qsearch({ 'table'     => 'contact',
+            'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )',
+            'hashref'   => \%hash,
+            'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ).
+                           " cust_contact.custnum = $custnum "
+         });                
+}
+
+sub process_o2m_qsearchs {
+  my $self = shift;
+  my $table = shift;
+  return qsearchs($table, @_) unless $table eq 'contact';
+
+  my $hashref = shift;
+  my %hash = %$hashref;
+  ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/
+    or die 'guru meditation #2121';
+
+  qsearchs({ 'table'     => 'contact',
+             'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )',
+             'hashref'   => \%hash,
+             'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ).
+                            " cust_contact.custnum = $custnum "
+          });                
+}
+
 =item queued_bill 'custnum' => CUSTNUM [ , OPTION => VALUE ... ]
 
 Subroutine (not a method), designed to be called from the queue.
@@ -4766,18 +5086,31 @@ sub _upgrade_data { #class method
 
     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;
+
+      }
+
+      $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;
-      $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;
 
     };