add latitude/longitude to prospects, customers and package locations, RT#15539
[freeside.git] / FS / FS / cust_main.pm
index f9204ca..f49eb69 100644 (file)
@@ -3,16 +3,18 @@ package FS::cust_main;
 require 5.006;
 use strict;
              #FS::cust_main:_Marketgear when they're ready to move to 2.1
-use base qw( FS::cust_main::Packages
+use base qw( FS::cust_main::Packages FS::cust_main::Status
              FS::cust_main::Billing FS::cust_main::Billing_Realtime
+             FS::cust_main::Billing_Discount
              FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
+             FS::geocode_Mixin
              FS::Record
            );
 use vars qw( $DEBUG $me $conf
              @encrypted_fields
              $import
              $ignore_expired_card $ignore_illegal_zip $ignore_banned_card
-             $skip_fuzzyfiles @fuzzyfields
+             $skip_fuzzyfiles
              @paytypes
            );
 use Carp;
@@ -25,7 +27,7 @@ use Tie::IxHash;
 use Digest::MD5 qw(md5_base64);
 use Date::Format;
 #use Date::Manip;
-use File::Temp qw( tempfile );
+use File::Temp; #qw( tempfile );
 use Business::CreditCard 0.28;
 use Locale::Country;
 use FS::UID qw( getotaker dbh driver_name );
@@ -33,10 +35,12 @@ use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
 use FS::Misc qw( generate_email send_email generate_ps do_print );
 use FS::Msgcat qw(gettext);
 use FS::CurrentUser;
+use FS::TicketSystem;
 use FS::payby;
 use FS::cust_pkg;
 use FS::cust_svc;
 use FS::cust_bill;
+use FS::legacy_cust_bill;
 use FS::cust_pay;
 use FS::cust_pay_pending;
 use FS::cust_pay_void;
@@ -62,7 +66,10 @@ use FS::type_pkgs;
 use FS::payment_gateway;
 use FS::agent_payment_gateway;
 use FS::banned_pay;
-use FS::TicketSystem;
+use FS::cust_main_note;
+use FS::cust_attachment;
+use FS::contact;
+use FS::Locales;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -76,7 +83,6 @@ $ignore_illegal_zip = 0;
 $ignore_banned_card = 0;
 
 $skip_fuzzyfiles = 0;
-@fuzzyfields = ( 'first', 'last', 'company', 'address1' );
 
 @encrypted_fields = ('payinfo', 'paycvv');
 sub nohistory_fields { ('payinfo', 'paycvv'); }
@@ -205,6 +211,10 @@ phone (optional)
 
 phone (optional)
 
+=item mobile
+
+phone (optional)
+
 =item ship_first
 
 Shipping first name
@@ -251,6 +261,10 @@ phone (optional)
 
 phone (optional)
 
+=item ship_mobile
+
+phone (optional)
+
 =item payby
 
 Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
@@ -319,6 +333,14 @@ A suggestion to events (see L<FS::part_bill_event">) to delay until this unix ti
 
 Discourage individual CDR printing, empty or `Y'
 
+=item edit_subject
+
+Allow self-service editing of ticket subjects, empty or 'Y'
+
+=item calling_list_exempt
+
+Do not call, empty or 'Y'
+
 =back
 
 =head1 METHODS
@@ -364,7 +386,8 @@ 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> and I<tax_exemption>.
+Currently available options are: I<depend_jobnum>, I<noexport>,
+I<tax_exemption> and I<prospectnum>.
 
 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).
@@ -378,6 +401,8 @@ the B<reexport> method.)
 The I<tax_exemption> option can be set to an arrayref of tax names.
 FS::cust_main_exemption records will be created and inserted.
 
+If I<prospectnum> is set, moves contacts and locations from that prospect.
+
 =cut
 
 sub insert {
@@ -476,16 +501,41 @@ sub insert {
     }
   }
 
-  if ( $invoicing_list ) {
-    $error = $self->check_invoicing_list( $invoicing_list );
+  my $prospectnum = delete $options{'prospectnum'};
+  if ( $prospectnum ) {
+
+    warn "  moving contacts and locations from prospect $prospectnum\n"
+      if $DEBUG > 1;
+
+    my $prospect_main =
+      qsearchs('prospect_main', { 'prospectnum' => $prospectnum } );
+    unless ( $prospect_main ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Unknown prospectnum $prospectnum";
+    }
+    $prospect_main->custnum($self->custnum);
+    $prospect_main->disabled('Y');
+    my $error = $prospect_main->replace;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      #return "checking invoicing_list (transaction rolled back): $error";
       return $error;
     }
-    $self->invoicing_list( $invoicing_list );
-  }
 
+    my @contact = $prospect_main->contact;
+    my @cust_location = $prospect_main->cust_location;
+    my @qual = $prospect_main->qual;
+
+    foreach my $r ( @contact, @cust_location, @qual ) {
+      $r->prospectnum('');
+      $r->custnum($self->custnum);
+      my $error = $r->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
+  }
 
   warn "  setting cust_main_exemption\n"
     if $DEBUG > 1;
@@ -739,7 +789,7 @@ sub get_prepay {
 
     $prepay_credit = qsearchs(
       'prepay_credit',
-      { 'identifier' => $prepay_credit },
+      { 'identifier' => $identifier },
       '',
       'FOR UPDATE'
     );
@@ -1158,6 +1208,227 @@ sub delete {
 
 }
 
+=item merge NEW_CUSTNUM [ , OPTION => VALUE ... ]
+
+This merges this customer into the provided new custnum, and then deletes the
+customer.  If there is an error, returns the error, otherwise returns false.
+
+The source customer's name, company name, phone numbers, agent,
+referring customer, customer class, advertising source, order taker, and
+billing information (except balance) are discarded.
+
+All packages are moved to the target customer.  Packages with package locations
+are preserved.  Packages without package locations are moved to a new package
+location with the source customer's service/shipping address.
+
+All invoices, statements, payments, credits and refunds are moved to the target
+customer.  The source customer's balance is added to the target customer.
+
+All notes, attachments, tickets and customer tags are moved to the target
+customer.
+
+Change history is not currently moved.
+
+=cut
+
+sub merge {
+  my( $self, $new_custnum, %opt ) = @_;
+
+  return "Can't merge a customer into self" if $self->custnum == $new_custnum;
+
+  unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
+    return "Invalid new customer number: $new_custnum";
+  }
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) {
+     $dbh->rollback if $oldAutoCommit;
+     return "Can't merge a master agent customer";
+  }
+
+  #use FS::access_user
+  if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) {
+     $dbh->rollback if $oldAutoCommit;
+     return "Can't merge a master employee customer";
+  }
+
+  if ( qsearch('cust_pay_pending', { 'custnum' => $self->custnum,
+                                     'status'  => { op=>'!=', value=>'done' },
+                                   }
+              )
+  ) {
+     $dbh->rollback if $oldAutoCommit;
+     return "Can't merge a customer with pending payments";
+  }
+
+  tie my %financial_tables, 'Tie::IxHash',
+    'cust_bill'      => 'invoices',
+    'cust_statement' => 'statements',
+    'cust_credit'    => 'credits',
+    'cust_pay'       => 'payments',
+    'cust_pay_void'  => 'voided payments',
+    'cust_refund'    => 'refunds',
+  ;
+   
+  foreach my $table ( keys %financial_tables ) {
+
+    my @records = $self->$table();
+
+    foreach my $record ( @records ) {
+      $record->custnum($new_custnum);
+      my $error = $record->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error merging ". $financial_tables{$table}. ": $error\n";
+      }
+    }
+
+  }
+
+  my $name = $self->ship_name;
+
+  my $locationnum = '';
+  foreach my $cust_pkg ( $self->all_pkgs ) {
+    $cust_pkg->custnum($new_custnum);
+
+    unless ( $cust_pkg->locationnum ) {
+      unless ( $locationnum ) {
+        my $cust_location = new FS::cust_location {
+          $self->location_hash,
+          'custnum' => $new_custnum,
+        };
+        my $error = $cust_location->insert;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
+        $locationnum = $cust_location->locationnum;
+      }
+      $cust_pkg->locationnum($locationnum);
+    }
+
+    my $error = $cust_pkg->replace;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+    # add customer (ship) name to svc_phone.phone_name if blank
+    my @cust_svc = $cust_pkg->cust_svc;
+    foreach my $cust_svc (@cust_svc) {
+      my($label, $value, $svcdb) = $cust_svc->label;
+      next unless $svcdb eq 'svc_phone';
+      my $svc_phone = $cust_svc->svc_x;
+      next if $svc_phone->phone_name;
+      $svc_phone->phone_name($name);
+      my $error = $svc_phone->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
+  }
+
+  #not considered:
+  # cust_tax_exempt (texas tax exemptions)
+  # cust_recon (some sort of not-well understood thing for OnPac)
+
+  #these are moved over
+  foreach my $table (qw(
+    cust_tag cust_location contact cust_attachment cust_main_note
+    cust_tax_adjustment cust_pay_batch queue
+  )) {
+    foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
+      $record->custnum($new_custnum);
+      my $error = $record->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+  }
+
+  #these aren't preserved
+  foreach my $table (qw(
+    cust_main_exemption cust_main_invoice
+  )) {
+    foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
+      my $error = $record->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+  }
+
+
+  my $sth = $dbh->prepare(
+    'UPDATE cust_main SET referral_custnum = ? WHERE referral_custnum = ?'
+  ) or do {
+    my $errstr = $dbh->errstr;
+    $dbh->rollback if $oldAutoCommit;
+    return $errstr;
+  };
+  $sth->execute($new_custnum, $self->custnum) or do {
+    my $errstr = $sth->errstr;
+    $dbh->rollback if $oldAutoCommit;
+    return $errstr;
+  };
+
+  #tickets
+
+  my $ticket_dbh = '';
+  if ($conf->config('ticket_system') eq 'RT_Internal') {
+    $ticket_dbh = $dbh;
+  } elsif ($conf->config('ticket_system') eq 'RT_External') {
+    my ($datasrc, $user, $pass) = $conf->config('ticket_system-rt_external_datasrc');
+    $ticket_dbh = DBI->connect($datasrc, $user, $pass, { 'ChopBlanks' => 1 });
+      #or die "RT_External DBI->connect error: $DBI::errstr\n";
+  }
+
+  if ( $ticket_dbh ) {
+
+    my $ticket_sth = $ticket_dbh->prepare(
+      'UPDATE Links SET Target = ? WHERE Target = ?'
+    ) or do {
+      my $errstr = $ticket_dbh->errstr;
+      $dbh->rollback if $oldAutoCommit;
+      return $errstr;
+    };
+    $ticket_sth->execute('freeside://freeside/cust_main/'.$new_custnum,
+                         'freeside://freeside/cust_main/'.$self->custnum)
+      or do {
+        my $errstr = $ticket_sth->errstr;
+        $dbh->rollback if $oldAutoCommit;
+        return $errstr;
+      };
+
+  }
+
+  #delete the customer record
+
+  my $error = $self->delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
 
 
@@ -1199,11 +1470,50 @@ sub replace {
     return "You are not permitted to create complimentary accounts.";
   }
 
+  if ( $old->get('geocode') && $old->get('geocode') eq $self->get('geocode')
+       && $conf->exists('enable_taxproducts')
+     )
+  {
+    my $pre = ($conf->exists('tax-ship_address') && $self->ship_zip)
+                ? 'ship_' : '';
+    $self->set('geocode', '')
+      if $old->get($pre.'zip') ne $self->get($pre.'zip')
+      && length($self->get($pre.'zip')) >= 10;
+  }
+
+  for my $pre ( grep $old->get($_.'coord_auto'), ( '', 'ship_' ) ) {
+
+    $self->set($pre.'coord_auto', '') && next
+      if $self->get($pre.'latitude') && $self->get($pre.'longitude')
+      && (    $self->get($pre.'latitude')  != $old->get($pre.'latitude')
+           || $self->get($pre.'longitude') != $old->get($pre.'longitude')
+         );
+
+    $self->set_coord($pre)
+      if $old->get($pre.'address1') ne $self->get($pre.'address1')
+      || $old->get($pre.'city')     ne $self->get($pre.'city')
+      || $old->get($pre.'state')    ne $self->get($pre.'state')
+      || $old->get($pre.'country')  ne $self->get($pre.'country');
+
+  }
+
+  $self->set_coord
+    if ! $self->coord_auto && ! $self->latitude && ! $self->longitude;
+
+  $self->set_coord('ship_')
+    if $self->has_ship_address && ! $self->ship_coord_auto
+    && ! $self->ship_latitude && ! $self->ship_longitude;
+
   local($ignore_expired_card) = 1
     if $old->payby  =~ /^(CARD|DCRD)$/
     && $self->payby =~ /^(CARD|DCRD)$/
     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
 
+  local($ignore_banned_card) = 1
+    if (    $old->payby  =~ /^(CARD|DCRD)$/ && $self->payby =~ /^(CARD|DCRD)$/
+         || $old->payby  =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
+    && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -1341,6 +1651,7 @@ Used by insert & replace to update the fuzzy search cache
 
 =cut
 
+use FS::cust_main::Search;
 sub queue_fuzzyfiles_update {
   my $self = shift;
 
@@ -1355,16 +1666,16 @@ sub queue_fuzzyfiles_update {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
-  my $error = $queue->insert( map $self->getfield($_), @fuzzyfields );
+  my $queue = new FS::queue { 'job' => 'FS::cust_main::Search::append_fuzzyfiles' };
+  my $error = $queue->insert( map $self->getfield($_), @FS::cust_main::Search::fuzzyfields );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return "queueing job (transaction rolled back): $error";
   }
 
   if ( $self->ship_last ) {
-    $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
-    $error = $queue->insert( map $self->getfield("ship_$_"), @fuzzyfields );
+    $queue = new FS::queue { 'job' => 'FS::cust_main::Search::append_fuzzyfiles' };
+    $error = $queue->insert( map $self->getfield("ship_$_"), @FS::cust_main::Search::fuzzyfields );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "queueing job (transaction rolled back): $error";
@@ -1408,6 +1719,9 @@ sub check {
     || $self->ut_textn('county')
     || $self->ut_textn('state')
     || $self->ut_country('country')
+    || $self->ut_coordn('latitude')
+    || $self->ut_coordn('longitude')
+    || $self->ut_enum('coord_auto', [ '', 'Y' ])
     || $self->ut_anything('comments')
     || $self->ut_numbern('referral_custnum')
     || $self->ut_textn('stateid')
@@ -1415,8 +1729,16 @@ sub check {
     || $self->ut_textn('invoice_terms')
     || $self->ut_alphan('geocode')
     || $self->ut_floatn('cdr_termination_percentage')
+    || $self->ut_floatn('credit_limit')
+    || $self->ut_numbern('billday')
+    || $self->ut_enum('edit_subject', [ '', 'Y' ] )
+    || $self->ut_enum('calling_list_exempt', [ '', 'Y' ] )
+    || $self->ut_enum('locale', [ '', FS::Locales->locales ])
   ;
 
+  $self->set_coord
+    unless $import || ($self->latitude && $self->longitude);
+
   #barf.  need message catalogs.  i18n.  etc.
   $error .= "Please select an advertising source."
     if $error =~ /^Illegal or empty \(numeric\) refnum: /;
@@ -1468,9 +1790,10 @@ sub check {
   }
 
   $error =
-    $self->ut_phonen('daytime', $self->country)
-    || $self->ut_phonen('night', $self->country)
-    || $self->ut_phonen('fax', $self->country)
+       $self->ut_phonen('daytime', $self->country)
+    || $self->ut_phonen('night',   $self->country)
+    || $self->ut_phonen('fax',     $self->country)
+    || $self->ut_phonen('mobile',  $self->country)
   ;
   return $error if $error;
 
@@ -1480,7 +1803,7 @@ sub check {
   }
 
   if ( $conf->exists('cust_main-require_phone')
-       && ! length($self->daytime) && ! length($self->night)
+       && ! length($self->daytime) && ! length($self->night) && ! length($self->mobile)
      ) {
 
     my $daytime_label = FS::Msgcat::_gettext('daytime') =~ /^(daytime)?$/
@@ -1489,8 +1812,12 @@ sub check {
     my $night_label = FS::Msgcat::_gettext('night') =~ /^(night)?$/
                         ? 'Night Phone'
                         : FS::Msgcat::_gettext('night');
-  
-    return "$daytime_label or $night_label is required"
+
+    my $mobile_label = FS::Msgcat::_gettext('mobile') =~ /^(mobile)?$/
+                        ? 'Mobile Phone'
+                        : FS::Msgcat::_gettext('mobile');
+
+    return "$daytime_label, $night_label or $mobile_label is required"
   
   }
 
@@ -1509,9 +1836,15 @@ sub check {
       || $self->ut_textn('ship_county')
       || $self->ut_textn('ship_state')
       || $self->ut_country('ship_country')
+      || $self->ut_coordn('ship_latitude')
+      || $self->ut_coordn('ship_longitude')
+      || $self->ut_enum('ship_coord_auto', [ '', 'Y' ] )
     ;
     return $error if $error;
 
+    $self->set_coord('ship_')
+      unless $import || ($self->ship_latitude && $self->ship_longitude);
+
     #false laziness with above
     unless ( qsearchs('cust_main_county', {
       'country' => $self->ship_country,
@@ -1528,9 +1861,10 @@ sub check {
     #eofalse
 
     $error =
-      $self->ut_phonen('ship_daytime', $self->ship_country)
-      || $self->ut_phonen('ship_night', $self->ship_country)
-      || $self->ut_phonen('ship_fax', $self->ship_country)
+         $self->ut_phonen('ship_daytime', $self->ship_country)
+      || $self->ut_phonen('ship_night',   $self->ship_country)
+      || $self->ut_phonen('ship_fax',     $self->ship_country)
+      || $self->ut_phonen('ship_mobile',  $self->ship_country)
     ;
     return $error if $error;
 
@@ -1581,7 +1915,7 @@ sub check {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/\D//g;
-    $payinfo =~ /^(\d{13,16})$/
+    $payinfo =~ /^(\d{13,16}|\d{8,9})$/
       or return gettext('invalid_card'); # . ": ". $self->payinfo;
     $payinfo = $1;
     $self->payinfo($payinfo);
@@ -1593,12 +1927,21 @@ sub check {
       && cardtype($self->payinfo) eq "Unknown";
 
     unless ( $ignore_banned_card ) {
-      my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
+      my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
       if ( $ban ) {
-        return 'Banned credit card: banned on '.
-               time2str('%a %h %o at %r', $ban->_date).
-               ' by '. $ban->otaker.
-               ' (ban# '. $ban->bannum. ')';
+        if ( $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. ')';
+        }
       }
     }
 
@@ -1643,8 +1986,11 @@ sub check {
   } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
 
     my $payinfo = $self->payinfo;
-    $payinfo =~ s/[^\d\@]//g;
-    if ( $conf->exists('echeck-nonus') ) {
+    $payinfo =~ s/[^\d\@\.]//g;
+    if ( $conf->exists('cust_main-require-bank-branch') ) {
+      $payinfo =~ /^(\d+)\@(\d+)\.(\d+)$/ or return 'invalid echeck account@branch.bank';
+      $payinfo = "$1\@$2.$3";
+    } elsif ( $conf->exists('echeck-nonus') ) {
       $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@aba';
       $payinfo = "$1\@$2";
     } else {
@@ -1654,12 +2000,19 @@ sub check {
     $self->payinfo($payinfo);
     $self->paycvv('');
 
-    my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
-    if ( $ban ) {
-      return 'Banned ACH account: banned on '.
-             time2str('%a %h %o at %r', $ban->_date).
-             ' by '. $ban->otaker.
-             ' (ban# '. $ban->bannum. ')';
+    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' ) {
@@ -1719,6 +2072,7 @@ sub check {
     } 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')
@@ -1760,7 +2114,8 @@ Returns a list of fields which have ship_ duplicates.
 sub addr_fields {
   qw( last first company
       address1 address2 city county state zip country
-      daytime night fax
+      latitude longitude
+      daytime night fax mobile
     );
 }
 
@@ -1778,22 +2133,10 @@ sub has_ship_address {
 =item location_hash
 
 Returns a list of key/value pairs, with the following keys: address1, adddress2,
-city, county, state, zip, country.  The shipping address is used if present.
+city, county, state, zip, country, and geocode.  The shipping address is used if present.
 
 =cut
 
-#geocode?  dependent on tax-ship_address config, not available in cust_location
-#mostly.  not yet then.
-
-sub location_hash {
-  my $self = shift;
-  my $prefix = $self->has_ship_address ? 'ship_' : '';
-
-  map { $_ => $self->get($prefix.$_) }
-      qw( address1 address2 city county state zip country geocode );
-      #fields that cust_location has
-}
-
 =item cust_location
 
 Returns all locations (see L<FS::cust_location>) for this customer.
@@ -1805,59 +2148,16 @@ sub cust_location {
   qsearch('cust_location', { 'custnum' => $self->custnum } );
 }
 
-=item location_label [ OPTION => VALUE ... ]
-
-Returns the label of the service location (see analog in L<FS::cust_location>) for this customer.
-
-Options are
-
-=over 4
-
-=item join_string
-
-used to separate the address elements (defaults to ', ')
-
-=item escape_function
+=item cust_contact
 
-a callback used for escaping the text of the address elements
-
-=back
+Returns all contacts (see L<FS::contact>) for this customer.
 
 =cut
 
-# false laziness with FS::cust_location::line
-
-sub location_label {
+#already used :/ sub contact {
+sub cust_contact {
   my $self = shift;
-  my %opt = @_;
-
-  my $separator = $opt{join_string} || ', ';
-  my $escape = $opt{escape_function} || sub{ shift };
-  my $line = '';
-  my $cydefault = FS::conf->new->config('countrydefault') || 'US';
-  my $prefix = length($self->ship_last) ? 'ship_' : '';
-
-  my $notfirst = 0;
-  foreach (qw ( address1 address2 ) ) {
-    my $method = "$prefix$_";
-    $line .= ($notfirst ? $separator : ''). &$escape($self->$method)
-      if $self->$method;
-    $notfirst++;
-  }
-  $notfirst = 0;
-  foreach (qw ( city county state zip ) ) {
-    my $method = "$prefix$_";
-    if ( $self->$method ) {
-      $line .= ' (' if $method eq 'county';
-      $line .= ($notfirst ? ' ' : $separator). &$escape($self->$method);
-      $line .= ' )' if $method eq 'county';
-      $notfirst++;
-    }
-  }
-  $line .= $separator. &$escape(code2country($self->country))
-    if $self->country ne $cydefault;
-
-  $line;
+  qsearch('contact', { 'custnum' => $self->custnum } );
 }
 
 =item unsuspend
@@ -1992,7 +2292,7 @@ sub cancel {
     return ( "Can't (yet) ban encrypted credit cards" )
       if $self->is_encrypted($self->payinfo);
 
-    my $ban = new FS::banned_pay $self->_banned_pay_hashref;
+    my $ban = new FS::banned_pay $self->_new_banned_pay_hashref;
     my $error = $ban->insert;
     return ( $error ) if $error;
 
@@ -2026,11 +2326,18 @@ sub _banned_pay_hashref {
 
   {
     'payby'   => $payby2ban{$self->payby},
-    'payinfo' => md5_base64($self->payinfo),
+    'payinfo' => $self->payinfo,
     #don't ever *search* on reason! #'reason'  =>
   };
 }
 
+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.
@@ -2038,13 +2345,14 @@ Returns all notes (see L<FS::cust_main_note>) for this customer.
 =cut
 
 sub notes {
-  my $self = shift;
-  #order by?
+  my($self,$orderby_classnum) = (shift,shift);
+  my $orderby = "_DATE DESC";
+  $orderby = "CLASSNUM ASC, $orderby" if $orderby_classnum;
   qsearch( 'cust_main_note',
            { 'custnum' => $self->custnum },
-          '',
-          'ORDER BY _DATE DESC'
-        );
+           '',
+           "ORDER BY $orderby",
+         );
 }
 
 =item agent
@@ -2210,6 +2518,7 @@ sub batch_card {
     'status' => 'O',
     'payby'  => FS::payby->payby2payment($payby),
   );
+  $pay_batch{agentnum} = $self->agentnum if $conf->exists('batch-spoolagent');
 
   my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
 
@@ -2227,8 +2536,9 @@ sub batch_card {
       'custnum'  => $self->custnum,
   } );
 
-  foreach (qw( address1 address2 city state zip country payby payinfo paydate
-               payname )) {
+  foreach (qw( address1 address2 city state zip country latitude longitude
+               payby payinfo paydate payname ))
+  {
     $options{$_} = '' unless exists($options{$_});
   }
 
@@ -2332,7 +2642,7 @@ sub total_owed_date {
         AND _date <= $time
   ";
 
-  sprintf( "%.2f", $self->scalar_sql($sql) );
+  sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
 
 }
 
@@ -2412,7 +2722,7 @@ sub total_unapplied_credits {
       WHERE custnum = $custnum
   ";
 
-  sprintf( "%.2f", $self->scalar_sql($sql) );
+  sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
 
 }
 
@@ -2450,7 +2760,7 @@ sub total_unapplied_payments {
       WHERE custnum = $custnum
   ";
 
-  sprintf( "%.2f", $self->scalar_sql($sql) );
+  sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
 
 }
 
@@ -2488,7 +2798,7 @@ sub total_unapplied_refunds {
       WHERE custnum = $custnum
   ";
 
-  sprintf( "%.2f", $self->scalar_sql($sql) );
+  sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
 
 }
 
@@ -2546,7 +2856,7 @@ sub balance_date_range {
   my $self = shift;
   my $sql = 'SELECT SUM('. $self->balance_date_sql(@_).
             ') FROM cust_main WHERE custnum='. $self->custnum;
-  sprintf( '%.2f', $self->scalar_sql($sql) );
+  sprintf( '%.2f', $self->scalar_sql($sql) || 0 );
 }
 
 =item balance_pkgnum PKGNUM
@@ -2690,6 +3000,60 @@ sub paydate_monthyear {
   }
 }
 
+=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.
+
+=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);
+  }
+}
+
+=item paydate_epoch_sql
+
+Class method.  Returns an SQL expression to obtain the payment expiration date
+as a number of seconds.
+
+=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"
+}
+
 =item tax_exemption TAXNAME
 
 =cut
@@ -3194,7 +3558,7 @@ sub charge {
 sub charge_postal_fee {
   my $self = shift;
 
-  my $pkgpart = $conf->config('postal_invoice-fee_pkgpart');
+  my $pkgpart = $conf->config('postal_invoice-fee_pkgpart', $self->agentnum);
   return '' unless ($pkgpart && grep { $_ eq 'POST' } $self->invoicing_list);
 
   my $cust_pkg = new FS::cust_pkg ( {
@@ -3249,6 +3613,25 @@ sub open_cust_bill {
 
 }
 
+=item legacy_cust_bill [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
+
+Returns all the legacy invoices (see L<FS::legacy_cust_bill>) for this customer.
+
+=cut
+
+sub legacy_cust_bill {
+  my $self = shift;
+
+  #return $self->num_legacy_cust_bill unless wantarray;
+
+  map { $_ } #behavior of sort undefined in scalar context
+    sort { $a->_date <=> $b->_date }
+      qsearch({ 'table'    => 'legacy_cust_bill',
+                'hashref'  => { 'custnum' => $self->custnum, },
+                'order_by' => 'ORDER BY _date ASC',
+             });
+}
+
 =item cust_statement [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
 
 Returns all the statements (see L<FS::cust_statement>) for this customer.
@@ -3483,6 +3866,9 @@ sub display_custnum {
   my $self = shift;
   if ( $conf->exists('cust_main-default_agent_custid') && $self->agent_custid ){
     return $self->agent_custid;
+  } elsif ( $conf->config('cust_main-custnum-display_prefix') ) {
+    return $conf->config('cust_main-custnum-display_prefix').
+           sprintf('%08d', $self->custnum)
   } else {
     return $self->custnum;
   }
@@ -3615,38 +4001,6 @@ Currently this only makes sense for "CCH" as DATA_VENDOR.
 
 =cut
 
-sub geocode {
-  my ($self, $data_vendor) = (shift, shift);  #always cch for now
-
-  my $geocode = $self->get('geocode');  #XXX only one data_vendor for geocode
-  return $geocode if $geocode;
-
-  my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) )
-               ? 'ship_'
-               : '';
-
-  my($zip,$plus4) = split /-/, $self->get("${prefix}zip")
-    if $self->country eq 'US';
-
-  $zip ||= '';
-  $plus4 ||= '';
-  #CCH specific location stuff
-  my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
-
-  my @cust_tax_location =
-    qsearch( {
-               'table'     => 'cust_tax_location', 
-               'hashref'   => { 'zip' => $zip, 'data_vendor' => $data_vendor },
-               'extra_sql' => $extra_sql,
-               'order_by'  => 'ORDER BY plus4hi',#overlapping with distinct ends
-             }
-           );
-  $geocode = $cust_tax_location[0]->geocode
-    if scalar(@cust_tax_location);
-
-  $geocode;
-}
-
 =item cust_status
 
 =item status
@@ -3669,13 +4023,15 @@ Returns a status string for this customer, currently:
 
 =back
 
+Behavior of inactive vs. cancelled edge cases can be adjusted with the
+cust_main-status_module configuration option.
+
 =cut
 
 sub status { shift->cust_status(@_); }
 
 sub cust_status {
   my $self = shift;
-  # prospect ordered active inactive suspended cancelled
   for my $status ( FS::cust_main->statuses() ) {
     my $method = $status.'_sql';
     my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
@@ -3707,21 +4063,11 @@ Returns a hex triplet color string for this customer's status.
 
 =cut
 
-use vars qw(%statuscolor);
-tie %statuscolor, 'Tie::IxHash',
-  'prospect'  => '7e0079', #'000000', #black?  naw, purple
-  'active'    => '00CC00', #green
-  'ordered'   => '009999', #teal? cyan?
-  'inactive'  => '0000CC', #blue
-  'suspended' => 'FF9900', #yellow
-  'cancelled' => 'FF0000', #red
-;
-
 sub statuscolor { shift->cust_statuscolor(@_); }
 
 sub cust_statuscolor {
   my $self = shift;
-  $statuscolor{$self->cust_status};
+  __PACKAGE__->statuscolors->{$self->cust_status};
 }
 
 =item tickets
@@ -3817,10 +4163,28 @@ Class method that returns the list of possible status strings for customers
 =cut
 
 sub statuses {
-  #my $self = shift; #could be class...
-  keys %statuscolor;
+  my $self = shift;
+  keys %{ $self->statuscolors };
 }
 
+=item cust_status_sql
+
+Returns an SQL fragment to determine the status of a cust_main record, as a 
+string.
+
+=cut
+
+sub cust_status_sql {
+  my $sql = 'CASE';
+  for my $status ( FS::cust_main->statuses() ) {
+    my $method = $status.'_sql';
+    $sql .= ' WHEN ('.FS::cust_main->$method.") THEN '$status'";
+  }
+  $sql .= ' END';
+  return $sql;
+}
+
+
 =item prospect_sql
 
 Returns an SQL expression identifying prospective cust_main records (customers
@@ -3844,13 +4208,14 @@ sub prospect_sql {
 =item ordered_sql
 
 Returns an SQL expression identifying ordered cust_main records (customers with
-recurring packages not yet setup).
+no active packages, but recurring packages not yet setup or one time charges
+not yet billed).
 
 =cut
 
 sub ordered_sql {
   FS::cust_main->none_active_sql.
-  " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->ordered_sql. " ) ";
+  " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->not_yet_billed_sql. " ) ";
 }
 
 =item active_sql
@@ -3909,22 +4274,7 @@ Returns an SQL expression identifying cancelled cust_main records.
 
 =cut
 
-sub cancelled_sql { cancel_sql(@_); }
-sub cancel_sql {
-
-  my $recurring_sql = FS::cust_pkg->recurring_sql;
-  my $cancelled_sql = FS::cust_pkg->cancelled_sql;
-
-  "
-        0 < ( $select_count_pkgs )
-    AND 0 < ( $select_count_pkgs AND $recurring_sql AND $cancelled_sql   )
-    AND 0 = ( $select_count_pkgs AND $recurring_sql
-                  AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
-            )
-    AND 0 = (  $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
-  ";
-
-}
+sub cancel_sql { shift->cancelled_sql(@_); }
 
 =item uncancel_sql
 =item uncancelled_sql
@@ -4034,7 +4384,7 @@ sub balance_date_sql {
 =item unapplied_payments_date_sql START_TIME [ END_TIME ]
 
 Returns an SQL fragment to retreive the total unapplied payments for this
-customer, only considering invoices with date earlier than START_TIME, and
+customer, only considering payments with date earlier than START_TIME, and
 optionally not later than END_TIME.
 
 Times are specified as SQL fragments or numeric
@@ -4102,42 +4452,6 @@ sub search {
 
 =over 4
 
-=item append_fuzzyfiles FIRSTNAME LASTNAME COMPANY ADDRESS1
-
-=cut
-
-use FS::cust_main::Search;
-sub append_fuzzyfiles {
-  #my( $first, $last, $company ) = @_;
-
-  FS::cust_main::Search::check_and_rebuild_fuzzyfiles();
-
-  use Fcntl qw(:flock);
-
-  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
-
-  foreach my $field (@fuzzyfields) {
-    my $value = shift;
-
-    if ( $value ) {
-
-      open(CACHE,">>$dir/cust_main.$field")
-        or die "can't open $dir/cust_main.$field: $!";
-      flock(CACHE,LOCK_EX)
-        or die "can't lock $dir/cust_main.$field: $!";
-
-      print CACHE "$value\n";
-
-      flock(CACHE,LOCK_UN)
-        or die "can't unlock $dir/cust_main.$field: $!";
-      close CACHE;
-    }
-
-  }
-
-  1;
-}
-
 =item batch_charge
 
 =cut
@@ -4558,7 +4872,7 @@ sub _agent_plandata {
         " ORDER BY
            CASE WHEN part_event_condition_option.optionname IS NULL
            THEN -1
-          ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue').
+           ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue').
         " END
           , part_event.weight".
         " LIMIT 1"
@@ -4608,9 +4922,24 @@ sub process_bill_and_collect {
 sub _upgrade_data { #class method
   my ($class, %opts) = @_;
 
-  my $sql = 'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL';
-  my $sth = dbh->prepare($sql) or die dbh->errstr;
-  $sth->execute or die $sth->errstr;
+  my @statements = (
+    'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL',
+    'UPDATE cust_main SET signupdate = (SELECT signupdate FROM h_cust_main WHERE signupdate IS NOT NULL AND h_cust_main.custnum = cust_main.custnum ORDER BY historynum DESC LIMIT 1) WHERE signupdate IS NULL',
+  );
+  # 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) = '-'";
+  }
+
+  foreach my $sql ( @statements ) {
+    my $sth = dbh->prepare($sql) or die dbh->errstr;
+    $sth->execute or die $sth->errstr;
+  }
 
   local($ignore_expired_card) = 1;
   local($ignore_illegal_zip) = 1;