Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / cust_main.pm
index dbb0144..7c7c9e2 100644 (file)
@@ -2,18 +2,21 @@ 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 FS::cust_main::Status
+             FS::cust_main::NationalID
              FS::cust_main::Billing FS::cust_main::Billing_Realtime
              FS::cust_main::Billing_Discount
+             FS::cust_main::Billing_ThirdParty
+             FS::cust_main::Location
              FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
-             FS::geocode_Mixin
+             FS::geocode_Mixin FS::Quotable_Mixin
+             FS::o2m_Common
              FS::Record
            );
 use vars qw( $DEBUG $me $conf
              @encrypted_fields
              $import
-             $ignore_expired_card $ignore_illegal_zip $ignore_banned_card
+             $ignore_expired_card $ignore_banned_card $ignore_illegal_zip
              $skip_fuzzyfiles
              @paytypes
            );
@@ -30,7 +33,7 @@ use Date::Format;
 use File::Temp; #qw( tempfile );
 use Business::CreditCard 0.28;
 use Locale::Country;
-use FS::UID qw( getotaker dbh driver_name );
+use FS::UID qw( dbh driver_name );
 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);
@@ -40,6 +43,7 @@ use FS::payby;
 use FS::cust_pkg;
 use FS::cust_svc;
 use FS::cust_bill;
+use FS::cust_bill_void;
 use FS::legacy_cust_bill;
 use FS::cust_pay;
 use FS::cust_pay_pending;
@@ -55,6 +59,7 @@ use FS::cust_main_exemption;
 use FS::cust_tax_adjustment;
 use FS::cust_tax_location;
 use FS::agent;
+use FS::agent_currency;
 use FS::cust_main_invoice;
 use FS::cust_tag;
 use FS::prepay_credit;
@@ -70,6 +75,7 @@ use FS::cust_main_note;
 use FS::cust_attachment;
 use FS::contact;
 use FS::Locales;
+use FS::upgrade_journal;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -79,7 +85,6 @@ $me = '[FS::cust_main]';
 
 $import = 0;
 $ignore_expired_card = 0;
-$ignore_illegal_zip = 0;
 $ignore_banned_card = 0;
 
 $skip_fuzzyfiles = 0;
@@ -177,28 +182,6 @@ Cocial security number (optional)
 
 (optional)
 
-=item address1
-
-=item address2
-
-(optional)
-
-=item city
-
-=item county
-
-(optional, see L<FS::cust_main_county>)
-
-=item state
-
-(see L<FS::cust_main_county>)
-
-=item zip
-
-=item country
-
-(see L<FS::cust_main_county>)
-
 =item daytime
 
 phone (optional)
@@ -215,56 +198,6 @@ phone (optional)
 
 phone (optional)
 
-=item ship_first
-
-Shipping first name
-
-=item ship_last
-
-Shipping last name
-
-=item ship_company
-
-(optional)
-
-=item ship_address1
-
-=item ship_address2
-
-(optional)
-
-=item ship_city
-
-=item ship_county
-
-(optional, see L<FS::cust_main_county>)
-
-=item ship_state
-
-(see L<FS::cust_main_county>)
-
-=item ship_zip
-
-=item ship_country
-
-(see L<FS::cust_main_county>)
-
-=item ship_daytime
-
-phone (optional)
-
-=item ship_night
-
-phone (optional)
-
-=item ship_fax
-
-phone (optional)
-
-=item ship_mobile
-
-phone (optional)
-
 =item payby
 
 Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
@@ -363,6 +296,12 @@ sub table { 'cust_main'; }
 Adds this customer to the database.  If there is an error, returns the error,
 otherwise returns false.
 
+Usually the customer's location will not yet exist in the database, and
+the C<bill_location> and C<ship_location> pseudo-fields must be set to 
+uninserted L<FS::cust_location> objects.  These will be inserted and linked
+(in both directions) to the new customer record.  If they're references 
+to the same object, they will become the same location.
+
 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
 are inserted atomicly, or the transaction is rolled back.  Passing an empty
@@ -398,8 +337,9 @@ The I<noexport> option is deprecated.  If I<noexport> is set true, no
 provisioning jobs (exports) are scheduled.  (You can schedule them later with
 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.
+The I<tax_exemption> option can be set to an arrayref of tax names or a hashref
+of tax names and exemption numbers.  FS::cust_main_exemption records will be
+created and inserted.
 
 If I<prospectnum> is set, moves contacts and locations from that prospect.
 
@@ -452,7 +392,7 @@ sub insert {
 
     $payby = 'PREP' if $amount;
 
-  } elsif ( $self->payby =~ /^(CASH|WEST|MCRD)$/ ) {
+  } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|PPAL)$/ ) {
 
     $payby = $1;
     $self->payby('BILL');
@@ -460,13 +400,47 @@ sub insert {
 
   }
 
+  # insert locations
+  foreach my $l (qw(bill_location ship_location)) {
+    my $loc = delete $self->hashref->{$l};
+    # XXX if we're moving a prospect's locations, do that here
+    if ( !$loc ) {
+      return "$l not set";
+    }
+    
+    if ( !$loc->locationnum ) {
+      # warn the location that we're going to insert it with no custnum
+      $loc->set(custnum_pending => 1);
+      warn "  inserting $l\n"
+        if $DEBUG > 1;
+      my $error = $loc->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        my $label = $l eq 'ship_location' ? 'service' : 'billing';
+        return "$error (in $label location)";
+      }
+    }
+    elsif ( ($loc->custnum || 0) > 0 or $loc->prospectnum ) {
+      # then it somehow belongs to another customer--shouldn't happen
+      $dbh->rollback if $oldAutoCommit;
+      return "$l belongs to customer ".$loc->custnum;
+    }
+    # else it already belongs to this customer 
+    # (happens when ship_location is identical to bill_location)
+
+    $self->set($l.'num', $loc->locationnum);
+
+    if ( $self->get($l.'num') eq '' ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "$l not set";
+    }
+  }
+
   warn "  inserting $self\n"
     if $DEBUG > 1;
 
   $self->signupdate(time) unless $self->signupdate;
 
-  $self->censusyear($conf->config('census_year')) if $self->censustract;
-
   $self->auto_agent_custid()
     if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
 
@@ -477,6 +451,22 @@ sub insert {
     return $error;
   }
 
+  # now set cust_location.custnum
+  foreach my $l (qw(bill_location ship_location)) {
+    warn "  setting $l.custnum\n"
+      if $DEBUG > 1;
+    my $loc = $self->$l;
+    unless ( $loc->custnum ) {
+      $loc->set(custnum => $self->custnum);
+      $error ||= $loc->replace;
+    }
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "error setting $l custnum: $error";
+    }
+  }
+
   warn "  setting invoicing list\n"
     if $DEBUG > 1;
 
@@ -544,10 +534,15 @@ sub insert {
 
   my $tax_exemption = delete $options{'tax_exemption'};
   if ( $tax_exemption ) {
-    foreach my $taxname ( @$tax_exemption ) {
+
+    $tax_exemption = { map { $_ => '' } @$tax_exemption }
+      if ref($tax_exemption) eq 'ARRAY';
+
+    foreach my $taxname ( keys %$tax_exemption ) {
       my $cust_main_exemption = new FS::cust_main_exemption {
-        'custnum' => $self->custnum,
-        'taxname' => $taxname,
+        'custnum'       => $self->custnum,
+        'taxname'       => $taxname,
+        'exempt_number' => $tax_exemption->{$taxname},
       };
       my $error = $cust_main_exemption->insert;
       if ( $error ) {
@@ -557,14 +552,6 @@ sub insert {
     }
   }
 
-  if ( $self->can('start_copy_skel') ) {
-    my $error = $self->start_copy_skel;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }
-  }
-
   warn "  ordering packages\n"
     if $DEBUG > 1;
 
@@ -987,47 +974,6 @@ sub insert_cust_pay {
 
 }
 
-=item reexport
-
-This method is deprecated.  See the I<depend_jobnum> option to the insert and
-order_pkgs methods for a better way to defer provisioning.
-
-Re-schedules all exports by calling the B<reexport> method of all associated
-packages (see L<FS::cust_pkg>).  If there is an error, returns the error;
-otherwise returns false.
-
-=cut
-
-sub reexport {
-  my $self = shift;
-
-  carp "WARNING: FS::cust_main::reexport is deprectated; ".
-       "use the depend_jobnum option to insert or order_pkgs to delay export";
-
-  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;
-
-  foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
-    my $error = $cust_pkg->reexport;
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-  '';
-
-}
-
 =item delete [ OPTION => VALUE ... ]
 
 This deletes the customer.  If there is an error, returns the error, otherwise
@@ -1252,9 +1198,12 @@ sub merge {
 
   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";
-  }
+  my $new_cust_main = qsearchs( 'cust_main', { 'custnum' => $new_custnum } )
+    or return "Invalid new customer number: $new_custnum";
+
+  return 'Access denied: "Merge customer across agents" access right required to merge into a customer of a different agent'
+    if $self->agentnum != $new_cust_main->agentnum 
+    && ! $FS::CurrentUser::CurrentUser->access_right('Merge customer across agents');
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -1289,6 +1238,7 @@ sub merge {
 
   tie my %financial_tables, 'Tie::IxHash',
     'cust_bill'      => 'invoices',
+    'cust_bill_void' => 'voided invoices',
     'cust_statement' => 'statements',
     'cust_credit'    => 'credits',
     'cust_pay'       => 'payments',
@@ -1311,7 +1261,7 @@ sub merge {
 
   }
 
-  my $name = $self->ship_name;
+  my $name = $self->ship_name; #?
 
   my $locationnum = '';
   foreach my $cust_pkg ( $self->all_pkgs ) {
@@ -1447,10 +1397,13 @@ sub merge {
 
 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
 
-
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
+To change the customer's address, set the pseudo-fields C<bill_location> and
+C<ship_location>.  The address will still only change if at least one of the
+address fields differs from the existing values.
+
 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
 be set as the invoicing list (see L<"invoicing_list">).  Errors return as
 expected and rollback the entire transaction; it is not necessary to call 
@@ -1460,8 +1413,9 @@ check_invoicing_list first.  Here's an example:
 
 Currently available options are: I<tax_exemption>.
 
-The I<tax_exemption> option can be set to an arrayref of tax names.
-FS::cust_main_exemption records will be deleted and inserted as appropriate.
+The I<tax_exemption> option can be set to an arrayref of tax names or a hashref
+of tax names and exemption numbers.  FS::cust_main_exemption records will be
+deleted and inserted as appropriate.
 
 =cut
 
@@ -1486,42 +1440,6 @@ 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');
-
-  }
-
-  unless ( $import ) {
-    $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)$/
@@ -1532,11 +1450,10 @@ sub replace {
          || $old->payby  =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
 
-  if ( $self->censustract ne '' and $self->censustract ne $old->censustract ) {
-    # update censusyear whenever tract code changes
-    $self->censusyear($conf->config('census_year'));
-  }
-
+  return "Invoicing locale is required"
+    if $old->locale
+    && ! $self->locale
+    && $conf->exists('cust_main-require_locale');
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -1549,6 +1466,21 @@ sub replace {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  for my $l (qw(bill_location ship_location)) {
+    my $old_loc = $old->$l;
+    my $new_loc = $self->$l;
+
+    # find the existing location if there is one
+    $new_loc->set('custnum' => $self->custnum);
+    my $error = $new_loc->find_or_insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+    $self->set($l.'num', $new_loc->locationnum);
+  } #for $l
+
+  # replace the customer record
   my $error = $self->SUPER::replace($old);
 
   if ( $error ) {
@@ -1556,6 +1488,27 @@ sub replace {
     return $error;
   }
 
+  # now move packages to the new service location
+  $self->set('ship_location', ''); #flush cache
+  if ( $old->ship_locationnum and # should only be null during upgrade...
+       $old->ship_locationnum != $self->ship_locationnum ) {
+    $error = $old->ship_location->move_to($self->ship_location);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+  # don't move packages based on the billing location, but 
+  # disable it if it's no longer in use
+  if ( $old->bill_locationnum and
+       $old->bill_locationnum != $self->bill_locationnum ) {
+    $error = $old->bill_location->disable_if_unused;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
   if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
     my $invoicing_list = shift @param;
     $error = $self->check_invoicing_list( $invoicing_list );
@@ -1593,17 +1546,27 @@ sub replace {
   my $tax_exemption = delete $options{'tax_exemption'};
   if ( $tax_exemption ) {
 
+    $tax_exemption = { map { $_ => '' } @$tax_exemption }
+      if ref($tax_exemption) eq 'ARRAY';
+
     my %cust_main_exemption =
       map { $_->taxname => $_ }
           qsearch('cust_main_exemption', { 'custnum' => $old->custnum } );
 
-    foreach my $taxname ( @$tax_exemption ) {
+    foreach my $taxname ( keys %$tax_exemption ) {
 
-      next if delete $cust_main_exemption{$taxname};
+      if ( $cust_main_exemption{$taxname} && 
+           $cust_main_exemption{$taxname}->exempt_number eq $tax_exemption->{$taxname}
+         )
+      {
+        delete $cust_main_exemption{$taxname};
+        next;
+      }
 
       my $cust_main_exemption = new FS::cust_main_exemption {
-        'custnum' => $self->custnum,
-        'taxname' => $taxname,
+        'custnum'       => $self->custnum,
+        'taxname'       => $taxname,
+        'exempt_number' => $tax_exemption->{$taxname},
       };
       my $error = $cust_main_exemption->insert;
       if ( $error ) {
@@ -1647,24 +1610,7 @@ sub replace {
     }
   }
 
-  # FS::geocode_Mixin::after_replace ?
-  # though this will go away anyway once we move customer bill/service 
-  # locations into cust_location
-  # We can trigger this on any address change--just have to make sure 
-  # not to trigger it on itself.
-  if ( $conf->config('tax_district_method') and !$import 
-      and ( $self->get('ship_address1') ne $old->get('ship_address1')
-        or  $self->get('address1')      ne $old->get('address1') ) ) {
-    my $queue = new FS::queue {
-      'job'     => 'FS::geocode_Mixin::process_district_update',
-      'custnum' => $self->custnum,
-    };
-    my $error = $queue->insert( ref($self), $self->custnum );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "queueing tax district update: $error";
-    }
-  }
+  # tax district update in cust_location
 
   # cust_main exports!
 
@@ -1709,16 +1655,14 @@ sub queue_fuzzyfiles_update {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  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::Search::append_fuzzyfiles' };
-    $error = $queue->insert( map $self->getfield("ship_$_"), @FS::cust_main::Search::fuzzyfields );
+  my @locations = $self->bill_location;
+  push @locations, $self->ship_location if $self->has_ship_address;
+  foreach my $location (@locations) {
+    my $queue = new FS::queue { 
+      'job' => 'FS::cust_main::Search::append_fuzzyfiles'
+    };
+    my @args = map $location->get($_), @FS::cust_main::Search::fuzzyfields;
+    my $error = $queue->insert( @args );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "queueing job (transaction rolled back): $error";
@@ -1749,48 +1693,56 @@ sub check {
     || $self->ut_number('agentnum')
     || $self->ut_textn('agent_custid')
     || $self->ut_number('refnum')
+    || $self->ut_foreign_key('bill_locationnum', 'cust_location','locationnum')
+    || $self->ut_foreign_key('ship_locationnum', 'cust_location','locationnum')
     || $self->ut_foreign_keyn('classnum', 'cust_class', 'classnum')
     || $self->ut_textn('custbatch')
     || $self->ut_name('last')
     || $self->ut_name('first')
-    || $self->ut_snumbern('birthdate')
     || $self->ut_snumbern('signupdate')
+    || $self->ut_snumbern('birthdate')
+    || $self->ut_snumbern('spouse_birthdate')
+    || $self->ut_snumbern('anniversary_date')
     || $self->ut_textn('company')
-    || $self->ut_text('address1')
-    || $self->ut_textn('address2')
-    || $self->ut_text('city')
-    || $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_numbern('censusyear')
     || $self->ut_anything('comments')
     || $self->ut_numbern('referral_custnum')
     || $self->ut_textn('stateid')
     || $self->ut_textn('stateid_state')
     || $self->ut_textn('invoice_terms')
-    || $self->ut_alphan('geocode')
-    || $self->ut_alphan('district')
     || $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_numbern('prorate_day')
+    || $self->ut_flag('edit_subject')
+    || $self->ut_flag('calling_list_exempt')
+    || $self->ut_flag('invoice_noemail')
+    || $self->ut_flag('message_noemail')
     || $self->ut_enum('locale', [ '', FS::Locales->locales ])
+    || $self->ut_currencyn('currency')
   ;
 
-  $self->set_coord
-    unless $import || ($self->latitude && $self->longitude);
+  my $company = $self->company;
+  $company =~ s/^\s+//; 
+  $company =~ s/\s+$//; 
+  $company =~ s/\s+/ /g;
+  $self->company($company);
 
   #barf.  need message catalogs.  i18n.  etc.
   $error .= "Please select an advertising source."
     if $error =~ /^Illegal or empty \(numeric\) refnum: /;
   return $error if $error;
 
-  return "Unknown agent"
-    unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
+  my $agent = qsearchs( 'agent', { 'agentnum' => $self->agentnum } )
+    or return "Unknown agent";
+
+  if ( $self->currency ) {
+    my $agent_currency = qsearchs( 'agent_currency', {
+      'agentnum' => $agent->agentnum,
+      'currency' => $self->currency,
+    })
+      or return "Agent ". $agent->agent.
+                " not permitted to offer ".  $self->currency. " invoicing";
+  }
 
   return "Unknown refnum"
     unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
@@ -1799,13 +1751,6 @@ sub check {
     unless ! $self->referral_custnum 
            || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
 
-  if ( $self->censustract ne '' ) {
-    $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
-      or return "Illegal census tract: ". $self->censustract;
-    
-    $self->censustract("$1.$2");
-  }
-
   if ( $self->ss eq '' ) {
     $self->ss('');
   } else {
@@ -1816,23 +1761,7 @@ sub check {
     $self->ss("$1-$2-$3");
   }
 
-
-# bad idea to disable, causes billing to fail because of no tax rates later
-# except we don't fail any more
-  unless ( $import ) {
-    unless ( qsearch('cust_main_county', {
-      'country' => $self->country,
-      'state'   => '',
-     } ) ) {
-      return "Unknown state/county/country: ".
-        $self->state. "/". $self->county. "/". $self->country
-        unless qsearch('cust_main_county',{
-          'state'   => $self->state,
-          'county'  => $self->county,
-          'country' => $self->country,
-        } );
-    }
-  }
+  # cust_main_county verification now handled by cust_location check
 
   $error =
        $self->ut_phonen('daytime', $self->country)
@@ -1842,12 +1771,8 @@ sub check {
   ;
   return $error if $error;
 
-  unless ( $ignore_illegal_zip ) {
-    $error = $self->ut_zip('zip', $self->country);
-    return $error if $error;
-  }
-
-  if ( $conf->exists('cust_main-require_phone')
+  if ( $conf->exists('cust_main-require_phone', $self->agentnum)
+       && ! $import
        && ! length($self->daytime) && ! length($self->night) && ! length($self->mobile)
      ) {
 
@@ -1866,72 +1791,6 @@ sub check {
   
   }
 
-  if ( $self->has_ship_address
-       && scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
-                        $self->addr_fields )
-     )
-  {
-    my $error =
-      $self->ut_name('ship_last')
-      || $self->ut_name('ship_first')
-      || $self->ut_textn('ship_company')
-      || $self->ut_text('ship_address1')
-      || $self->ut_textn('ship_address2')
-      || $self->ut_text('ship_city')
-      || $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,
-      'state'   => '',
-     } ) ) {
-      return "Unknown ship_state/ship_county/ship_country: ".
-        $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
-        unless qsearch('cust_main_county',{
-          'state'   => $self->ship_state,
-          'county'  => $self->ship_county,
-          'country' => $self->ship_country,
-        } );
-    }
-    #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_mobile',  $self->ship_country)
-    ;
-    return $error if $error;
-
-    unless ( $ignore_illegal_zip ) {
-      $error = $self->ut_zip('ship_zip', $self->ship_country);
-      return $error if $error;
-    }
-    return "Unit # is required."
-      if $self->ship_address2 =~ /^\s*$/
-      && $conf->exists('cust_main-require_address2');
-
-  } else { # ship_ info eq billing info, so don't store dup info in database
-
-    $self->setfield("ship_$_", '')
-      foreach $self->addr_fields;
-
-    return "Unit # is required."
-      if $self->address2 =~ /^\s*$/
-      && $conf->exists('cust_main-require_address2');
-
-  }
-
   #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
   #  or return "Illegal payby: ". $self->payby;
   #$self->payby($1);
@@ -1956,7 +1815,9 @@ sub check {
   # check the credit card.
   my $check_payinfo = ! $self->is_encrypted($self->payinfo);
 
-  if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
+  # Need some kind of global flag to accept invalid cards, for testing
+  # on scrubbed data.
+  if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/\D//g;
@@ -2105,7 +1966,8 @@ sub check {
 
   if ( $self->paydate eq '' || $self->paydate eq '-' ) {
     return "Expiration date required"
-      unless $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD)$/;
+      # shouldn't payinfo_check do this?
+      unless $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD|PPAL)$/;
     $self->paydate('');
   } else {
     my( $m, $y );
@@ -2138,6 +2000,11 @@ sub check {
     $self->payname($1);
   }
 
+  return "Please select an invoicing locale"
+    if ! $self->locale
+    && ! $self->custnum
+    && $conf->exists('cust_main-require_locale');
+
   foreach my $flag (qw( tax spool_cdr squelch_cdr archived email_csv_cdr )) {
     $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
     $self->$flag($1);
@@ -2173,7 +2040,7 @@ Returns true if this customer record has a separate shipping address.
 
 sub has_ship_address {
   my $self = shift;
-  scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
+  $self->bill_locationnum != $self->ship_locationnum;
 }
 
 =item location_hash
@@ -2184,6 +2051,11 @@ shipping address is used if present.
 
 =cut
 
+sub location_hash {
+  my $self = shift;
+  $self->ship_location->location_hash;
+}
+
 =item cust_location
 
 Returns all locations (see L<FS::cust_location>) for this customer.
@@ -2192,7 +2064,8 @@ Returns all locations (see L<FS::cust_location>) for this customer.
 
 sub cust_location {
   my $self = shift;
-  qsearch('cust_location', { 'custnum' => $self->custnum } );
+  qsearch('cust_location', { 'custnum' => $self->custnum,
+                             'prospectnum' => '' } );
 }
 
 =item cust_contact
@@ -2529,6 +2402,25 @@ Adds a payment for this invoice to the pending credit card batch (see
 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
 runs the payment using a realtime gateway.
 
+Options may include:
+
+B<amount>: the amount to be paid; defaults to the customer's balance minus
+any payments in transit.
+
+B<payby>: the payment method; defaults to cust_main.payby
+
+B<realtime>: runs this as a realtime payment instead of adding it to a 
+batch.  Deprecated.
+
+B<invnum>: sets cust_pay_batch.invnum.
+
+B<address1>, B<address2>, B<city>, B<state>, B<zip>, B<country>: sets 
+the billing address for the payment; defaults to the customer's billing
+location.
+
+B<payinfo>, B<paydate>, B<payname>: sets the payment account, expiration
+date, and name; defaults to those fields in cust_main.
+
 =cut
 
 sub batch_card {
@@ -2589,6 +2481,8 @@ sub batch_card {
     $options{$_} = '' unless exists($options{$_});
   }
 
+  my $loc = $self->bill_location;
+
   my $cust_pay_batch = new FS::cust_pay_batch ( {
     'batchnum' => $pay_batch->batchnum,
     'invnum'   => $invnum || 0,                    # is there a better value?
@@ -2598,12 +2492,12 @@ sub batch_card {
     'custnum'  => $self->custnum,
     'last'     => $self->getfield('last'),
     'first'    => $self->getfield('first'),
-    'address1' => $options{address1} || $self->address1,
-    'address2' => $options{address2} || $self->address2,
-    'city'     => $options{city}     || $self->city,
-    'state'    => $options{state}    || $self->state,
-    'zip'      => $options{zip}      || $self->zip,
-    'country'  => $options{country}  || $self->country,
+    'address1' => $options{address1} || $loc->address1,
+    'address2' => $options{address2} || $loc->address2,
+    'city'     => $options{city}     || $loc->city,
+    'state'    => $options{state}    || $loc->state,
+    'zip'      => $options{zip}      || $loc->zip,
+    'country'  => $options{country}  || $loc->country,
     'payby'    => $options{payby}    || $self->payby,
     'payinfo'  => $options{payinfo}  || $self->payinfo,
     'exp'      => $options{paydate}  || $self->paydate,
@@ -2999,7 +2893,8 @@ sub payment_info {
   $return{payname} = $self->payname
                      || ( $self->first. ' '. $self->get('last') );
 
-  $return{$_} = $self->get($_) for qw(address1 address2 city state zip);
+  $return{$_} = $self->bill_location->$_
+    for qw(address1 address2 city state zip);
 
   $return{payby} = $self->payby;
   $return{stateid_state} = $self->stateid_state;
@@ -3214,7 +3109,7 @@ sub check_invoicing_list {
   }
 
   return "Email address required"
-    if $conf->exists('cust_main-require_invoicing_list_email')
+    if $conf->exists('cust_main-require_invoicing_list_email', $self->agentnum)
     && ! grep { $_ !~ /^([A-Z]+)$/ } @$arrayref;
 
   '';
@@ -3452,6 +3347,8 @@ New-style, with a hashref of options:
 
                                     'setuptax'   => '', # or 'Y' for tax exempt
 
+                                    'locationnum'=> 1234, # optional
+
                                     #internal taxation
                                     'taxclass'   => 'Tax class',
 
@@ -3483,6 +3380,7 @@ sub charge {
   my $no_auto = '';
   my $cust_pkg_ref = '';
   my ( $bill_now, $invoice_terms ) = ( 0, '' );
+  my $locationnum;
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
@@ -3500,6 +3398,7 @@ sub charge {
     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
     $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 {
     $amount     = shift;
     $quantity   = 1;
@@ -3566,6 +3465,7 @@ sub charge {
     'quantity'   => $quantity,
     'start_date' => $start_date,
     'no_auto'    => $no_auto,
+    'locationnum'=> $locationnum,
   } );
 
   $error = $cust_pkg->insert;
@@ -3688,6 +3588,20 @@ be passed.
 
 =cut
 
+=item cust_bill_void
+
+Returns all the voided invoices (see L<FS::cust_bill_void>) for this customer.
+
+=cut
+
+sub cust_bill_void {
+  my $self = shift;
+
+  map { $_ } #return $self->num_cust_bill_void unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_bill_void', { 'custnum' => $self->custnum } )
+}
+
 sub cust_statement {
   my $self = shift;
   my $opt = ref($_[0]) ? shift : { @_ };
@@ -3844,7 +3758,7 @@ sub cust_pay_void {
 
 =item cust_pay_batch [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
 
-Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
+Returns all batched payments (see L<FS::cust_pay_batch>) for this customer.
 
 Optionally, a list or hashref of additional arguments to the qsearch call can
 be passed.
@@ -3961,11 +3875,35 @@ cust_main-default_agent_custid is set and it has a value, custnum otherwise.
 
 sub display_custnum {
   my $self = shift;
+
+  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 ( $conf->config('cust_main-custnum-display_prefix') ) {
-    return $conf->config('cust_main-custnum-display_prefix').
-           sprintf('%08d', $self->custnum)
+  } elsif ( $prefix ) {
+    $length = 8 if !defined($length);
+    return $prefix . 
+           sprintf('%0'.$length.'d', $self->custnum)
+  } elsif ( $length ) {
+    return sprintf('%0'.$length.'d', $self->custnum);
   } else {
     return $self->custnum;
   }
@@ -3985,6 +3923,27 @@ sub name {
   $name;
 }
 
+=item service_contact
+
+Returns the L<FS::contact> object for this customer that has the 'Service'
+contact class, or undef if there is no such contact.  Deprecated; don't use
+this in new code.
+
+=cut
+
+sub service_contact {
+  my $self = shift;
+  if ( !exists($self->{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;
+  }
+  $self->{service_contact};
+}
+
 =item ship_name
 
 Returns a name string for this (service/shipping) contact, either
@@ -3994,13 +3953,10 @@ Returns a name string for this (service/shipping) contact, either
 
 sub ship_name {
   my $self = shift;
-  if ( $self->get('ship_last') ) { 
-    my $name = $self->ship_contact;
-    $name = $self->ship_company. " ($name)" if $self->ship_company;
-    $name;
-  } else {
-    $self->name;
-  }
+
+  my $name = $self->ship_contact;
+  $name = $self->company. " ($name)" if $self->company;
+  $name;
 }
 
 =item name_short
@@ -4023,13 +3979,9 @@ or "First Last".
 
 sub ship_name_short {
   my $self = shift;
-  if ( $self->get('ship_last') ) { 
-    $self->ship_company !~ /^\s*$/
-      ? $self->ship_company
-      : $self->ship_contact_firstlast;
-  } else {
-    $self->name_company_or_firstlast;
-  }
+  $self->service_contact 
+    ? $self->ship_contact_firstlast 
+    : $self->name_short
 }
 
 =item contact
@@ -4051,9 +4003,8 @@ Returns this customer's full (shipping) contact name only, "Last, First"
 
 sub ship_contact {
   my $self = shift;
-  $self->get('ship_last')
-    ? $self->get('ship_last'). ', '. $self->ship_first
-    : $self->contact;
+  my $contact = $self->service_contact || $self;
+  $contact->get('last') . ', ' . $contact->get('first');
 }
 
 =item contact_firstlast
@@ -4075,20 +4026,38 @@ Returns this customer's full (shipping) contact name only, "First Last".
 
 sub ship_contact_firstlast {
   my $self = shift;
-  $self->get('ship_last')
-    ? $self->first. ' '. $self->get('ship_last')
-    : $self->contact_firstlast;
+  my $contact = $self->service_contact || $self;
+  $contact->get('first') . ' '. $contact->get('last');
 }
 
-=item country_full
+#XXX this doesn't work in 3.x+
+#=item country_full
+#
+#Returns this customer's full country name
+#
+#=cut
+#
+#sub country_full {
+#  my $self = shift;
+#  code2country($self->country);
+#}
+
+=item county_state_county [ PREFIX ]
 
-Returns this customer's full country name
+Returns a string consisting of just the county, state and country.
 
 =cut
 
-sub country_full {
+sub county_state_country {
   my $self = shift;
-  code2country($self->country);
+  my $locationnum;
+  if ( @_ && $_[0] && $self->has_ship_address ) {
+    $locationnum = $self->ship_locationnum;
+  } else {
+    $locationnum = $self->bill_locationnum;
+  }
+  my $cust_location = qsearchs('cust_location', { locationnum=>$locationnum });
+  $cust_location->county_state_country;
 }
 
 =item geocode DATA_VENDOR
@@ -4167,14 +4136,17 @@ sub cust_statuscolor {
   __PACKAGE__->statuscolors->{$self->cust_status};
 }
 
-=item tickets
+=item tickets [ STATUS ]
 
 Returns an array of hashes representing the customer's RT tickets.
 
+An optional status (or arrayref or hashref of statuses) may be specified.
+
 =cut
 
 sub tickets {
   my $self = shift;
+  my $status = ( @_ && $_[0] ) ? shift : '';
 
   my $num = $conf->config('cust_main-max_tickets') || 10;
   my @tickets = ();
@@ -4182,7 +4154,12 @@ sub tickets {
   if ( $conf->config('ticket_system') ) {
     unless ( $conf->config('ticket_system-custom_priority_field') ) {
 
-      @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
+      @tickets = @{ FS::TicketSystem->customer_tickets( $self->custnum,
+                                                        $num,
+                                                        undef,
+                                                        $status,
+                                                      )
+                  };
 
     } else {
 
@@ -4194,6 +4171,7 @@ sub tickets {
           @{ FS::TicketSystem->customer_tickets( $self->custnum,
                                                  $num - scalar(@tickets),
                                                  $priority,
+                                                 $status,
                                                )
            };
       }
@@ -4911,7 +4889,10 @@ sub queueable_print {
 
 sub print {
   my ($self, $template) = (shift, shift);
-  do_print [ $self->print_ps($template) ];
+  do_print(
+    [ $self->print_ps($template) ],
+    'agentnum' => $self->agentnum,
+  );
 }
 
 #these three subs should just go away once agent stuff is all config overrides
@@ -5016,69 +4997,89 @@ sub process_bill_and_collect {
   $cust_main->bill_and_collect( %$param );
 }
 
-=item process_censustract_update CUSTNUM
-
-Queueable function to update the census tract to the current year (as set in 
-the 'census_year' configuration variable) and retrieve the new tract code.
-
-=cut
-
-sub process_censustract_update { 
-  eval "use FS::Misc::Geo qw(get_censustract)";
-  die $@ if $@;
-  my $custnum = shift;
-  my $cust_main = qsearchs( 'cust_main', { custnum => $custnum })
-      or die "custnum '$custnum' not found!\n";
-
-  my $new_year = $conf->config('census_year') or return;
-  my $new_tract = get_censustract({ $cust_main->location_hash }, $new_year);
-  if ( $new_tract =~ /^\d/ ) {
-    # then it's a tract code
-        $cust_main->set('censustract', $new_tract);
-    $cust_main->set('censusyear',  $new_year);
-
-    local($import) = 1; #prevent automatic geocoding (need its own variable?)
-    my $error = $cust_main->replace;
-    die $error if $error;
-  }
-  else {
-    # it's an error message
-    die $new_tract;
-  }
-  return;
-}
+#starting to take quite a while for big dbs
+#   (JRNL: journaled so it only happens once per database)
+# - seq scan of h_cust_main (yuck), but not going to index paycvv, so
+# JRNL seq scan of cust_main on signupdate... index signupdate?  will that help?
+# JRNL seq scan of cust_main on paydate... index on substrings?  maybe set an
+# JRNL seq scan of cust_main on payinfo.. certainly not going toi ndex that...
+# JRNL leading/trailing spaces in first, last, company
+# - otaker upgrade?  journal and call it good?  (double check to make sure
+#    we're not still setting otaker here)
+#
+#only going to get worse with new location stuff...
 
 sub _upgrade_data { #class method
   my ($class, %opts) = @_;
 
   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 ) {
+
+  #this seems to be the only expensive one.. why does it take so long?
+  unless ( FS::upgrade_journal->is_done('cust_main__signupdate') ) {
     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) = '-'";
+      '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';
+    FS::upgrade_journal->set_done('cust_main__signupdate');
   }
-  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) = '-'";
+
+  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');
   }
 
-  push @statements, #fix the weird BILL with a cc# in payinfo problem
-    #DCRD to be safe, or CARD?
-    "UPDATE cust_main SET payby = 'DCRD' WHERE payby = 'BILL' and length(payinfo) = 16";
+  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;
     $sth->execute or die $sth->errstr;
+    #warn ( (time - $t). " seconds\n" );
+    #$t = time;
   }
 
   local($ignore_expired_card) = 1;
-  local($ignore_illegal_zip) = 1;
   local($ignore_banned_card) = 1;
   local($skip_fuzzyfiles) = 1;
   local($import) = 1; #prevent automatic geocoding (need its own variable?)
+
+  FS::cust_main::Location->_upgrade_data(%opts);
+
+  unless ( FS::upgrade_journal->is_done('cust_main__trimspaces') ) {
+
+    foreach my $cust_main ( qsearch({
+      'table'     => 'cust_main', 
+      'hashref'   => {},
+      'extra_sql' => 'WHERE '.
+                       join(' OR ',
+                         map "$_ LIKE ' %' OR $_ LIKE '% ' OR $_ LIKE '%  %'",
+                           qw( first last company )
+                       ),
+    }) ) {
+      my $error = $cust_main->replace;
+      die $error if $error;
+    }
+
+    FS::upgrade_journal->set_done('cust_main__trimspaces');
+
+  }
+
   $class->_upgrade_otaker(%opts);
 
 }