[freeside-commits] branch master updated. 01629c3c934f1f6fd2ab9de5f7638f671fd59791

Mark Wells mark at 420.am
Fri May 25 13:38:43 PDT 2012


The branch, master has been updated
       via  01629c3c934f1f6fd2ab9de5f7638f671fd59791 (commit)
      from  f2c26594352302de80c2cd0cbba8b0e2abada6f7 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 01629c3c934f1f6fd2ab9de5f7638f671fd59791
Author: Mark Wells <mark at freeside.biz>
Date:   Fri May 25 13:38:07 2012 -0700

    customer bill/ship location refactoring, #940

diff --git a/FS/FS/ClientAPI/MasonComponent.pm b/FS/FS/ClientAPI/MasonComponent.pm
index 37cf7ef..534b48a 100644
--- a/FS/FS/ClientAPI/MasonComponent.pm
+++ b/FS/FS/ClientAPI/MasonComponent.pm
@@ -36,7 +36,7 @@ my %session_callbacks = (
     my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
       or return "unknown custnum $custnum";
     my %args = @$argsref;
-    $args{object} = $cust_main;
+    $args{object} = $cust_main->bill_location;
     @$argsref = ( %args );
     return ''; #no error
   },
diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm
index e79fbfc..54799b8 100644
--- a/FS/FS/ClientAPI/MyAccount.pm
+++ b/FS/FS/ClientAPI/MyAccount.pm
@@ -46,18 +46,17 @@ use FS::msg_template;
 $DEBUG = 0;
 $me = '[FS::ClientAPI::MyAccount]';
 
-use vars qw( @cust_main_editable_fields );
+use vars qw( @cust_main_editable_fields @location_editable_fields );
 @cust_main_editable_fields = qw(
-  first last company address1 address2 city
-    county state zip country
-    daytime night fax mobile
-  ship_first ship_last ship_company ship_address1 ship_address2 ship_city
-    ship_state ship_zip ship_country
-    ship_daytime ship_night ship_fax ship_mobile
+  first last daytime night fax mobile
   locale
   payby payinfo payname paystart_month paystart_year payissue payip
   ss paytype paystate stateid stateid_state
 );
+ at location_editable_fields = qw(
+  address1 address2 city county state zip country
+);
+
 
 BEGIN { #preload to reduce time customer_info takes
   if ( $FS::TicketSystem::system ) {
@@ -442,7 +441,6 @@ sub customer_info {
                     );
 
     $return{name} = $cust_main->first. ' '. $cust_main->get('last');
-    $return{ship_name} = $cust_main->ship_first. ' '. $cust_main->get('ship_last');
 
     $return{has_ship_address} = $cust_main->has_ship_address;
     $return{status} = $cust_main->status;
@@ -452,6 +450,18 @@ sub customer_info {
       $return{$_} = $cust_main->get($_);
     }
 
+    for (@location_editable_fields) {
+      $return{$_} = $cust_main->bill_location->get($_);
+      $return{'ship_'.$_} = $cust_main->ship_location->get($_);
+    }
+    $return{has_ship_address} = $cust_main->has_ship_address;
+    # compatibility: some places in selfservice use this to determine
+    # if there's a ship address
+    if ( $return{has_ship_address} ) {
+      $return{ship_last}  = $cust_main->last;
+      $return{ship_first} = $cust_main->first;
+    }
+
     if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
       $return{payinfo} = $cust_main->paymask;
       @return{'month', 'year'} = $cust_main->paydate_monthyear;
@@ -465,7 +475,7 @@ sub customer_info {
     if (scalar($conf->config('support_packages'))) {
       my @support_services = ();
       foreach ($cust_main->support_services) {
-        my $seconds = $_->svc_x->seconds;
+        my $seconds = $_->svc_x->seconds || 0;
         my $time_remaining = (($seconds < 0) ? '-' : '' ).
                              int(abs($seconds)/3600)."h".
                              sprintf("%02d",(abs($seconds)%3600)/60)."m";
@@ -541,7 +551,6 @@ sub customer_info_short {
                     );
 
     $return{name} = $cust_main->first. ' '. $cust_main->get('last');
-    $return{ship_name} = $cust_main->ship_first. ' '. $cust_main->get('ship_last');
 
     $return{payby} = $cust_main->payby;
 
@@ -549,7 +558,12 @@ sub customer_info_short {
     for (@cust_main_editable_fields) {
       $return{$_} = $cust_main->get($_);
     }
-    
+    #maybe a little more expensive, but it should be cached by now
+    for (@location_editable_fields) {
+      $return{$_} = $cust_main->bill_location->get($_);
+      $return{'ship_'.$_} = $cust_main->ship_location->get($_);
+    }
+ 
     if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
       $return{payinfo} = $cust_main->paymask;
       @return{'month', 'year'} = $cust_main->paydate_monthyear;
@@ -692,15 +706,32 @@ sub edit_info {
     or return { 'error' => "unknown custnum $custnum" };
 
   my $new = new FS::cust_main { $cust_main->hash };
-  # Avoid accidentally changing the service address.
-  if ( !$new->has_ship_address ) {
-    $new->set( $_ => $new->get($_) )
-      foreach $new->addr_fields;
-  }
 
   $new->set( $_ => $p->{$_} )
     foreach grep { exists $p->{$_} } @cust_main_editable_fields;
 
+  if ( exists($p->{address1}) ) {
+    my $bill_location = FS::cust_location->new({
+        map { $_ => $p->{$_} } @location_editable_fields
+    });
+    # if this is unchanged from before, cust_main::replace will ignore it
+    $new->set('bill_location' => $bill_location);
+  }
+
+  if ( exists($p->{ship_address1}) ) {
+    my $ship_location = FS::cust_location->new({
+        map { $_ => $p->{"ship_$_"} } @location_editable_fields
+    });
+    if ( !grep { length($p->{"ship_$_"}) } @location_editable_fields ) {
+      # Selfservice unfortunately tries to indicate "same as billing 
+      # address" by sending all fields empty.  Did this ever work?
+      $ship_location = $cust_main->bill_location;
+    }
+    $new->set('ship_location' => $ship_location);
+  }
+  # but if it hasn't been passed in at all, leave ship_location alone--
+  # DON'T change it to match bill_location.
+
   my $payby = '';
   if (exists($p->{'payby'})) {
     $p->{'payby'} =~ /^([A-Z]{4})$/
@@ -838,7 +869,8 @@ sub payment_info {
   $return{payname} = $cust_main->payname
                      || ( $cust_main->first. ' '. $cust_main->get('last') );
 
-  $return{$_} = $cust_main->get($_) for qw(address1 address2 city state zip);
+  $return{$_} = $cust_main->bill_location->get($_) 
+    for qw(address1 address2 city state zip);
 
   $return{payby} = $cust_main->payby;
   $return{stateid_state} = $cust_main->stateid_state;
@@ -1062,13 +1094,12 @@ sub do_process_payment {
         foreach qw( payname paystart_month paystart_year payissue payip );
       $new->set( 'payby' => $validate->{'auto'} ? 'CARD' : 'DCRD' );
 
-      # Avoid accidentally changing the service address.
-      if ( !$new->has_ship_address ) {
-        $new->set( "ship_$_" => $new->get($_) ) 
-          foreach $new->addr_fields;
-      }
-      $new->set( $_ => $validate->{$_} )
-        foreach qw(address1 address2 city state country zip);
+      my $bill_location = FS::cust_location->new({
+          map { $_ => $validate->{$_} } 
+          qw(address1 address2 city state country zip)
+      }); # county?
+      $new->set('bill_location' => $bill_location);
+      # but don't allow the service address to change this way.
 
     } elsif ($payby eq 'CHEK' || $payby eq 'DCHK') {
       $new->set( $_ => $validate->{$_} )
diff --git a/FS/FS/ClientAPI/Signup.pm b/FS/FS/ClientAPI/Signup.pm
index f17752a..b7dcdbb 100644
--- a/FS/FS/ClientAPI/Signup.pm
+++ b/FS/FS/ClientAPI/Signup.pm
@@ -405,8 +405,8 @@ sub signup_info {
            && $agent->agent_cust_main ) {
 
         my $cust_main = $agent->agent_cust_main;
-        my $prefix = length($cust_main->ship_last) ? 'ship_' : '';
-        $signup_info_cache_agent->{"ship_$_"} = $cust_main->get("$prefix$_")
+        my $location = $cust_main->ship_location;
+        $signup_info_cache_agent->{"ship_$_"} = $location->get($_)
           foreach qw( address1 city county state zip country );
 
       }
@@ -509,6 +509,13 @@ sub new_customer {
                 || $conf->config('signup_server-default_agentnum');
   }
 
+  my ($bill_hash, $ship_hash);
+  foreach my $f (FS::cust_main->location_fields) {
+    # avoid having to change this in front-end code
+    $bill_hash->{$f} = $packet->{"bill_$f"} || $packet->{$f};
+    $ship_hash->{$f} = $packet->{"ship_$f"};
+  }
+
   #shares some stuff with htdocs/edit/process/cust_main.cgi... take any
   # common that are still here and library them.
   my $template_custnum = $conf->config('signup_server-prepaid-template-custnum');
@@ -517,6 +524,7 @@ sub new_customer {
 
     my $template_cust = qsearchs('cust_main', { 'custnum' => $template_custnum } );
     return { 'error' => 'Configuration error' } unless $template_cust;
+    #XXX Copy template customer's locations
     $cust_main = new FS::cust_main ( {
       'agentnum'      => $agentnum,
       'refnum'        => $packet->{refnum}
@@ -556,41 +564,48 @@ sub new_customer {
                          || $conf->config('signup_server-default_refnum'),
 
       map { $_ => $packet->{$_} } qw(
-
-        last first ss company address1 address2
-        city county state zip country
+        last first ss company 
         daytime night fax stateid stateid_state
-
-        ship_last ship_first ship_ss ship_company ship_address1 ship_address2
-        ship_city ship_county ship_state ship_zip ship_country
-        ship_daytime ship_night ship_fax
-
         payby
         payinfo paycvv paydate payname paystate paytype
         paystart_month paystart_year payissue
         payip
         override_ban_warn
-
         referral_custnum comments
-      )
+      ),
 
     } );
   }
 
+  my $bill_location = FS::cust_location->new($bill_hash);
+  my $ship_location;
   my $agent = qsearchs('agent', { 'agentnum' => $agentnum } );
   if ( $conf->exists('agent-ship_address', $agentnum) 
     && $agent->agent_custnum ) {
 
     my $agent_cust_main = $agent->agent_cust_main;
     my $prefix = length($agent_cust_main->ship_last) ? 'ship_' : '';
-    $cust_main->set("ship_$_", $agent_cust_main->get("$prefix$_") )
-      foreach qw( address1 city county state zip country );
-
-    $cust_main->set("ship_$_", $cust_main->get($_))
-      foreach qw( last first );
+    $ship_location = FS::cust_location->new({ 
+        $agent_cust_main->ship_location->location_hash
+    });
 
   }
+  # we don't have an equivalent of the "same" checkbox in selfservice
+  # so is there a ship address, and if so, is it different from the billing 
+  # address?
+  elsif ( length($ship_hash->{address1}) > 0 and
+          grep { $bill_hash->{$_} ne $ship_hash->{$_} } keys(%$ship_hash)
+         ) {
+
+    $ship_location = FS::cust_location->new( $ship_hash );
+  
+  }
+  else {
+    $ship_location = $bill_location;
+  }
 
+  $cust_main->set('bill_location' => $bill_location);
+  $cust_main->set('ship_location' => $ship_location);
 
   return { 'error' => "Illegal payment type" }
     unless grep { $_ eq $packet->{'payby'} }
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 2968903..5476589 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -861,13 +861,13 @@ sub tables_hashref {
         'signupdate', at date_type, '', '', 
         'dundate',   @date_type, '', '', 
         'company',  'varchar', 'NULL', $char_d, '', '', 
-        'address1', 'varchar', '',     $char_d, '', '', 
+        'address1', 'varchar', 'NULL', $char_d, '', '', 
         'address2', 'varchar', 'NULL', $char_d, '', '', 
-        'city',     'varchar', '',     $char_d, '', '', 
+        'city',     'varchar', 'NULL', $char_d, '', '', 
         'county',   'varchar', 'NULL', $char_d, '', '', 
         'state',    'varchar', 'NULL', $char_d, '', '', 
         'zip',      'varchar', 'NULL', 10, '', '', 
-        'country',  'char', '',     2, '', '', 
+        'country',  'char',    'NULL',  2, '', '', 
         'latitude', 'decimal', 'NULL', '10,7', '', '', 
         'longitude','decimal', 'NULL', '10,7', '', '', 
         'coord_auto',  'char', 'NULL',  1, '', '',
@@ -896,7 +896,7 @@ sub tables_hashref {
         'payby',    'char', '',     4, '', '', 
         'payinfo',  'varchar', 'NULL', 512, '', '', 
         'paycvv',   'varchar', 'NULL', 512, '', '', 
-	'paymask', 'varchar', 'NULL', $char_d, '', '', 
+        'paymask', 'varchar', 'NULL', $char_d, '', '', 
         #'paydate',  @date_type, '', '', 
         'paydate',  'varchar', 'NULL', 10, '', '', 
         'paystart_month', 'int', 'NULL', '', '', '', 
@@ -929,6 +929,8 @@ sub tables_hashref {
         'locale', 'varchar', 'NULL', 16, '', '', 
         'calling_list_exempt', 'char', 'NULL', 1, '', '',
         'invoice_noemail', 'char', 'NULL', 1, '', '',
+        'bill_locationnum', 'int', 'NULL', '', '', '',
+        'ship_locationnum', 'int', 'NULL', '', '', '',
       ],
       'primary_key' => 'custnum',
       'unique' => [ [ 'agentnum', 'agent_custid' ] ],
@@ -939,16 +941,6 @@ sub tables_hashref {
                    [ 'referral_custnum' ],
                    [ 'payby' ], [ 'paydate' ],
                    [ 'archived' ],
-                   #billing
-                   [ 'last' ], [ 'company' ],
-                   [ 'county' ], [ 'state' ], [ 'country' ],
-                   [ 'zip' ],
-                   [ 'daytime' ], [ 'night' ], [ 'fax' ], [ 'mobile' ],
-                   #shipping
-                   [ 'ship_last' ], [ 'ship_company' ],
-                   [ 'ship_county' ], [ 'ship_state' ], [ 'ship_country' ],
-                   [ 'ship_zip' ],
-                   [ 'ship_daytime' ], [ 'ship_night' ], [ 'ship_fax' ], [ 'ship_mobile' ]
                  ],
     },
 
@@ -1081,6 +1073,8 @@ sub tables_hashref {
         'country',            'char',     '',       2, '', '', 
         'geocode',         'varchar', 'NULL',      20, '', '',
         'district',        'varchar', 'NULL',      20, '', '',
+        'censustract',     'varchar', 'NULL',      20, '', '',
+        'censusyear',         'char', 'NULL',       4, '', '',
         'location_type',   'varchar', 'NULL',      20, '', '',
         'location_number', 'varchar', 'NULL',      20, '', '',
         'location_kind',      'char', 'NULL',       1, '', '',
@@ -1090,6 +1084,7 @@ sub tables_hashref {
       'unique'      => [],
       'index'       => [ [ 'prospectnum' ], [ 'custnum' ],
                          [ 'county' ], [ 'state' ], [ 'country' ], [ 'zip' ],
+                         [ 'city' ], [ 'district' ]
                        ],
     },
 
diff --git a/FS/FS/UI/Web/small_custview.pm b/FS/FS/UI/Web/small_custview.pm
index 53a3b5e..2c42a6b 100644
--- a/FS/FS/UI/Web/small_custview.pm
+++ b/FS/FS/UI/Web/small_custview.pm
@@ -82,45 +82,23 @@ sub small_custview {
 
   $html .= '</TD></TR></TABLE></TD>';
 
-  if ( defined $cust_main->dbdef_table->column('ship_last') ) {
-
-    my $pre = $cust_main->ship_last ? 'ship_' : '';
-
-    $html .= '<TD VALIGN="top">'. ntable("#cccccc",2).
-      '<TR><TD ALIGN="right" VALIGN="top">Service<BR>Address</TD><TD BGCOLOR="#ffffff">'.
-      $cust_main->get("${pre}last"). ', '.
-      $cust_main->get("${pre}first"). '<BR>';
-    $html .= $cust_main->get("${pre}company"). '<BR>'
-      if $cust_main->get("${pre}company");
-    $html .= $cust_main->get("${pre}address1"). '<BR>';
-    $html .= $cust_main->get("${pre}address2"). '<BR>'
-      if $cust_main->get("${pre}address2");
-    $html .= $cust_main->get("${pre}city"). ', '.
-             $cust_main->get("${pre}state"). '  '.
-             $cust_main->get("${pre}zip"). '<BR>';
-    $html .= $cust_main->get("${pre}country"). '<BR>'
-      if $cust_main->get("${pre}country")
-         && $cust_main->get("${pre}country") ne $countrydefault;
-
-    $html .= '</TD></TR><TR><TD></TD><TD BGCOLOR="#ffffff">';
-
-    if ( $cust_main->get("${pre}daytime") && $cust_main->get("${pre}night") ) {
-      use FS::Msgcat;
-      $html .= ( FS::Msgcat::_gettext('daytime') || 'Day' ).
-               ' '. $cust_main->get("${pre}daytime").
-               '<BR>'. ( FS::Msgcat::_gettext('night') || 'Night' ).
-               ' '. $cust_main->get("${pre}night");
-    } elsif ( $cust_main->get("${pre}daytime")
-              || $cust_main->get("${pre}night") ) {
-      $html .= $cust_main->get("${pre}daytime")
-               || $cust_main->get("${pre}night");
-    }
-    if ( $cust_main->get("${pre}fax") ) {
-      $html .= '<BR>Fax '. $cust_main->get("${pre}fax");
-    }
+  my $ship = $cust_main->ship_location;
+
+  $html .= '<TD VALIGN="top">'. ntable("#cccccc",2).
+    '<TR><TD ALIGN="right" VALIGN="top">Service<BR>Address</TD><TD BGCOLOR="#ffffff">';
+  $html .= join('<BR>', 
+    grep $_,
+      $cust_main->contact,
+      $cust_main->company,
+      $ship->address1,
+      $ship->address2,
+      ($ship->city . ', ' . $ship->state . '  ' . $ship->zip),
+      ($ship->country eq $countrydefault ? '' : $ship->country ),
+  );
+
+  # ship phone numbers no longer exist...
 
-    $html .= '</TD></TR></TABLE></TD>';
-  }
+  $html .= '</TD></TR></TABLE></TD>';
 
   $html .= '</TR></TABLE>';
 
diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm
index 1f4943a..d1cb3ba 100644
--- a/FS/FS/cust_bill.pm
+++ b/FS/FS/cust_bill.pm
@@ -2780,11 +2780,13 @@ sub print_generic {
   $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
 
   my $countrydefault = $conf->config('countrydefault') || 'US';
-  my $prefix = $cust_main->has_ship_address ? 'ship_' : '';
-  foreach ( qw( contact company address1 address2 city state zip country fax) ){
-    my $method = $prefix.$_;
+  foreach ( qw( address1 address2 city state zip country fax) ){
+    my $method = 'ship_'.$_;
     $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
   }
+  foreach ( qw( contact company ) ) { #compatibility
+    $invoice_data{"ship_$_"} = _latex_escape($cust_main->$_);
+  }
   $invoice_data{'ship_country'} = ''
     if ( $invoice_data{'ship_country'} eq $countrydefault );
   
diff --git a/FS/FS/cust_location.pm b/FS/FS/cust_location.pm
index bcdb50c..1f07aa8 100644
--- a/FS/FS/cust_location.pm
+++ b/FS/FS/cust_location.pm
@@ -113,11 +113,16 @@ otherwise returns false.
 
 sub insert {
   my $self = shift;
+  my $conf = new FS::Conf;
+
+  if ( $self->censustract ) {
+    $self->set('censusyear' => $conf->config('census_year') || 2012);
+  }
+
   my $error = $self->SUPER::insert(@_);
 
   #false laziness with cust_main, will go away eventually
-  my $conf = new FS::Conf;
-  if ( !$error and $conf->config('tax_district_method') ) {
+  if ( !$import and !$error and $conf->config('tax_district_method') ) {
 
     my $queue = new FS::queue {
       'job' => 'FS::geocode_Mixin::process_district_update'
@@ -144,21 +149,14 @@ sub replace {
   my $self = shift;
   my $old = shift;
   $old ||= $self->replace_old;
-  my $error = $self->SUPER::replace($old);
-
-  #false laziness with cust_main, will go away eventually
-  my $conf = new FS::Conf;
-  if ( !$error and $conf->config('tax_district_method') 
-    and $self->get('address1') ne $old->get('address1') ) {
-
-    my $queue = new FS::queue {
-      'job' => 'FS::geocode_Mixin::process_district_update'
-    };
-    $error = $queue->insert( ref($self), $self->locationnum );
-
+  # the following fields are immutable
+  foreach (qw(address1 address2 city state zip country)) {
+    if ( $self->$_ ne $old->$_ ) {
+      return "can't change cust_location field $_";
+    }
   }
 
-  $error || '';
+  $self->SUPER::replace($old);
 }
 
 
@@ -174,6 +172,7 @@ and replace methods.
 #fields anyway...
 sub check {
   my $self = shift;
+  my $conf = new FS::Conf;
 
   my $error = 
     $self->ut_numbern('locationnum')
@@ -185,7 +184,7 @@ sub check {
     || $self->ut_textn('county')
     || $self->ut_textn('state')
     || $self->ut_country('country')
-    || $self->ut_zip('zip', $self->country)
+    || (!$import && $self->ut_zip('zip', $self->country))
     || $self->ut_coordn('latitude')
     || $self->ut_coordn('longitude')
     || $self->ut_enum('coord_auto', [ '', 'Y' ])
@@ -194,22 +193,36 @@ sub check {
     || $self->ut_enum('location_kind', [ '', 'R', 'B' ] )
     || $self->ut_alphan('geocode')
     || $self->ut_alphan('district')
+    || $self->ut_numbern('censusyear')
   ;
   return $error if $error;
+  if ( $self->censustract ne '' ) {
+    $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
+      or return "Illegal census tract: ". $self->censustract;
+
+    $self->censustract("$1.$2");
+  }
+
+  if ( $conf->exists('cust_main-require_address2') and 
+       !$self->ship_address2 =~ /\S/ ) {
+    return "Unit # is required";
+  }
 
   $self->set_coord
     unless $import || ($self->latitude && $self->longitude);
 
-  return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
+  # tricky...we have to allow for the customer to not be inserted yet
+  return "No prospect or customer!" unless $self->prospectnum 
+                                        || $self->custnum
+                                        || $self->get('custnum_pending');
   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
 
-  my $conf = new FS::Conf;
   return 'Location kind is required'
     if $self->prospectnum
     && $conf->exists('prospect_main-alt_address_format')
     && ! $self->location_kind;
 
-  unless ( qsearch('cust_main_county', {
+  unless ( $import or qsearch('cust_main_county', {
     'country' => $self->country,
     'state'   => '',
    } ) ) {
@@ -266,19 +279,40 @@ location_kind.
 
 =cut
 
-=item move_to HASHREF
+=item disable_if_unused
 
-Takes a hashref with one or more cust_location fields.  Creates a duplicate 
-of the existing location with all fields set to the values in the hashref.  
-Moves all packages that use the existing location to the new one, then sets 
-the "disabled" flag on the old location.  Returns nothing on success, an 
-error message on error.
+Sets the "disabled" flag on the location if it is no longer in use as a 
+prospect location, package location, or a customer's billing or default
+service address.
+
+=cut
+
+sub disable_if_unused {
+
+  my $self = shift;
+  my $locationnum = $self->locationnum;
+  return '' if FS::cust_main->count('bill_locationnum = '.$locationnum)
+            or FS::cust_main->count('ship_locationnum = '.$locationnum)
+            or FS::contact->count(      'locationnum  = '.$locationnum)
+            or FS::cust_pkg->count('cancel IS NULL AND 
+                                         locationnum  = '.$locationnum)
+          ;
+  $self->disabled('Y');
+  $self->replace;
+
+}
+
+=item move_to
+
+Takes a new L<FS::cust_location> object.  Moves all packages that use the 
+existing location to the new one, then sets the "disabled" flag on the old
+location.  Returns nothing on success, an error message on error.
 
 =cut
 
 sub move_to {
   my $old = shift;
-  my $hashref = shift;
+  my $new = shift;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -292,16 +326,12 @@ sub move_to {
   my $dbh = dbh;
   my $error = '';
 
-  my $new = FS::cust_location->new({
-      $old->location_hash,
-      'custnum'     => $old->custnum,
-      'prospectnum' => $old->prospectnum,
-      %$hashref
-    });
-  $error = $new->insert;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "Error creating location: $error";
+  if ( !$new->locationnum ) {
+    $error = $new->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error creating location: $error";
+    }
   }
 
   my @pkgs = qsearch('cust_pkg', { 
@@ -319,15 +349,14 @@ sub move_to {
     }
   }
 
-  $old->disabled('Y');
-  $error = $old->replace;
+  $error = $old->disable_if_unused;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return "Error disabling old location: $error";
   }
 
   $dbh->commit if $oldAutoCommit;
-  return;
+  '';
 }
 
 =item alternize
@@ -421,14 +450,15 @@ sub location_label {
   my $conf = new FS::Conf;
   my $prefix = '';
   my $format = $conf->config('cust_location-label_prefix') || '';
+  my $cust_or_prospect;
+  if ( $self->custnum ) {
+    $cust_or_prospect = FS::cust_main->by_key($self->custnum);
+  }
+  elsif ( $self->prospectnum ) {
+    $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
+  }
+
   if ( $format eq 'CoStAg' ) {
-    my $cust_or_prospect;
-    if ( $self->custnum ) {
-      $cust_or_prospect = FS::cust_main->by_key($self->custnum);
-    }
-    elsif ( $self->prospectnum )  {
-      $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
-    }
     my $agent = $conf->config('cust_main-custnum-display_prefix',
                   $cust_or_prospect->agentnum)
                 || $cust_or_prospect->agent->agent;
@@ -440,15 +470,65 @@ sub location_label {
         sprintf('%05d', $self->locationnum)
     ) );
   }
+  elsif ( $self->custnum and 
+          $self->locationnum == $cust_or_prospect->ship_locationnum ) {
+    $prefix = 'Default service location';
+  }
   $prefix .= ($opt{join_string} ||  ': ') if $prefix;
   $prefix . $self->SUPER::location_label(%opt);
 }
 
 =back
 
-=head1 BUGS
+=head1 CLASS METHODS
+
+=item in_county_sql OPTIONS
+
+Returns an SQL expression to test membership in a cust_main_county 
+geographic area.  By default, this requires district, city, county,
+state, and country to match exactly.  Pass "ornull => 1" to allow 
+partial matches where some fields are NULL in the cust_main_county 
+record but not in the location.
+
+Pass "param => 1" to receive a parameterized expression (rather than
+one that requires a join to cust_main_county) and a list of parameter
+names in order.
+
+=cut
 
-Not yet used for cust_main billing and shipping addresses.
+sub in_county_sql {
+  # replaces FS::cust_pkg::location_sql
+  my ($class, %opt) = @_;
+  my $ornull = $opt{ornull} ? ' OR ? IS NULL' : '';
+  my $x = $ornull ? 3 : 2;
+  my @fields = (('district') x 3,
+                ('city') x 3,
+                ('county') x $x,
+                ('state') x $x,
+                'country');
+
+  my @where = (
+    "cust_location.district = ? OR ? = '' OR CAST(? AS text) IS NULL",
+    "cust_location.city     = ? OR ? = '' OR CAST(? AS text) IS NULL",
+    "cust_location.county   = ? OR (? = '' AND cust_location.county IS NULL) $ornull",
+    "cust_location.state    = ? OR (? = '' AND cust_location.state IS NULL ) $ornull",
+    "cust_location.country = ?"
+  );
+  my $sql = join(' AND ', map "($_)\n", @where);
+  if ( $opt{param} ) {
+    return $sql, @fields;
+  }
+  else {
+    # do the substitution here
+    foreach (@fields) {
+      $sql =~ s/\?/cust_main_county.$_/;
+      $sql =~ s/cust_main_county.$_ = ''/cust_main_county.$_ IS NULL/;
+    }
+    return $sql;
+  }
+}
+
+=head1 BUGS
 
 =head1 SEE ALSO
 
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 9766579..56338e5 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -6,6 +6,7 @@ use strict;
 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::cust_main::Location
              FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
              FS::geocode_Mixin
              FS::o2m_Common
@@ -14,7 +15,7 @@ use base qw( FS::cust_main::Packages FS::cust_main::Status
 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
            );
@@ -80,7 +81,6 @@ $me = '[FS::cust_main]';
 
 $import = 0;
 $ignore_expired_card = 0;
-$ignore_illegal_zip = 0;
 $ignore_banned_card = 0;
 
 $skip_fuzzyfiles = 0;
@@ -178,28 +178,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)
@@ -216,56 +194,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)
@@ -364,6 +292,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
@@ -462,13 +396,44 @@ 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->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 != $self->custnum or $loc->prospectnum > 0 ) {
+      # this 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')||'2012') if $self->censustract;
-
   $self->auto_agent_custid()
     if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
 
@@ -479,6 +444,20 @@ 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;
+    $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;
 
@@ -1318,7 +1297,7 @@ sub merge {
 
   }
 
-  my $name = $self->ship_name;
+  my $name = $self->ship_name; #?
 
   my $locationnum = '';
   foreach my $cust_pkg ( $self->all_pkgs ) {
@@ -1454,10 +1433,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 
@@ -1494,41 +1476,19 @@ 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');
-
-  }
+  # should be unnecessary--geocode will default to null on new locations
+  #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;
+  #}
 
-  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;
-  }
+  # set_coord/coord_auto stuff is now handled by cust_location
 
   local($ignore_expired_card) = 1
     if $old->payby  =~ /^(CARD|DCRD)$/
@@ -1540,11 +1500,6 @@ 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')||'2012');
-  }
-
   return "Invoicing locale is required"
     if $old->locale
     && ! $self->locale
@@ -1561,6 +1516,47 @@ 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;
+
+    if ( !$new_loc->locationnum ) {
+      # changing location
+      # If the new location is all empty fields, or if it's identical to 
+      # the old location in all fields, don't replace.
+      my @nonempty = grep { $new_loc->$_ } $self->location_fields;
+      next if !@nonempty;
+      my @unlike = grep { $new_loc->$_ ne $old_loc->$_ } $self->location_fields;
+
+      if ( @unlike or $old_loc->disabled ) {
+        warn "  changed $l fields: ".join(',', at unlike)."\n"
+          if $DEBUG;
+        $new_loc->set(custnum => $self->custnum);
+
+        # insert it--the old location will be disabled later
+        my $error = $new_loc->insert;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
+
+      } else {
+      # no fields have changed and $old_loc isn't disabled, so don't change it
+        next;
+      }
+
+    }
+    elsif ( $new_loc->custnum ne $self->custnum or $new_loc->prospectnum ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "$l belongs to customer ".$new_loc->custnum;
+    }
+    # else the new location belongs to this customer so we're good
+
+    # set the foo_locationnum now that we have one.
+    $self->set($l.'num', $new_loc->locationnum);
+
+  } #for $l
+
   my $error = $self->SUPER::replace($old);
 
   if ( $error ) {
@@ -1568,6 +1564,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 );
@@ -1669,24 +1686,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!
 
@@ -1731,16 +1731,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";
@@ -1771,6 +1769,8 @@ 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')
@@ -1778,16 +1778,6 @@ sub check {
     || $self->ut_snumbern('birthdate')
     || $self->ut_snumbern('signupdate')
     || $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')
@@ -1804,9 +1794,6 @@ sub check {
     || $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: /;
@@ -1822,13 +1809,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 {
@@ -1839,23 +1819,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)
@@ -1865,12 +1829,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', $self->agentnum)
+       && ! $import
        && ! length($self->daytime) && ! length($self->night) && ! length($self->mobile)
      ) {
 
@@ -1889,71 +1849,7 @@ 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');
-
-  }
+  #ship_ fields are gone
 
   #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
   #  or return "Illegal payby: ". $self->payby;
@@ -1979,7 +1875,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;
@@ -2201,7 +2099,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
@@ -2212,6 +2110,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.
@@ -2617,6 +2520,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?
@@ -2626,16 +2531,16 @@ 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,
-    'payby'    => $options{payby}    || $self->payby,
-    'payinfo'  => $options{payinfo}  || $self->payinfo,
-    'exp'      => $options{paydate}  || $self->paydate,
-    'payname'  => $options{payname}  || $self->payname,
+    '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}    || $loc->payby,
+    'payinfo'  => $options{payinfo}  || $loc->payinfo,
+    'exp'      => $options{paydate}  || $loc->paydate,
+    'payname'  => $options{payname}  || $loc->payname,
     'amount'   => $amount,                         # consolidating
   } );
   
@@ -3027,7 +2932,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;
@@ -4037,6 +3943,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
@@ -4046,13 +3973,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
@@ -4075,13 +3999,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_company_or_firstlast;
 }
 
 =item contact
@@ -4103,9 +4023,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
@@ -4127,9 +4046,8 @@ 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
@@ -5113,6 +5031,8 @@ sub process_censustract_update {
 #    upgrade journal again?  this is also an ancient problem
 # - 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) = @_;
@@ -5141,12 +5061,13 @@ sub _upgrade_data { #class method
   }
 
   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?)
   $class->_upgrade_otaker(%opts);
 
+  FS::cust_main::Location->_upgrade_data(%opts);
+
 }
 
 =back
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index ca8d996..339fa44 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -721,6 +721,11 @@ jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
 sub calculate_taxes {
   my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
 
+  # $taxlisthash is a hashref
+  # keys are identifiers, values are arrayrefs
+  # each arrayref starts with a tax object (cust_main_county or tax_rate)
+  # then any cust_bill_pkg objects the tax applies to
+
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
   warn "$me calculate_taxes\n"
@@ -746,9 +751,15 @@ sub calculate_taxes {
   my %tax_rate_location = ();
 
   foreach my $tax ( keys %$taxlisthash ) {
+    # $tax is a tax identifier
     my $tax_object = shift @{ $taxlisthash->{$tax} };
+    # $tax_object is a cust_main_county or tax_rate 
+    # (with pkgnum and locationnum set)
+    # the rest of @{ $taxlisthash->{$tax} } is cust_bill_pkg objects
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
     warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
+    # taxline calculates the tax on all cust_bill_pkgs in the 
+    # first (arrayref) argument
     my $hashref_or_error =
       $tax_object->taxline( $taxlisthash->{$tax},
                             'custnum'      => $self->custnum,
@@ -767,8 +778,10 @@ sub calculate_taxes {
 
     $tax{ $tax } += $amount;
 
+    # link records between cust_main_county/tax_rate and cust_location
     $tax_location{ $tax } ||= [];
-    if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
+    $tax_rate_location{ $tax } ||= [];
+    if ( ref($tax_object) eq 'FS::cust_main_county' ) {
       push @{ $tax_location{ $tax }  },
         {
           'taxnum'      => $tax_object->taxnum, 
@@ -778,9 +791,7 @@ sub calculate_taxes {
           'amount'      => sprintf('%.2f', $amount ),
         };
     }
-
-    $tax_rate_location{ $tax } ||= [];
-    if ( ref($tax_object) eq 'FS::tax_rate' ) {
+    elsif ( ref($tax_object) eq 'FS::tax_rate' ) {
       my $taxratelocationnum =
         $tax_object->tax_rate_location->taxratelocationnum;
       push @{ $tax_rate_location{ $tax }  },
@@ -1206,21 +1217,12 @@ sub _handle_taxes {
     } else {
 
       my @loc_keys = qw( district city county state country );
-      my %taxhash;
-      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
-        my $cust_location = $cust_pkg->cust_location;
-        %taxhash = map { $_ => $cust_location->$_()    } @loc_keys;
-      } else {
-        my $prefix = 
-          ( $conf->exists('tax-ship_address') && length($self->ship_last) )
-          ? 'ship_'
-          : '';
-        %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys;
-      }
+      my $location = $cust_pkg->tax_location;
+      my %taxhash = map { $_ => $location->$_ } @loc_keys;
 
       $taxhash{'taxclass'} = $part_pkg->taxclass;
 
-      my @taxes = ();
+      my @taxes = (); # entries are cust_main_county objects
       my %taxhash_elim = %taxhash;
       my @elim = qw( district city county state );
       do { 
@@ -1243,11 +1245,13 @@ sub _handle_taxes {
                     @taxes
         if $self->cust_main_exemption; #just to be safe
 
-      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
-        foreach (@taxes) {
-          $_->set('pkgnum',      $cust_pkg->pkgnum );
-          $_->set('locationnum', $cust_pkg->locationnum );
-        }
+      # all packages now have a locationnum and should get a 
+      # cust_bill_pkg_tax_location record.  The tax_locationnum
+      # may be the package's locationnum, or the customer's bill 
+      # or service location.
+      foreach (@taxes) {
+        $_->set('pkgnum',      $cust_pkg->pkgnum);
+        $_->set('locationnum', $cust_pkg->tax_locationnum);
       }
 
       $taxes{''} = [ @taxes ];
@@ -1274,17 +1278,27 @@ sub _handle_taxes {
 
   my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
   foreach my $key (keys %tax_cust_bill_pkg) {
+    # $key is "setup", "recur", or a usage class name. ('' is a usage class.)
+    # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of 
+    # the line item.
+    # $taxes{$key} is an arrayref of cust_main_county or tax_rate objects that
+    # apply to $key-class charges.
     my @taxes = @{ $taxes{$key} || [] };
     my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
 
     my %localtaxlisthash = ();
     foreach my $tax ( @taxes ) {
 
+      # this is the tax identifier, not the taxname
       my $taxname = ref( $tax ). ' '. $tax->taxnum;
 #      $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
 #                  ' locationnum'. $cust_pkg->locationnum
 #        if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
 
+      # $taxlisthash: keys are "setup", "recur", and usage classes
+      # values are arrayrefs, first the tax object (cust_main_county
+      # or tax_rate) and then any cust_bill_pkg objects that the 
+      # tax applies to
       $taxlisthash->{ $taxname } ||= [ $tax ];
       push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
 
diff --git a/FS/FS/cust_main/Location.pm b/FS/FS/cust_main/Location.pm
new file mode 100644
index 0000000..d1d6d67
--- /dev/null
+++ b/FS/FS/cust_main/Location.pm
@@ -0,0 +1,252 @@
+package FS::cust_main::Location;
+
+use strict;
+use vars qw( $DEBUG $me @location_fields );
+use FS::Record qw(qsearch qsearchs);
+use FS::UID qw(dbh);
+use FS::cust_location;
+
+use Carp qw(carp);
+
+$DEBUG = 1;
+$me = '[FS::cust_main::Location]';
+
+my $init = 0;
+BEGIN {
+  # set up accessors for location fields
+  if (!$init) {
+    no strict 'refs';
+    @location_fields = 
+      qw( address1 address2 city county state zip country district
+        latitude longitude coord_auto censustract censusyear geocode );
+
+    foreach my $f (@location_fields) {
+      *{"FS::cust_main::Location::$f"} = sub {
+        carp "WARNING: tried to set cust_main.$f with accessor" if (@_ > 1);
+        shift->bill_location->$f
+      };
+      *{"FS::cust_main::Location::ship_$f"} = sub {
+        carp "WARNING: tried to set cust_main.ship_$f with accessor" if (@_ > 1);
+        shift->ship_location->$f
+      };
+    }
+    $init++;
+  }
+}
+
+#debugging shim--probably a performance hit, so remove this at some point
+sub get {
+  my $self = shift;
+  my $field = shift;
+  if ( $DEBUG and grep (/^(ship_)?($field)$/, @location_fields) ) {
+    carp "WARNING: tried to get() location field $field";
+    $self->$field;
+  }
+  $self->FS::Record::get($field);
+}
+
+=head1 NAME
+
+FS::cust_main::Location - Location-related methods for cust_main
+
+=head1 DESCRIPTION
+
+These methods are available on FS::cust_main objects;
+
+=head1 METHODS
+
+=over 4
+
+=item bill_location
+
+Returns an L<FS::cust_location> object for the customer's billing address.
+
+=cut
+
+sub bill_location {
+  my $self = shift;
+  $self->hashref->{bill_location} 
+    ||= FS::cust_location->by_key($self->bill_locationnum);
+}
+
+=item ship_location
+
+Returns an L<FS::cust_location> object for the customer's service address.
+
+=cut
+
+sub ship_location {
+  my $self = shift;
+  $self->hashref->{ship_location}
+    ||= FS::cust_location->by_key($self->ship_locationnum);
+}
+
+=item location TYPE
+
+An alternative way of saying "bill_location or ship_location, depending on 
+if TYPE is 'bill' or 'ship'".
+
+=cut
+
+sub location {
+  my $self = shift;
+  return $self->bill_location if $_[0] eq 'bill';
+  return $self->ship_location if $_[0] eq 'ship';
+  die "bad location type '$_[0]'";
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item location_fields
+
+Returns a list of fields found in the location objects.  All of these fields
+can be read (but not written) by calling them as methods on the 
+L<FS::cust_main> object (prefixed with 'ship_' for the service address 
+fields).
+
+=cut
+
+sub location_fields { @location_fields }
+
+sub _upgrade_data {
+  my $class = shift;
+  eval "use FS::contact;
+        use FS::contact_class;
+        use FS::contact_phone;
+        use FS::phone_type";
+
+  local $FS::cust_location::import = 1;
+  local $DEBUG = 0;
+  my $error;
+
+  # Step 0: set up contact classes and phone types
+  my $service_contact_class = 
+    qsearchs('contact_class', { classname => 'Service'})
+    || new FS::contact_class { classname => 'Service'};
+
+  if ( !$service_contact_class->classnum ) {
+    $error = $service_contact_class->insert;
+    die "error creating contact class for Service: $error" if $error;
+  }
+  my %phone_type = ( # fudge slightly
+    daytime => 'Work',
+    night   => 'Home',
+    mobile  => 'Mobile',
+    fax     => 'Fax'
+  );
+  my $w = 10;
+  foreach (keys %phone_type) {
+    $phone_type{$_} = qsearchs('phone_type', { typename => $phone_type{$_}})
+                      || new FS::phone_type  { typename => $phone_type{$_},
+                                               weight   => $w };
+    # just in case someone still doesn't have these
+    if ( !$phone_type{$_}->phonetypenum ) {
+      $error = $phone_type{$_}->insert;
+      die "error creating phone type '$_': $error";
+    }
+  }
+
+  foreach my $cust_main (qsearch('cust_main', { bill_locationnum => '' })) {
+    # Step 1: extract billing and service addresses into cust_location
+    my $custnum = $cust_main->custnum;
+    my $bill_location = FS::cust_location->new(
+      {
+        custnum => $custnum,
+        map { $_ => $cust_main->get($_) } location_fields()
+      }
+    );
+    $error = $bill_location->insert;
+    die "error migrating billing address for customer $custnum: $error"
+      if $error;
+
+    $cust_main->set(bill_locationnum => $bill_location->locationnum);
+
+    if ( $cust_main->get('ship_address1') ) {
+      my $ship_location = FS::cust_location->new(
+        {
+          custnum => $custnum,
+          map { $_ => $cust_main->get("ship_$_") } location_fields()
+        }
+      );
+      $error = $ship_location->insert;
+      die "error migrating service address for customer $custnum: $error"
+        if $error;
+
+      $cust_main->set(ship_locationnum => $ship_location->locationnum);
+
+      # Step 2: Extract shipping address contact fields into contact
+      my %unlike = map { $_ => 1 }
+        grep { $cust_main->get($_) ne $cust_main->get("ship_$_") }
+        qw( last first company daytime night fax mobile );
+
+      if ( %unlike ) {
+        # then there IS a service contact
+        my $contact = FS::contact->new({
+          'custnum'     => $custnum,
+          'classnum'    => $service_contact_class->classnum,
+          'locationnum' => $ship_location->locationnum,
+          'last'        => $cust_main->get('ship_last'),
+          'first'       => $cust_main->get('ship_first'),
+        });
+        if ( $unlike{'company'} ) {
+          # there's no contact.company field, but keep a record of it
+          $contact->set(comment => 'Company: '.$cust_main->get('ship_company'));
+        }
+        $error = $contact->insert;
+        die "error migrating service contact for customer $custnum: $error"
+          if $error;
+
+        foreach ( grep { $unlike{$_} } qw( daytime night fax mobile ) ) {
+          my $phone = $cust_main->get("ship_$_");
+          next if !$phone;
+          my $contact_phone = FS::contact_phone->new({
+            'contactnum'    => $contact->contactnum,
+            'phonetypenum'  => $phone_type{$_}->phonetypenum,
+            FS::contact::_parse_phonestring( $phone )
+          });
+          $error = $contact_phone->insert;
+          # die "whose responsible this"
+          die "error migrating service contact phone for customer $custnum: $error"
+            if $error;
+          $cust_main->set("ship_$_" => '');
+        }
+
+        $cust_main->set("ship_$_" => '') foreach qw(last first company);
+      } #if %unlike
+    } #if ship_address1
+    else {
+      $cust_main->set(ship_locationnum => $bill_location->locationnum);
+    }
+
+    # Step 3: Wipe the migrated fields and update the cust_main
+
+    $cust_main->set("ship_$_" => '') foreach location_fields();
+    $cust_main->set($_ => '') foreach location_fields();
+
+    $error = $cust_main->replace;
+    die "error migrating addresses for customer $custnum: $error"
+      if $error;
+
+    # Step 4: set packages at the "default service location" to ship_location
+    foreach my $cust_pkg (
+      qsearch('cust_pkg', { custnum => $custnum, locationnum => '' })  
+    ) {
+      # not a location change
+      $cust_pkg->set('locationnum', $cust_main->ship_locationnum);
+      $error = $cust_pkg->replace;
+      die "error migrating package ".$cust_pkg->pkgnum.": $error"
+        if $error;
+    }
+
+  } #foreach $cust_main
+}
+
+=back
+
+=cut
+
+1;
diff --git a/FS/FS/cust_main/Packages.pm b/FS/FS/cust_main/Packages.pm
index 06331d3..887ac49 100644
--- a/FS/FS/cust_main/Packages.pm
+++ b/FS/FS/cust_main/Packages.pm
@@ -40,7 +40,8 @@ FS::cust_pkg object
 
 =item cust_location
 
-Optional FS::cust_location object
+Optional FS::cust_location object.  If not specified, the customer's 
+ship_location will be used.
 
 =item svcs
 
@@ -105,6 +106,9 @@ sub order_pkg {
     }
     $cust_pkg->locationnum($opt->{'cust_location'}->locationnum);
   }
+  else {
+    $cust_pkg->locationnum($self->ship_locationnum);
+  }
 
   $cust_pkg->custnum( $self->custnum );
 
diff --git a/FS/FS/cust_main/Search.pm b/FS/FS/cust_main/Search.pm
index b663c20..ca4d167 100644
--- a/FS/FS/cust_main/Search.pm
+++ b/FS/FS/cust_main/Search.pm
@@ -85,8 +85,7 @@ sub smart_search {
       'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
                      ' ( '.
                          join(' OR ', map "$_ = '$phonen'",
-                                          qw( daytime night fax
-                                              ship_daytime ship_night ship_fax )
+                                          qw( daytime night fax )
                              ).
                      ' ) '.
                      " AND $agentnums_sql", #agent virtualization
@@ -101,8 +100,7 @@ sub smart_search {
         'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
                        ' ( '.
                            join(' OR ', map "$_ LIKE '$phonen\%'",
-                                            qw( daytime night
-                                                ship_daytime ship_night )
+                                            qw( daytime night )
                                ).
                        ' ) '.
                        " AND $agentnums_sql", #agent virtualization
@@ -175,16 +173,17 @@ sub smart_search {
     if ( $conf->exists('address1-search') ) {
       my $len = length($num);
       $num = lc($num);
-      foreach my $prefix ( '', 'ship_' ) {
-        push @cust_main, qsearch( {
-          'table'     => 'cust_main',
-          'hashref'   => { %options, },
-          'extra_sql' => 
-            ( keys(%options) ? ' AND ' : ' WHERE ' ).
-            " LOWER(SUBSTRING(${prefix}address1 FROM 1 FOR $len)) = '$num' ".
-            " AND $agentnums_sql",
-        } );
-      }
+      # probably the Right Thing: return customers that have any associated
+      # locations matching the string, not just bill/ship location
+      push @cust_main, qsearch( {
+        'table'     => 'cust_main',
+        'addl_from' => ' JOIN cust_location USING (custnum) ',
+        'hashref'   => { %options, },
+        'extra_sql' => 
+          ( keys(%options) ? ' AND ' : ' WHERE ' ).
+          " LOWER(SUBSTRING(cust_location.address1 FROM 1 FOR $len)) = '$num' ".
+          " AND $agentnums_sql",
+      } );
     }
 
   } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
@@ -196,20 +195,19 @@ sub smart_search {
     #so just do an exact search (but case-insensitive, so USPS standardization
     #doesn't throw a wrench in the works)
 
-    foreach my $prefix ( '', 'ship_' ) {
-      push @cust_main, qsearch( {
+    push @cust_main, qsearch( {
         'table'     => 'cust_main',
         'hashref'   => { %options },
         'extra_sql' => 
-          ( keys(%options) ? ' AND ' : ' WHERE ' ).
-          join(' AND ',
-            " LOWER(${prefix}first)   = ". dbh->quote(lc($first)),
-            " LOWER(${prefix}last)    = ". dbh->quote(lc($last)),
-            " LOWER(${prefix}company) = ". dbh->quote(lc($company)),
-            $agentnums_sql,
-          ),
-      } );
-    }
+        ( keys(%options) ? ' AND ' : ' WHERE ' ).
+        join(' AND ',
+          " LOWER(first)   = ". dbh->quote(lc($first)),
+          " LOWER(last)    = ". dbh->quote(lc($last)),
+          " LOWER(company) = ". dbh->quote(lc($company)),
+          $agentnums_sql,
+        ),
+      } ),
+    #contacts?
 
   } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
                                               # try (ship_){last,company}
@@ -247,16 +245,14 @@ sub smart_search {
 
       #exact
       my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
-      $sql .= "
-        (     ( LOWER(last) = $q_last AND LOWER(first) = $q_first )
-           OR ( LOWER(ship_last) = $q_last AND LOWER(ship_first) = $q_first )
-        )";
+      $sql .= "( LOWER(cust_main.last) = $q_last AND LOWER(cust_main.first) = $q_first )";
 
       push @cust_main, qsearch( {
         'table'     => 'cust_main',
         'hashref'   => \%options,
         'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
       } );
+      #contacts?
 
       # or it just be something that was typed in... (try that in a sec)
 
@@ -268,11 +264,13 @@ sub smart_search {
     my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
     $sql .= " (    LOWER(last)          = $q_value
                 OR LOWER(company)       = $q_value
-                OR LOWER(ship_last)     = $q_value
-                OR LOWER(ship_company)  = $q_value
             ";
-    $sql .= "   OR LOWER(address1)      = $q_value
-                OR LOWER(ship_address1) = $q_value
+    #yes, it's a kludge
+    $sql .= "   OR EXISTS( 
+                SELECT 1 FROM cust_location 
+                WHERE LOWER(cust_location.address1) = $q_value
+                  AND cust_location.custnum = cust_main.custnum
+            )
             "
       if $conf->exists('address1-search');
     $sql .= " )";
@@ -294,32 +292,21 @@ sub smart_search {
 
       my @hashrefs = (
         { 'company'      => { op=>'ILIKE', value=>"%$value%" }, },
-        { 'ship_company' => { op=>'ILIKE', value=>"%$value%" }, },
       );
 
       if ( $first && $last ) {
+        #contacts? ship_first/ship_last are gone
 
         push @hashrefs,
           { 'first'        => { op=>'ILIKE', value=>"%$first%" },
             'last'         => { op=>'ILIKE', value=>"%$last%" },
           },
-          { 'ship_first'   => { op=>'ILIKE', value=>"%$first%" },
-            'ship_last'    => { op=>'ILIKE', value=>"%$last%" },
-          },
         ;
 
       } else {
 
         push @hashrefs,
           { 'last'         => { op=>'ILIKE', value=>"%$value%" }, },
-          { 'ship_last'    => { op=>'ILIKE', value=>"%$value%" }, },
-        ;
-      }
-
-      if ( $conf->exists('address1-search') ) {
-        push @hashrefs,
-          { 'address1'      => { op=>'ILIKE', value=>"%$value%" }, },
-          { 'ship_address1' => { op=>'ILIKE', value=>"%$value%" }, },
         ;
       }
 
@@ -335,27 +322,38 @@ sub smart_search {
 
       }
 
+      if ( $conf->exists('address1-search') ) {
+
+        push @cust_main, qsearch( {
+          'table'     => 'cust_main',
+          'addl_from' => 'JOIN cust_location USING (custnum)',
+          'extra_sql' => 'WHERE cust_location.address1 ILIKE '.
+                          dbh->quote("%$value%"),
+        } );
+
+      }
+
       #fuzzy
-      my @fuzopts = (
-        \%options,                #hashref
-        '',                       #select
-        " AND $agentnums_sql",    #extra_sql  #agent virtualization
+      my %fuzopts = (
+        'hashref'   => \%options,
+        'select'    => '',
+        'extra_sql' => " AND $agentnums_sql",    #agent virtualization
       );
 
       if ( $first && $last ) {
         push @cust_main, FS::cust_main::Search->fuzzy_search(
           { 'last'   => $last,    #fuzzy hashref
             'first'  => $first }, #
-          @fuzopts
+          %fuzopts
         );
       }
       foreach my $field ( 'last', 'company' ) {
         push @cust_main,
-          FS::cust_main::Search->fuzzy_search( { $field => $value }, @fuzopts );
+          FS::cust_main::Search->fuzzy_search( { $field => $value }, %fuzopts );
       }
       if ( $conf->exists('address1-search') ) {
         push @cust_main,
-          FS::cust_main::Search->fuzzy_search( { 'address1' => $value }, @fuzopts );
+          FS::cust_main::Search->fuzzy_search( { 'address1' => $value }, %fuzopts );
       }
 
     }
@@ -566,11 +564,12 @@ sub search {
   ##
   if ( $params->{'address'} =~ /\S/ ) {
     my $address = dbh->quote('%'. lc($params->{'address'}). '%');
-    push @where, '('. join(' OR ',
-                             map "LOWER($_) LIKE $address",
-                               qw(address1 address2 ship_address1 ship_address2)
-                          ).
-                 ')';
+    push @where, "EXISTS(
+      SELECT 1 FROM cust_location 
+      WHERE cust_location.custnum = cust_main.custnum
+        AND (LOWER(cust_location.address1) LIKE $address OR
+             LOWER(cust_location.address2) LIKE $address)
+    )";
   }
 
   ###
@@ -839,20 +838,27 @@ sub search {
 
 }
 
-=item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
+=item fuzzy_search FUZZY_HASHREF [ OPTS ]
 
 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
 records.  Currently, I<first>, I<last>, I<company> and/or I<address1> may be
-specified (the appropriate ship_ field is also searched).
+specified.
 
 Additional options are the same as FS::Record::qsearch
 
 =cut
 
 sub fuzzy_search {
-  my( $self, $fuzzy, $hash, @opt) = @_;
-  #$self
-  $hash ||= {};
+  my( $self, $fuzzy ) = @_;
+  # sensible defaults, then merge in any passed options
+  my %fuzopts = (
+    'table'     => 'cust_main',
+    'addl_from' => '',
+    'extra_sql' => '',
+    'hashref'   => {},
+    @_
+  );
+
   my @cust_main = ();
 
   check_and_rebuild_fuzzyfiles();
@@ -866,8 +872,25 @@ sub fuzzy_search {
 
     my @fcust = ();
     foreach ( keys %match ) {
-      push @fcust, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
-      push @fcust, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt);
+      if ( $field eq 'address1' ) {
+        #because it lives outside the table
+        my $addl_from = $fuzopts{addl_from} .
+                        'JOIN cust_location USING (custnum)';
+        my $extra_sql = $fuzopts{extra_sql} .
+                        " AND cust_location.address1 = ".dbh->quote($_);
+        push @fcust, qsearch({
+            %fuzopts,
+            'addl_from' => $addl_from,
+            'extra_sql' => $extra_sql,
+        });
+      } else {
+        my $hash = $fuzopts{hashref};
+        $hash->{$field} = $_;
+        push @fcust, qsearch({
+            %fuzopts,
+            'hashref' => $hash
+        });
+      }
     }
     my %fsaw = ();
     push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm
index 788b1d3..6899fa4 100644
--- a/FS/FS/cust_pkg.pm
+++ b/FS/FS/cust_pkg.pm
@@ -2616,6 +2616,39 @@ Returns the label of the location object (see L<FS::cust_location>).
 
 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
 
+=item tax_locationnum
+
+Returns the foreign key to a L<FS::cust_location> object for calculating  
+tax on this package, as determined by the C<tax-pkg_address> and 
+C<tax-ship_address> configuration flags.
+
+=cut
+
+sub tax_locationnum {
+  my $self = shift;
+  my $conf = FS::Conf->new;
+  if ( $conf->exists('tax-pkg_address') ) {
+    return $self->locationnum;
+  }
+  elsif ( $conf->exists('tax-ship_address') ) {
+    return $self->cust_main->ship_locationnum;
+  }
+  else {
+    return $self->cust_main->bill_locationnum;
+  }
+}
+
+=item tax_location
+
+Returns the L<FS::cust_location> object for tax_locationnum.
+
+=cut
+
+sub tax_location {
+  my $self = shift;
+  FS::cust_location->by_key( $self->tax_locationnum )
+}
+
 =item seconds_since TIMESTAMP
 
 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
@@ -3602,6 +3635,25 @@ sub fcc_477_count {
 
 }
 
+=item tax_locationnum_sql
+
+Returns an SQL expression for the tax location for a package, based
+on the settings of 'tax-pkg_address' and 'tax-ship_address'.
+
+=cut
+
+sub tax_locationnum_sql {
+  my $conf = FS::Conf->new;
+  if ( $conf->exists('tax-pkg_address') ) {
+    'cust_pkg.locationnum';
+  }
+  elsif ( $conf->exists('tax-ship_address') ) {
+    'cust_main.ship_locationnum';
+  }
+  else {
+    'cust_main.bill_locationnum';
+  }
+}
 
 =item location_sql
 
diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm
index 62bcebc..c3e781a 100644
--- a/FS/FS/msg_template.pm
+++ b/FS/FS/msg_template.pm
@@ -485,6 +485,11 @@ sub substitutions {
       signupdate dundate
       packages recurdates
       ),
+      #compatibility: obsolete ship_ fields
+      map ( { [ "ship_$_"   => sub { shift->$_ } ] } 
+        qw( last first company name name_short contact contact_firstlast
+            daytime night fax )
+      ),
       [ expdate           => sub { shift->paydate_epoch } ], #compatibility
       [ signupdate_ymd    => sub { $ymd->(shift->signupdate) } ],
       [ dundate_ymd       => sub { $ymd->(shift->dundate) } ],
diff --git a/httemplate/edit/cust_main.cgi b/httemplate/edit/cust_main.cgi
index 0289246..a30c7c1 100755
--- a/httemplate/edit/cust_main.cgi
+++ b/httemplate/edit/cust_main.cgi
@@ -23,6 +23,8 @@
 % } 
 
 %# agent, agent_custid, refnum (advertising source), referral_custnum
+%# better section title for this?
+<FONT CLASS="fsinnerbox-title"><% mt('Basics') |h %></FONT>
 <& cust_main/top_misc.html, $cust_main, 'custnum' => $custnum  &>
 
 %# birthdate
@@ -33,113 +35,77 @@
   <BR>
   <& cust_main/birthdate.html, $cust_main &>
 % }
-
-%# contact info
-
-%  my $same_checked = '';
-%  my $ship_disabled = '';
-%  my @ship_style = ();
-%  unless ( $cust_main->ship_last && $same ne 'Y' ) {
-%    $same_checked = 'CHECKED';
-%    $ship_disabled = 'DISABLED';
-%    push @ship_style, 'background-color:#dddddd';
-%    foreach (
-%      qw( last first company address1 address2 city county state zip country
-%          latitude longitude coord_auto
-%          daytime night fax mobile )
-%    ) {
-%      $cust_main->set("ship_$_", $cust_main->get($_) );
-%    }
-%  }
-
+% my $has_ship_address = '';
+% if ( $cgi->param('error') ) {
+%   $has_ship_address = !$cgi->param('same');
+% } elsif ( $cust_main->custnum ) {
+%   $has_ship_address = $cust_main->has_ship_address;
+% }
 <BR>
-<FONT CLASS="fsinnerbox-title"><% mt('Billing address') |h %></FONT>
-
-<& cust_main/contact.html,
-             'cust_main'    => $cust_main,
-             'pre'          => '',
-             'onchange'     => 'bill_changed(this)',
-             'disabled'     => '',
-             'ss'           => $ss,
-             'stateid'      => $stateid,
-             'same_checked' => $same_checked, #for address2 "Unit #" labeling
-&>
+<TABLE> <TR>
+  <TD STYLE="width:650px">
+%#; padding-right:2px; vertical-align:top">
+    <FONT CLASS="fsinnerbox-title"><% mt('Billing address') |h %></FONT>
+    <TABLE CLASS="fsinnerbox">
+    <& cust_main/before_bill_location.html, $cust_main &>
+    <& /elements/location.html,
+        object => $cust_main->bill_location,
+        prefix => 'bill_',
+    &>
+    <& cust_main/after_bill_location.html, $cust_main &>
+    </TABLE>
+  </TD>
+</TR>
+<TR><TD STYLE="height:40px"></TD></TR>
+<TR>
+  <TD STYLE="width:650px">
+%#; padding-left:2px; vertical-align:top">
+    <FONT CLASS="fsinnerbox-title"><% mt('Service address') |h %></FONT>
+    <INPUT TYPE="checkbox" 
+           NAME="same"
+           ID="same"
+           onclick="samechanged(this)"
+           onkeyup="samechanged(this)"
+           VALUE="Y"
+           <% $has_ship_address ? '' : 'CHECKED' %>
+    ><% mt('same as billing address') |h %>
+    <TABLE CLASS="fsinnerbox" ID="table_ship_location">
+    <& /elements/location.html,
+        object => $cust_main->ship_location,
+        prefix => 'ship_',
+        enable_censustract => 1,
+        enable_district => 1,
+    &>
+    </TABLE>
+    <TABLE CLASS="fsinnerbox" ID="table_ship_location_blank"
+    STYLE="display:none">
+    <TR><TD></TD></TR>
+    </TABLE>
+  </TD>
+</TR></TABLE>
 
 <SCRIPT>
-function bill_changed(what) {
-  if ( what.form.same.checked ) {
-% for (qw( last first company address1 address2 city zip latitude longitude coord_auto daytime night fax mobile )) { 
-    what.form.ship_<%$_%>.value = what.form.<%$_%>.value;
-% } 
-
-    what.form.ship_country.selectedIndex = what.form.country.selectedIndex;
-
-    function fix_ship_city() {
-      what.form.ship_city_select.selectedIndex = what.form.city_select.selectedIndex;
-      what.form.ship_city.style.display = what.form.city.style.display;
-      what.form.ship_city_select.style.display = what.form.city_select.style.display;
-    }
-
-    function fix_ship_county() {
-      what.form.ship_county.selectedIndex = what.form.county.selectedIndex;
-      ship_county_changed(what.form.ship_county, fix_ship_city );
-    }
-
-    function fix_ship_state() {
-      what.form.ship_state.selectedIndex = what.form.state.selectedIndex;
-      ship_state_changed(what.form.ship_state, fix_ship_county );
-    }
-
-    ship_country_changed(what.form.ship_country, fix_ship_state );
-
-  }
-}
 function samechanged(what) {
+%# not display = 'none', because we still want it to take up space
+%#  document.getElementById('table_ship_location').style.visibility = 
+%#    what.checked ? 'hidden' : 'visible';
+  var t1 = document.getElementById('table_ship_location');
+  var t2 = document.getElementById('table_ship_location_blank');
   if ( what.checked ) {
-    bill_changed(what);
-
-%   my @fields = qw( last first company address1 address2 city city_select county state zip country latitude longitude daytime night fax mobile );
-%   for (@fields) { 
-      what.form.ship_<%$_%>.disabled = true;
-      what.form.ship_<%$_%>.style.backgroundColor = '#dddddd';
-%   } 
-
-%   if ( $conf->exists('cust_main-require_address2') ) {
-      document.getElementById('address2_required').style.visibility = '';
-      document.getElementById('address2_label').style.visibility = '';
-      document.getElementById('ship_address2_required').style.visibility = 'hidden';
-      document.getElementById('ship_address2_label').style.visibility = 'hidden';
-%   }
-
-  } else {
-
-%   for (@fields) { 
-      what.form.ship_<%$_%>.disabled = false;
-      what.form.ship_<%$_%>.style.backgroundColor = '#ffffff';
-%   } 
-
-%   if ( $conf->exists('cust_main-require_address2') ) {
-      document.getElementById('address2_required').style.visibility = 'hidden';
-      document.getElementById('address2_label').style.visibility = 'hidden';
-      document.getElementById('ship_address2_required').style.visibility = '';
-      document.getElementById('ship_address2_label').style.visibility = '';
-%   }
-
+    t2.style.width  = t1.clientWidth  + 'px';
+    t2.style.height = t1.clientHeight + 'px';
+    t1.style.display = 'none';
+    t2.style.display = '';
+  }
+  else {
+    t2.style.display = 'none';
+    t1.style.display = '';
   }
 }
+samechanged(document.getElementById('same'));
 </SCRIPT>
 
 <BR>
-<FONT CLASS="fsinnerbox-title"><% mt('Service address') |h %></FONT>
-
-<INPUT TYPE="checkbox" NAME="same" VALUE="Y" onClick="samechanged(this)" <%$same_checked%>><% mt('same as billing address') |h %>
-<& cust_main/contact.html,
-             'cust_main' => $cust_main,
-             'pre'       => 'ship_',
-             'onchange'  => '',
-             'disabled'  => $ship_disabled,
-             'style'     => \@ship_style
-&>
 
 <& cust_main/contacts_new.html,
              'cust_main' => $cust_main,
@@ -242,10 +208,28 @@ my $locationnum = '';
 
 if ( $cgi->param('error') ) {
 
+  # false laziness w/ edit/process/cust_main.cgi
+  my %locations;
+  for my $pre (qw(bill ship)) {
+    my %hash;
+    foreach ( FS::cust_main->location_fields ) {
+      $hash{$_} = scalar($cgi->param($pre.'_'.$_));
+    }
+    $hash{'custnum'} = $cgi->param('custnum');
+    $locations{$pre} = qsearchs('cust_location', \%hash)
+                       || FS::cust_location->new( \%hash );
+  }
+
   $cust_main = new FS::cust_main ( {
-    map { $_, scalar($cgi->param($_)) } fields('cust_main')
+    map { ( $_, scalar($cgi->param($_)) ) } (fields('cust_main')),
+    map { ( "ship_$_", '' ) } (FS::cust_main->location_fields)
   } );
 
+  for my $pre (qw(bill ship)) {
+    $cust_main->set($pre.'_location', $locations{$pre});
+    $cust_main->set($pre.'_locationnum', $locations{$pre}->locationnum);
+  }
+
   $custnum = $cust_main->custnum;
 
   die "access denied"
@@ -355,6 +339,20 @@ if ( $cgi->param('error') ) {
     $svc_dsl{$_} = $qual->$_
       foreach qw( phonenum vendor_qual_id );
   }
+  else {
+    my $countrydefault = $conf->config('countrydefault') || 'US';
+    my $statedefault = $conf->config('statedefault') || 'CA';
+    $cust_main->set('bill_location', 
+      FS::cust_location->new(
+        { country => $countrydefault, state => $statedefault }
+      )
+    );
+    $cust_main->set('ship_location',
+      FS::cust_location->new(
+        { country => $countrydefault, state => $statedefault }
+      )
+    );
+  }
 
   if ( $cgi->param('lock_pkgpart') =~ /^(\d+)$/ ) {
     my $pkgpart = $1;
diff --git a/httemplate/edit/cust_main/after_bill_location.html b/httemplate/edit/cust_main/after_bill_location.html
new file mode 100644
index 0000000..2f4c3b5
--- /dev/null
+++ b/httemplate/edit/cust_main/after_bill_location.html
@@ -0,0 +1,12 @@
+% if ( ! $conf->exists('cust-edit-alt-field-order') ) {
+  <& phones.html, $cust_main &>
+  <& fax.html, $cust_main &>
+% } else {
+  <& fax.html, $cust_main &>
+  <& company.html, $cust_main &>
+% }
+<& stateid.html, $cust_main &>
+<%init>
+my $cust_main = shift;
+my $conf = FS::Conf->new;
+</%init>
diff --git a/httemplate/edit/cust_main/before_bill_location.html b/httemplate/edit/cust_main/before_bill_location.html
new file mode 100644
index 0000000..973201e
--- /dev/null
+++ b/httemplate/edit/cust_main/before_bill_location.html
@@ -0,0 +1,10 @@
+<& name.html, $cust_main &>
+% if ( ! $conf->exists('cust-edit-alt-field-order') ) {
+  <& company.html, $cust_main &>
+% } else {
+  <& phones.html, $cust_main &>
+% }
+<%init>
+my $cust_main = shift;
+my $conf = FS::Conf->new;
+</%init>
diff --git a/httemplate/edit/cust_main/birthdate.html b/httemplate/edit/cust_main/birthdate.html
index 6d1c221..5d6a123 100644
--- a/httemplate/edit/cust_main/birthdate.html
+++ b/httemplate/edit/cust_main/birthdate.html
@@ -1,4 +1,5 @@
 <% ntable("#cccccc", 2) %>
+% # maybe put after the contact names?
 % if ( $conf->exists('cust_main-enable_birthdate') ) {
   <% include( '/elements/tr-input-date-field.html', {
                 'name'        => 'birthdate',
diff --git a/httemplate/edit/cust_main/bottomfixup.js b/httemplate/edit/cust_main/bottomfixup.js
index 800864b..77d4294 100644
--- a/httemplate/edit/cust_main/bottomfixup.js
+++ b/httemplate/edit/cust_main/bottomfixup.js
@@ -66,21 +66,25 @@ function copy_payby_fields() {
 %# call submit_continue() on completion...
 %# otherwise not touching standardize_locations for now
 <% include( '/elements/standardize_locations.js',
-            'callback' => 'submit_continue();'
+            'callback' => 'submit_continue();',
+            'main_prefix' => 'bill_',
+            'no_company' => 1,
           )
 %>
 
+var prefix;
 function fetch_censustract() {
 
   //alert('fetch census tract data');
+  prefix = document.getElementById('same').checked ? 'bill_' : 'ship_';
   var cf = document.CustomerForm;
-  var state_el = cf.elements['ship_state'];
+  var state_el = cf.elements[prefix + 'state'];
   var census_data = new Array(
     'year',     <% $conf->config('census_year') || '2012' %>,
-    'address1', cf.elements['ship_address1'].value,
-    'city',     cf.elements['ship_city'].value,
+    'address1', cf.elements[prefix + 'address1'].value,
+    'city',     cf.elements[prefix + 'city'].value,
     'state',    state_el.options[ state_el.selectedIndex ].value,
-    'zip',      cf.elements['ship_zip'].value
+    'zip',      cf.elements[prefix + 'zip'].value
   );
 
   censustract( census_data, update_censustract );
@@ -109,19 +113,21 @@ function update_censustract(arg) {
 
   set_censustract = function () {
 
-    cf.elements['censustract'].value = newcensus;
+    cf.elements[prefix + 'censustract'].value = newcensus;
     submit_continue();
 
   }
 
-  if (error || cf.elements['censustract'].value != newcensus) {
+  if (error || cf.elements[prefix + 'censustract'].value != newcensus) {
     // popup an entry dialog
 
     if (error) { newcensus = error; }
     newcensus.replace(/.*ndefined.*/, 'Not found');
 
-    var latitude = cf.elements['latitude' ].value || '<% $company_latitude %>';
-    var longitude= cf.elements['longitude'].value || '<% $company_longitude %>';
+    var latitude = cf.elements[prefix + 'latitude'].value 
+                   || '<% $company_latitude %>';
+    var longitude= cf.elements[prefix + 'longitude'].value 
+                   || '<% $company_longitude %>';
 
     var choose_censustract =
       '<CENTER><BR><B>Confirm censustract</B><BR>' +
@@ -132,14 +138,14 @@ function update_censustract(arg) {
       '" target="_blank">Map service module location</A><BR>' +
       '<A href="http://maps.ffiec.gov/FFIECMapper/TGMapSrv.aspx?' +
       'census_year=<% $conf->config('census_year') || '2012' %>' +
-      '&zip_code=' + cf.elements['ship_zip'].value +
+      '&zip_code=' + cf.elements[prefix + 'zip'].value +
       '" target="_blank">Map zip code center</A><BR><BR>' +
       '<TABLE>';
     
     choose_censustract = choose_censustract + 
       '<TR><TH style="width:50%">Entered census tract</TH>' +
         '<TH style="width:50%">Calculated census tract</TH></TR>' +
-      '<TR><TD>' + cf.elements['censustract'].value +
+      '<TR><TD>' + cf.elements[prefix + 'censustract'].value +
         '</TD><TD>' + newcensus + '</TD></TR>' +
         '<TR><TD> </TD><TD> </TD></TR>';
 
diff --git a/httemplate/edit/cust_main/company.html b/httemplate/edit/cust_main/company.html
new file mode 100644
index 0000000..8a6ed0b
--- /dev/null
+++ b/httemplate/edit/cust_main/company.html
@@ -0,0 +1,7 @@
+% my $cust_main = shift;
+<TR ID="company_row" <% $cust_main->company ? '' : 'STYLE="display:none"' %>>
+  <TD ALIGN="right"><% mt('Company') |h %></TD>
+  <TD COLSPAN=6><INPUT TYPE="text" NAME="company" ID="company" SIZE=60
+             VALUE="<% $cust_main->company |h %>">
+  </TD>
+</TR>
diff --git a/httemplate/edit/cust_main/fax.html b/httemplate/edit/cust_main/fax.html
new file mode 100644
index 0000000..237d4be
--- /dev/null
+++ b/httemplate/edit/cust_main/fax.html
@@ -0,0 +1,5 @@
+% my $cust_main = shift;
+<TR>
+  <TD ALIGN="right"><% mt('Fax') |h %></TD>
+  <TD><INPUT TYPE="text" NAME="fax" VALUE="<% $cust_main->fax %>" SIZE=18></TD>
+</TR>
diff --git a/httemplate/edit/cust_main/name.html b/httemplate/edit/cust_main/name.html
new file mode 100644
index 0000000..2641ec9
--- /dev/null
+++ b/httemplate/edit/cust_main/name.html
@@ -0,0 +1,53 @@
+<%def .namepart>
+% my ($field, $value, $label, $extra) = @_;
+<TD>
+  <INPUT TYPE="text" NAME="<% $field %>" VALUE="<% $value |h %>" <%$extra%>>
+  <BR><FONT SIZE=-1><% mt($label) %></FONT>
+</TD>
+</%def>
+
+<TR>
+  <TH VALIGN="top" ALIGN="right"><%$r%><% mt('Contact name') |h %></TH>
+  <TD COLSPAN=6>
+    <TABLE CELLSPACING=0 CELLPADDING=0>
+      <TR>
+        <& .namepart, 'last', $cust_main->last, 'Last' &>
+        <TD VALIGN="top"> , </TD>
+        <& .namepart, 'first', $cust_main->first, 'First' &>
+% if ( $conf->exists('show_ss') ) {
+        <TD> </TD>
+        <& .namepart, 'ss', $ss, 'SS#', "SIZE=11" &>
+% } else  {
+        <INPUT TYPE="hidden" NAME="ss" VALUE="<% $ss %>">
+% }
+      </TR>
+    </TABLE>
+  </TD>
+</TR>
+
+% if ( $conf->exists('cust-email-high-visibility') ) {
+<TR>
+  <TD ALIGN="right">
+    <% $conf->exists('cust_main-require_invoicing_list_email', $agentnum)
+        ? $r
+        : '' %>Email address(es)
+  </TD>
+  <TD BGCOLOR="#FFFF00">
+    <INPUT TYPE="text" NAME="invoicing_list" 
+           VALUE=<% $cust_main->invoicing_list_emailonly_scalar %>>
+  </TD>
+</TR>
+% }
+<%init>
+my $cust_main = shift;
+my $agentnum = $cust_main->agentnum if $cust_main->custnum;
+my $conf = FS::Conf->new;
+my $r = '<font color="#ff0000">*</font> ';
+my $ss;
+
+if ( $cgi->param('error') or $conf->exists('unmask_ss') ) {
+  $ss = $cust_main->ss;
+} else {
+  $ss = $cust_main->masked('ss');
+}
+</%init>
diff --git a/httemplate/edit/cust_main/phones.html b/httemplate/edit/cust_main/phones.html
new file mode 100644
index 0000000..9b23e07
--- /dev/null
+++ b/httemplate/edit/cust_main/phones.html
@@ -0,0 +1,29 @@
+<TR>
+  <TD VALIGN="top" ALIGN="right"><% mt('Phones') |h %></TD>
+  <TD COLSPAN=6>
+    <TABLE CELLSPACING=0 CELLPADDING=0>
+      <TR>
+% foreach my $phone (qw(daytime night mobile)) {
+        <TD>
+          <INPUT TYPE="text"
+                 NAME="<% $phone %>"
+                 VALUE="<% $cust_main->get($phone) %>"
+                 SIZE=18
+          >
+          <BR><FONT SIZE=-1><% mt($phone_label{$phone}) |h %></FONT>
+        </TD>
+        <TD> </TD>
+% }
+      </TR>
+    </TABLE>
+  </TD>
+</TR>
+<%init>
+my $cust_main = shift;
+my $conf = FS::Conf->new;
+my %phone_label = (
+  daytime => 'Day Phone',
+  night   => 'Night Phone',
+  mobile  => 'Mobile',
+);
+</%init>
diff --git a/httemplate/edit/cust_main/stateid.html b/httemplate/edit/cust_main/stateid.html
new file mode 100644
index 0000000..2655f51
--- /dev/null
+++ b/httemplate/edit/cust_main/stateid.html
@@ -0,0 +1,39 @@
+% if ( $conf->exists('show_stateid') ) {
+<TR>
+  <TD ALIGN="right"><% $stateid_label %></TD>
+  <TD><INPUT TYPE="text" NAME="stateid" VALUE="<% $stateid %>" SIZE=12></TD>
+  <TD><& /elements/select-state.html,
+          state   => $cust_main->stateid_state,
+          country => $cust_main->country, # how does this work on new customer?
+          prefix  => 'stateid_',
+          disable_countyupdate => 1,
+      &></TD>
+</TR>
+% } else {
+<INPUT TYPE="hidden" NAME="stateid" VALUE="<% $stateid %>">
+<INPUT TYPE="hidden" NAME="stateid_state" VALUE="<% $cust_main->stateid_state %>">
+% }
+
+<%init>
+my $cust_main = shift;
+my $conf = FS::Conf->new;
+my $stateid;
+if ( $cgi->param('error') ) {
+  $stateid = $cust_main->stateid;
+} elsif ( $cust_main->custnum ) {
+  $stateid = $cust_main->masked('stateid');
+} else {
+  $stateid = '';
+}
+$cust_main->set('stateid_state' => $cust_main->state) 
+  unless $cust_main->stateid_state;
+
+my $stateid_label = FS::Msgcat::_gettext('stateid') =~ /^(stateid)?$/
+                  ? 'Driver’s License'
+                  : FS::Msgcat::_gettext('stateid') || 'Driver’s License';
+
+my $stateid_state_label = 
+                  FS::Msgcat::_gettext('stateid_state') =~ /^(stateid_state)?$/
+                  ? 'Driver’s License State'
+                  : FS::Msgcat::_gettext('stateid') || 'Driver’s License State';
+</%init>
diff --git a/httemplate/edit/cust_main/top_misc.html b/httemplate/edit/cust_main/top_misc.html
index 7ba167b..7ce283c 100644
--- a/httemplate/edit/cust_main/top_misc.html
+++ b/httemplate/edit/cust_main/top_misc.html
@@ -20,27 +20,16 @@
        <% $cust_main->residential_commercial eq 'Commercial' ? 'CHECKED' : '' %>
   ></TD>
 </TR>
-
 <SCRIPT TYPE="text/javascript">
-  function rescom_changed() {
-    var f = document.CustomerForm;
-
-    if        ( f.residential_commercial_Residential.checked ) {
-      document.getElementById('contacts_div').style.display = 'none';
-    } else { // if ( f.residential_commercial_Commercial.checked ) {
-      document.getElementById('contacts_div').style.display = '';
-    }
-
-    if        ( f.residential_commercial_Residential.checked && ! f.company.value.length ) {
-      document.getElementById('company_row').style.display = 'none'
-    } else { // if ( f.residential_commercial_Commercial.checked ) {
+  function rescom_changed(what) {
+    if ( what.checked == (what.value == 'Commercial' ) ) {
       document.getElementById('company_row').style.display = '';
-    }
-
-    if        ( f.residential_commercial_Residential.checked && ! f.ship_company.value.length ) {
-      document.getElementById('ship_company_row').style.display = 'none'
-    } else { // if ( f.residential_commercial_Commercial.checked ) {
-      document.getElementById('ship_company_row').style.display = '';
+      document.getElementById('contacts_div').style.display = '';
+    } else {
+      if ( document.getElementById('company').value.length == 0 ) {
+        document.getElementById('company_row').style.display = 'none';
+      }
+      document.getElementById('contacts_div').style.display = 'none';
     }
   }
 </SCRIPT>
diff --git a/httemplate/edit/msg_template.html b/httemplate/edit/msg_template.html
index f50d66d..115032a 100644
--- a/httemplate/edit/msg_template.html
+++ b/httemplate/edit/msg_template.html
@@ -227,6 +227,15 @@ my %substitutions = (
     '$mobile'         => 'Mobile phone',
     '$fax'            => 'Fax',
   ],
+  'service' => [
+    '$ship_address1'  => 'Address line 1',
+    '$ship_address2'  => 'Address line 2',
+    '$ship_city'      => 'City',
+    '$ship_county'    => 'County',
+    '$ship_state'     => 'State',
+    '$ship_zip'       => 'Zip',
+    '$ship_country'   => 'Country',
+  ],
   'cust_bill' => [
     '$invnum'         => 'Invoice#',
   ],
@@ -281,15 +290,10 @@ my %substitutions = (
     '$error'          => 'Decline reason',
   ],
 );
-my @c = @{ $substitutions{'contact'} };
-for (my $i=0; $i<scalar(@c); $i += 2) {
-  $c[$i] =~ s/\$(.*)/\$ship_$1/;
-}
-$substitutions{'shipping'} = \@c;
 
 tie my %sections, 'Tie::IxHash', (
 'contact'   => 'Name and contact info (billing)',
-'shipping'  => 'Name and contact info (shipping)',
+'service'   => 'Service address',
 'cust_main' => 'Customer status and payment info',
 'cust_pkg'  => 'Package fields',
 'cust_bill' => 'Invoice fields',
diff --git a/httemplate/edit/process/cust_location.cgi b/httemplate/edit/process/cust_location.cgi
index 790fc8e..b9f93db 100644
--- a/httemplate/edit/process/cust_location.cgi
+++ b/httemplate/edit/process/cust_location.cgi
@@ -28,10 +28,12 @@ my $cust_location = qsearchs({
 });
 die "unknown locationnum $locationnum" unless $cust_location;
 
-my $new = {
+my $new = FS::cust_location->new({
+  custnum     => $cust_location->custnum,
+  prospectnum => $cust_location->prospectnum,
   map { $_ => scalar($cgi->param($_)) }
     qw( address1 address2 city county state zip country )
-};
+});
 
 my $error = $cust_location->move_to($new);
 
diff --git a/httemplate/edit/process/cust_main.cgi b/httemplate/edit/process/cust_main.cgi
index 3f5e19e..5ee553b 100755
--- a/httemplate/edit/process/cust_main.cgi
+++ b/httemplate/edit/process/cust_main.cgi
@@ -57,19 +57,40 @@ push @invoicing_list, 'POST' if $cgi->param('invoicing_list_POST');
 push @invoicing_list, 'FAX' if $cgi->param('invoicing_list_FAX');
 $cgi->param('invoicing_list', join(',', @invoicing_list) );
 
+# is this actually used?  if so, we need to clone locations...
+# but I can't find anything that sets this parameter to a non-empty value
+$cgi->param('duplicate_of_custnum') =~ /^(\d+)$/;
+my $duplicate_of = $1;
+
+my %locations;
+for my $pre (qw(bill ship)) {
+
+  my %hash;
+  foreach ( FS::cust_main->location_fields ) {
+    $hash{$_} = scalar($cgi->param($pre.'_'.$_));
+  }
+  $hash{'custnum'} = $cgi->param('custnum');
+  warn Dumper \%hash if $DEBUG;
+  # if we can qsearchs it, then it's unchanged, so use that
+  $locations{$pre} = qsearchs('cust_location', \%hash)
+                     || FS::cust_location->new( \%hash );
+
+}
+
+if ( ($cgi->param('same') || '') eq 'Y' ) {
+  $locations{ship} = $locations{bill};
+}
 
 #create new record object
+# but explicitly avoid setting ship_ fields
 
 my $new = new FS::cust_main ( {
-  map {
-    $_, scalar($cgi->param($_))
-  } fields('cust_main')
+  map { ( $_, scalar($cgi->param($_)) ) } (fields('cust_main')),
+  map { ( "ship_$_", '' ) } (FS::cust_main->location_fields)
 } );
 
 $new->invoice_noemail( ($cgi->param('invoice_email') eq 'Y') ? '' : 'Y' );
 
-$cgi->param('duplicate_of_custnum') =~ /^(\d+)$/;
-my $duplicate_of = $1;
 if ( $duplicate_of ) {
   # then negate all changes to the customer; the only change we should
   # make is to order a package, if requested
@@ -78,11 +99,9 @@ if ( $duplicate_of ) {
     or die "nonexistent existing customer (custnum $duplicate_of)";
 }
 
-if ( defined($cgi->param('same')) && $cgi->param('same') eq "Y" ) {
-  $new->setfield("ship_$_", '') foreach qw(
-    last first company address1 address2 city county state zip
-    country daytime night fax
-  );
+for my $pre (qw(bill ship)) {
+  $new->set($pre.'_location', $locations{$pre});
+  $new->set($pre.'_locationnum', $locations{$pre}->locationnum);
 }
 
 if ( $cgi->param('no_credit_limit') ) {
@@ -261,6 +280,7 @@ if ( $new->custnum eq '' or $duplicate_of ) {
 
   my $old = qsearchs( 'cust_main', { 'custnum' => $new->custnum } ); 
   $error ||= "Old record not found!" unless $old;
+
   if ( length($old->paycvv) && $new->paycvv =~ /^\s*\*+\s*$/ ) {
     $new->paycvv($old->paycvv);
   }
@@ -299,6 +319,9 @@ if ( $new->custnum eq '' or $duplicate_of ) {
   local($FS::cust_main::DEBUG) = $DEBUG if $DEBUG;
   local($FS::Record::DEBUG)    = $DEBUG if $DEBUG;
 
+  local($Data::Dumper::Sortkeys) = 1;
+  warn Dumper({ new => $new, old => $old }) if $DEBUG;
+
   $error ||= $new->replace( $old, \@invoicing_list,
                             'tax_exemption' => \%tax_exempt,
                           );
diff --git a/httemplate/elements/location.html b/httemplate/elements/location.html
index c606523..7672318 100644
--- a/httemplate/elements/location.html
+++ b/httemplate/elements/location.html
@@ -3,16 +3,16 @@
 Example:
 
   include( '/elements/location.html',
-             'object'         => $cust_main,  # or $cust_location
-             'prefix'         => $pre,        #only for cust_main objects
+             'object'         => $cust_location
+             'prefix'         => $pre, # prefixed to form field names
              'onchange'       => $javascript,
-             'disabled'       => $disabled,
-             'same_checked'   => $same_checked,
              'geocode'        => $geocode, #passed through
              'censustract'    => $censustract, #passed through
              'no_asterisks'   => 0, #set true to disable the red asterisks next
                                     #to required fields
              'address1_label' => 'Address', #label for address
+             'enable_district' => 1, #show tax district field
+             'enable_censustract' => 1, #show censustract field
          )
 
 </%doc>
@@ -40,12 +40,12 @@ Example:
 % } 
 
 <TR>
-  <<%$th%> ALIGN="right"><%$r%><% $opt{'address1_label'} || emt('Address') %></<%$th%>>
+  <<%$th%> STYLE="width:16ex" ALIGN="right"><%$r%><% $opt{'address1_label'} || emt('Address') %></<%$th%>>
   <TD COLSPAN=7>
     <INPUT TYPE     = "text"
            NAME     = "<%$pre%>address1"
            ID       = "<%$pre%>address1"
-           VALUE    = "<% $object->get($pre.'address1') |h %>"
+           VALUE    = "<% $object->get('address1') |h %>"
            SIZE     = 54
            onChange = "<% $onchange %>"
            <% $disabled %>
@@ -62,7 +62,7 @@ Example:
         <INPUT TYPE     = "text"
                NAME     = "<%$pre%>address2"
                ID       = "<%$pre%>address2"
-               VALUE    = "<% $object->get($pre.'address2') |h %>"
+               VALUE    = "<% $object->get('address2') |h %>"
                SIZE     = 54
                onChange = "<% $onchange %>"
                <% $disabled %>
@@ -75,7 +75,7 @@ Example:
 
       <INPUT TYPE  = "hidden"
              NAME  = "<%$pre%>address2"
-             VALUE = "<% $object->get($pre.'address2') |h %>"
+             VALUE = "<% $object->get('address2') |h %>"
       >
 
 <TR>
@@ -83,7 +83,7 @@ Example:
     <TD COLSPAN=7>
 
 %     my $location_type = scalar($cgi->param('location_type'))
-%                           || $object->get($pre.'location_type');
+%                           || $object->get('location_type');
 %     #my $location_number = scalar($cgi->param('location_number'))
 %     #                        || $object->get($pre.'location_number');
 %
@@ -130,7 +130,7 @@ Example:
     <INPUT TYPE="text" 
                NAME  = "location_number"
                ID    = "location_number"
-               VALUE = "<% scalar($cgi->param('location_number')) || $object->get($pre.'location_number') |h %>"
+               VALUE = "<% scalar($cgi->param('location_number')) || $object->get('location_number') |h %>"
                SIZE  = "5"
                <% $disabled || ($location_type ? '' : 'DISABLED') %>
                <% $style %>
@@ -161,7 +161,7 @@ Example:
     <INPUT TYPE     = "text"
            NAME     = "<%$pre%>zip"
            ID       = "<%$pre%>zip"
-           VALUE    = "<% $object->get($pre.'zip') |h %>"
+           VALUE    = "<% $object->get('zip') |h %>"
            SIZE     = 10
            onChange = "<% $onchange %>"
            <% $disabled %>
@@ -181,7 +181,7 @@ Example:
     <INPUT TYPE  = "text"
            NAME  = "<%$pre%>latitude"
            ID    = "<%$pre%>latitude"
-           VALUE = "<% $object->get($pre.'latitude') |h %>"
+           VALUE = "<% $object->get('latitude') |h %>"
            <% $disabled %>
            <% $style %>
     >
@@ -189,36 +189,44 @@ Example:
     <INPUT TYPE  = "text"
            NAME  = "<%$pre%>longitude"
            ID    = "<%$pre%>longitude"
-           VALUE = "<% $object->get($pre.'longitude') |h %>"
+           VALUE = "<% $object->get('longitude') |h %>"
            <% $disabled %>
            <% $style %>
     >
   </TD>
 </TR>
-<INPUT TYPE="hidden" NAME="<%$pre%>coord_auto" VALUE="<% $object->get($pre.'coord_auto') %>">
+<INPUT TYPE="hidden" NAME="<%$pre%>coord_auto" VALUE="<% $object->coord_auto %>">
 
-% if ( !$pre ) { 
-  <INPUT TYPE="hidden" NAME="geocode" VALUE="<% $opt{geocode} %>">
+<INPUT TYPE="hidden" NAME="<%$pre%>geocode" VALUE="<% $object->geocode %>">
+<INPUT TYPE="hidden" NAME="<%$pre%>censusyear" VALUE="<% $object->censusyear %>">
+<TR>
+% if ( $opt{enable_censustract} ) {
+  <TD ALIGN="right">Census tract</TD>
+  <TD COLSPAN=8>
+    <INPUT TYPE="text" SIZE=15
+           NAME="<%$pre%>censustract" 
+           VALUE="<% $object->censustract %>">
+    <% '(automatic)' %>
+  </TD>
 % } else {
-%   if ( $pre eq 'ship_' && $conf->exists('cust_main-require_censustract') ) {
-      <TR><<%$th%> ALIGN="right">Census tract<BR>(automatic)</<%$th%>>
-        <TD>
-          <INPUT TYPE="text" NAME="censustract" VALUE="<% $opt{censustract} %>">
-          <INPUT TYPE="hidden" NAME="censusyear" VALUE="<% $object->get('censusyear') %>">
-        </TD>
-      </TR>
+  <INPUT TYPE="hidden" NAME="<%$pre%>censustract" VALUE="<% $object->censustract %>">
+% } 
+</TR>
+% if ( $conf->config('tax_district_method') ) {
+  <TR>
+%   if ( $opt{enable_district} ) {
+    <TD ALIGN="right">Tax district</TD>
+    <TD COLSPAN=8>
+      <INPUT TYPE="text" SIZE=15
+             NAME="<%$pre%>district" 
+             VALUE="<% $object->district %>">
+    <% '(automatic)' %>
+    </TD>
 %   } else {
-      <INPUT TYPE="hidden" NAME="censustract" VALUE="<% $opt{censustract} %>">
-%   } 
-%   if ( $conf->config('tax_district_method') or $object->get('district') ) {
-    <TR>
-      <<%$th%> ALIGN="right">Tax district<BR>(automatic)</<%$th%>>
-      <TD>
-        <INPUT TYPE="text" NAME="district" VALUE="<%$object->get('district')%>">
-      </TD>
-    </TR>
+    <INPUT TYPE="hidden" NAME="<%$pre%>district" VALUE="<% $object->district %>">
 %   }
-% } 
+  </TR>
+% }
 
 <%init>
 
@@ -233,16 +241,13 @@ my $conf = new FS::Conf;
 
 my $r = $opt{'no_asterisks'} ? '' : qq!<font color="#ff0000">*</font> !;
 
-#false laziness with ship state
 my $countrydefault = $conf->config('countrydefault') || 'US';
-$object->set($pre.'country', $countrydefault )
-  unless $object->get($pre.'country');
-
-my $statedefault = $conf->config('statedefault')
+my $statedefault = $conf->config('statedefault') 
                    || ($countrydefault eq 'US' ? 'CA' : '');
-$object->set($pre.'state', $statedefault )
-  unless $object->get($pre.'state')
-         || $object->get($pre.'country') ne $countrydefault;
+$object ||= FS::cust_location->new({
+  'country' => $countrydefault,
+  'state'   => $statedefault,
+});
 
 my $alt_err = ($opt{'alt_format'} && !$disabled) ? $object->alternize : '';
 
@@ -255,8 +260,8 @@ push @address2_label_style, 'visibility:hidden'
   || ! $conf->exists('cust_main-require_address2')
   || ( !$pre && !$opt{'same_checked'} );
 
-my @counties = counties( $object->get($pre.'state'),
-                         $object->get($pre.'country'),
+my @counties = counties( $object->get('state'),
+                         $object->get('country'),
                        );
 my @county_style = ();
 push @county_style, 'display:none' # 'visibility:hidden'
@@ -276,10 +281,10 @@ my $county_style =
     : '';
 
 my %select_hash = (
-  'city'     => $object->get($pre.'city'),
-  'county'   => $object->get($pre.'county'),
-  'state'    => $object->get($pre.'state'),
-  'country'  => $object->get($pre.'country'),
+  'city'     => $object->get('city'),
+  'county'   => $object->get('county'),
+  'state'    => $object->get('state'),
+  'country'  => $object->get('country'),
   'prefix'   => $pre,
   'onchange' => $onchange,
   'disabled' => $disabled,
diff --git a/httemplate/elements/standardize_locations.js b/httemplate/elements/standardize_locations.js
index e6a4aa6..86f8d2b 100644
--- a/httemplate/elements/standardize_locations.js
+++ b/httemplate/elements/standardize_locations.js
@@ -10,7 +10,7 @@ function standardize_locations() {
     'onlyship', 1,
 % } else {
 %   if ( $withfirm ) {
-    'company',  cf.elements['<% $main_prefix %>company'].value,
+    'company',  cf.elements['company'].value,
 %   }
     'address1', cf.elements['<% $main_prefix %>address1'].value,
     'address2', cf.elements['<% $main_prefix %>address2'].value,
@@ -18,9 +18,6 @@ function standardize_locations() {
     'state',    state_el.options[ state_el.selectedIndex ].value,
     'zip',      cf.elements['<% $main_prefix %>zip'].value,
 % }
-% if ( $withfirm ) {
-    'ship_company',  cf.elements['<% $ship_prefix %>company'].value,
-% }
     'ship_address1', cf.elements['<% $ship_prefix %>address1'].value,
     'ship_address2', cf.elements['<% $ship_prefix %>address2'].value,
     'ship_city',     cf.elements['<% $ship_prefix %>city'].value,
diff --git a/httemplate/elements/tr-select-cust_location.html b/httemplate/elements/tr-select-cust_location.html
index 0ca255b..05712ee 100644
--- a/httemplate/elements/tr-select-cust_location.html
+++ b/httemplate/elements/tr-select-cust_location.html
@@ -11,7 +11,6 @@ Example:
 
             #optional
             'empty_label'   => '(default service address)',
-            'disable_empty' => 0, #1 to disable
          )
 
 </%doc>
@@ -52,11 +51,12 @@ Example:
       var ftype = what.form.<%$_%>.tagName;
       if( ftype != 'SELECT') what.form.<%$_%>.style.backgroundColor = '#ffffff';
 %   } 
-
+%   if ( $opt{'alt_format'} ) {
     if ( what.form.location_type.options[what.form.location_type.selectedIndex].value ) {
       what.form.location_number.disabled = false;
       what.form.location_number.style.backgroundColor = '#ffffff';
     }
+%   }
   }
 
   function locationnum_changed(what) {
@@ -101,25 +101,8 @@ Example:
       return;
     }
 
-    if ( locationnum == 0 ) { //(default service address)
-%     if ( $cust_main ) {
-      what.form.address1.value = <% $cust_main->get($prefix.'address1') |js_string %>;
-      what.form.address2.value = <% $cust_main->get($prefix.'address2') |js_string %>;
-      what.form.city.value = <% $cust_main->get($prefix.'city') |js_string %>;
-      what.form.zip.value = <% $cust_main->get($prefix.'zip') |js_string %>;
-
-      changeSelect(what.form.country, <% $cust_main->get($prefix.'country') | js_string %> );
-
-      country_changed( what.form.country,
-                       fix_state_factory( <% $cust_main->get($prefix.'state') | js_string %>,
-                                          <% $cust_main->get($prefix.'county') | js_string %>
-                                        )
-                     );
-%     }
-
-    } else {
-      get_location( locationnum, update_location );
-    } 
+%# default service address is now just another location
+    get_location( locationnum, update_location );
 
 %   if ( $editable ) {
       if ( locationnum == 0 ) {
@@ -203,14 +186,16 @@ Example:
             ID       = "locationnum"
             onChange = "locationnum_changed(this);"
     >
-% if ( !$prospect_main && !$opt{'disable_empty'} ) {
-      <OPTION VALUE=""><% $opt{'empty_label'} || '(default service address)' |h %>
+% if ( $cust_main ) {
+      <OPTION VALUE="<% $cust_main->ship_locationnum %>"><% $opt{'empty_label'} || '(default service address)' |h %>
 % }
 % if ( $opt{'is_optional'} ) {
     <OPTION VALUE="-2" <% $locationnum == -2 ? 'SELECTED' : ''%>><% $opt{'optional_label'} || '(not required)' |h %>
 % }
 %
 %     foreach my $loc ( @cust_location ) {
+%       # don't show the ship_location redundantly
+%       next if $cust_main && $cust_main->ship_locationnum == $loc->locationnum;
         <OPTION VALUE="<% $loc->locationnum %>"
                 <% $locationnum == $loc->locationnum ? 'SELECTED' : '' %>
         ><% $loc->line |h %>
@@ -233,7 +218,9 @@ Example:
              'alt_format'   => $opt{'alt_format'},
           )
 %>
-
+<SCRIPT TYPE="text/javascript">
+  locationnum_changed(document.getElementById('locationnum'));
+</SCRIPT>
 <%init>
 
 my $conf = new FS::Conf;
@@ -246,8 +233,7 @@ my $cgi           = $opt{'cgi'};
 my $cust_pkg      = $opt{'cust_pkg'};
 my $cust_main     = $opt{'cust_main'};
 my $prospect_main = $opt{'prospect_main'};
-
-my $prefix = ($cust_main && length($cust_main->ship_last)) ? 'ship_' : '';
+die "cust_main or prospect_main required" unless $cust_main or $prospect_main;
 
 my $locationnum = '';
 if ( $cgi->param('error') ) {
@@ -259,9 +245,9 @@ if ( $cgi->param('error') ) {
   } elsif ($prospect_main) {
     my @cust_location = $prospect_main->cust_location;
     $locationnum = $cust_location[0]->locationnum if scalar(@cust_location)==1;
-  } else { #?
+  } else { #$cust_main
     $cgi->param('locationnum') =~ /^(\-?\d*)$/ or die "illegal locationnum";
-    $locationnum = $1;
+    $locationnum = $1 || $cust_main->ship_locationnum;
   }
 }
 
@@ -277,7 +263,7 @@ if ( $opt{'alt_format'} ) {
     push @location_fields, qw( location_type location_number location_kind );
 }
 
-my $cust_location;
+my $cust_location; #the one that shows by default in the location edit space
 if ( $locationnum && $locationnum > 0 ) {
   $cust_location = qsearchs('cust_location', { 'locationnum' => $locationnum } )
     or die "unknown locationnum";
@@ -290,7 +276,7 @@ if ( $locationnum && $locationnum > 0 ) {
     $cust_location->$_( $pkg_location->$_ ) foreach @location_fields;
     $opt{'empty_label'} ||= 'package address: '.$pkg_location->line;
   } elsif ( $cust_main ) {
-    $cust_location->$_( $cust_main->get($prefix.$_) ) foreach @location_fields;
+    $cust_location = $cust_main->ship_location; #I think
   }
 }
 
@@ -311,14 +297,14 @@ push @cust_location, $cust_location
 @cust_location = sort $location_sort grep !$_->disabled, @cust_location;
 
 $cust_location = $cust_location[0]
-  if ( $prospect_main || $opt{'disable_empty'} )
+  if ( $prospect_main )
   && !$opt{'is_optional'}
   && @cust_location;
 
 my $disabled =
   ( $locationnum < 0
     || ( $editable && $locationnum )
-    || ( ( $prospect_main || $opt{'disable_empty'} )
+    || ( $prospect_main
          && !$opt{'is_optional'} && !@cust_location && $addnew
        )
   )
diff --git a/httemplate/search/report_tax.cgi b/httemplate/search/report_tax.cgi
index 248f6c5..2786f57 100755
--- a/httemplate/search/report_tax.cgi
+++ b/httemplate/search/report_tax.cgi
@@ -239,6 +239,8 @@ as <A HREF="<% $p.'search/report_tax-xls.cgi?'.$cgi->query_string%>">Excel sprea
 
 <%init>
 
+my $DEBUG = $cgi->param('debug') || 0;
+
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
 
@@ -252,15 +254,19 @@ my $join_cust =     '     JOIN cust_bill      USING ( invnum  )
                       LEFT JOIN cust_main     USING ( custnum ) ';
 my $join_cust_pkg = $join_cust.
                     ' LEFT JOIN cust_pkg      USING ( pkgnum  )
-                      LEFT JOIN part_pkg      USING ( pkgpart ) ';
-$join_cust_pkg .=   ' LEFT JOIN cust_location USING ( locationnum )'
-  if $conf->exists('tax-pkg_address');
+                      LEFT JOIN part_pkg      USING ( pkgpart ) 
+                      LEFT JOIN cust_location 
+                        ON ( cust_location.locationnum = ' .
+                        FS::cust_pkg->tax_locationnum_sql . ' )';
 
 my $from_join_cust_pkg = " FROM cust_bill_pkg $join_cust_pkg "; 
 
 my $where = "WHERE _date >= $beginning AND _date <= $ending ";
 
-my( $location_sql, @base_param ) = FS::cust_pkg->location_sql;
+# this query will be run once per cust_main_county,
+# or maybe once per country/state/city tuple,
+# or maybe once per country/state...it's hard to say.
+my ($location_sql, @base_param) = FS::cust_location->in_county_sql(param => 1);
 $where .= " AND $location_sql ";
 
 my $agentname = '';
@@ -291,59 +297,27 @@ sub gotcust {
   ";
 }
 
-my $gotcust;
-if ( $conf->exists('tax-ship_address') ) {
-
-  $gotcust = "
-               (    cust_main_county.country = cust_main.country
-                 OR cust_main_county.country = cust_main.ship_country
-               )
-
-               AND
-
-               ( 
-                 (     ( ship_last IS NULL     OR  ship_last = '' )
-                   AND ". gotcust('cust_main'). "
-                 )
-                 OR
-                 (       ship_last IS NOT NULL AND ship_last != ''
-                   AND ". gotcust('cust_main', 'ship_'). "
-                 )
-               )
-  ";
-
-} else {
-
-  $gotcust = gotcust('cust_main');
-
-}
-if ( $conf->exists('tax-pkg_address') ) {
-  $gotcust = "
-       ( cust_pkg.locationnum IS     NULL AND $gotcust)
-    OR ( cust_pkg.locationnum IS NOT NULL AND ". gotcust('cust_location'). " )";
-  $gotcust =
-    "WHERE 0 < ( SELECT COUNT(*) FROM cust_pkg
-                                 LEFT JOIN cust_main USING ( custnum )
-                                 LEFT JOIN cust_location USING ( locationnum )
-                   WHERE $gotcust
-                   LIMIT 1
-               )
-    ";
-} else {
-  $gotcust =
-    "WHERE 0 < ( SELECT COUNT(*) FROM cust_main WHERE $gotcust LIMIT 1 )";
-}
+#non-parameterized form
+my $location_in_county = FS::cust_location->in_county_sql;
+my $gotcust = "WHERE EXISTS(
+  SELECT 1 FROM cust_location WHERE $location_in_county AND disabled IS NULL
+)";
 
 my $out = 'Out of taxable region(s)';
 # these are actually tax labels, not regions
 my %regions = ();
 
+# Phase 1: Taxable and exempt sales
+# Collect for each cust_main_county, and assign to a bin based on label.
+# Note that "label" includes city if show_cities is on, and taxclass if
+# show_taxclasses is on.
 foreach my $r ( qsearch({ 'table'     => 'cust_main_county',
                           'extra_sql' => $gotcust,
+                          'debug' => $DEBUG,
                        })
               )
 {
-  #warn $r->county. ' '. $r->state. ' '. $r->country. "\n";
+  warn $r->county. ' '. $r->state. ' '. $r->country. "\n" if $DEBUG > 1;
 
   # set up a %regions entry for this region's tax label
   my $label = getlabel($r);
@@ -475,7 +449,7 @@ foreach my $r ( qsearch({ 'table'     => 'cust_main_county',
     $regions{$label}->{'rate'} = $r->tax.'%';
   }
 }
-#warn Dumper(\%regions);
+warn Dumper(\%regions) if $DEBUG > 1;
 # $regions{$label} now contains 'total', 'exempt_cust', 'exempt_pkg', 
 # 'exempt_monthly', summed over each set of regions with the same label.
 
@@ -491,29 +465,27 @@ my $taxclass_distinct =
   )." AS taxclass";
 
 
+# Phase 2: invoiced/credited tax items
+# Collect this data for each country/state/city/district/taxname(/taxclass).
 my %qsearch = (
   'select'    => "DISTINCT $distinct, $taxclass_distinct",
   'table'     => 'cust_main_county',
   'hashref'   => {},
   'extra_sql' => $gotcust,
+  'debug' => $DEBUG,
 );
 
-my $taxfromwhere = " FROM cust_bill_pkg $join_cust ";
+# Join to cust_main the same as before (we need agentnum)
+# but not to cust_pkg (because tax line items don't have a package)
+# and then to cust_location via cust_bill_pkg_tax_location
+my $taxfromwhere = "FROM cust_bill_pkg $join_cust 
+                    LEFT JOIN cust_bill_pkg_tax_location USING ( billpkgnum )
+                    LEFT JOIN cust_location USING ( locationnum )
+                    ";
 my $taxwhere = $where;
-if ( $conf->exists('tax-pkg_address') ) {
-
-  $taxfromwhere .= 'LEFT JOIN cust_bill_pkg_tax_location USING ( billpkgnum )
-                    LEFT JOIN cust_location USING ( locationnum ) ';
 
-  #quelle kludge
-  $taxwhere =~ s/cust_pkg\.locationnum/cust_bill_pkg_tax_location.locationnum/g;
-
-}
 my $creditfromwhere = $taxfromwhere. 
-   " JOIN cust_credit_bill_pkg USING (billpkgnum";
-$creditfromwhere .= " ,billpkgtaxlocationnum"
-   if $conf->exists('tax-pkg_address');
-$creditfromwhere .= ")";
+   " JOIN cust_credit_bill_pkg USING (billpkgnum, billpkgtaxlocationnum)";
 
 $taxfromwhere .= " $taxwhere "; #AND payby != 'COMP' ";
 $creditfromwhere .= " $taxwhere AND billpkgtaxratelocationnum IS NULL"; #AND payby != 'COMP' ";
@@ -611,6 +583,10 @@ foreach my $r ( qsearch(\%qsearch) ) {
 
 }
 
+# Phase 3: Non-taxclassed totals for invoiced/credited tax
+# (If show_taxclasses is not in use, this was phase 2, but it 
+# displays somewhere different.)
+# Don't filter by report_groups.
 my %base_regions = ();
 if ( $cgi->param('show_taxclasses') ) {
 
diff --git a/httemplate/view/cust_main/contacts.html b/httemplate/view/cust_main/contacts.html
index fe7cc5c..6213f27 100644
--- a/httemplate/view/cust_main/contacts.html
+++ b/httemplate/view/cust_main/contacts.html
@@ -1,122 +1,128 @@
-% my %which = (
-%   ''      => emt('Billing'),
-%   'ship_' => emt('Service'),
-% );
-% foreach my $which ( '', 'ship_' ) {
-%   my $pre = $cust_main->get("${which}last") ? $which : '';
-
-<FONT CLASS="fsinnerbox-title"><% $which{$which} %> <% mt('address') |h %></FONT>
+% my %addr_label = ('bill' => 'Billing address', 'ship' => 'Service address');
+
+%# Locations (possibly break this out)
+% my @which = ('bill');
+% push @which, 'ship' if $cust_main->has_ship_address;
+% while (@which) {
+%   my $this = shift @which;
+%   my $method = $this.'_location';
+%   my $location = $cust_main->$method;
+<FONT CLASS="fsinnerbox-title"><% mt( $addr_label{$this} ) |h %></FONT>
 <TABLE CLASS="fsinnerbox">
-<TR>
-  <TD ALIGN="right"><% mt('Contact name') |h %></TD>
-  <TD COLSPAN=5 BGCOLOR="#ffffff">
-    <% $cust_main->get("${pre}last"). ', '. $cust_main->get("${pre}first") |h %>
-  </TD>
-% if ( $which eq '' && $conf->exists('show_ss') ) { 
-    <TD ALIGN="right"><% mt('SS#') |h %></TD>
-    <TD BGCOLOR="#ffffff"><% $conf->exists('unmask_ss') ? $cust_main->ss : $cust_main->masked('ss') || '&nbsp' %></TD>
-% } 
-</TR>
 
-% if ( $conf->exists('cust-email-high-visibility') && $which eq '') {
+% if ( $this eq 'bill' ) {
+%   #billing contact fields
+  <TR>
+    <TD ALIGN="right"><% mt('Contact name') |h %></TD>
+    <TD COLSPAN=5 BGCOLOR="#ffffff"><% $cust_main->contact |h %></TD>
+%   if ( $conf->exists('show_ss') ) {
+    <TD ALIGN="right"><% mt('SS#') |h %></TD>
+    <TD BGCOLOR="#ffffff"><% $conf->exists('unmask_ss')
+                              ? $cust_main->ss
+                              : $cust_main->masked('ss') || ' ' %></TD>
+%   }
+  </TR>
+%   if ( $conf->exists('cust-email-high-visibility') ) {
   <TR>
     <TD ALIGN="right"><% mt('Email address(es)') |h %></TD>
     <TD BGCOLOR="#ffff00">
-      <% join(', ', grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ) || $no %>
+      <% $cust_main->invoicing_list_emailonly_scalar || $no %>
     </TD>
   </TR>
-% }
-
-% if ( $cust_main->get("${pre}company") ) {
+%   }
+%   if ( $cust_main->company ) {
   <TR>
     <TD ALIGN="right"><% mt('Company') |h %></TD>
-    <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}company") |h %></TD>
+    <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->company %></TD>
   </TR>
-% }
-
+%   }
+% } # if $this eq 'bill'
+% # now the actual address
 <TR>
   <TD ALIGN="right"><% mt('Address') |h %></TD>
-  <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}address1") |h %></TD>
+  <TD COLSPAN=7 BGCOLOR="#ffffff"><% $location->address1 |h %></TD>
 </TR>
 
-% if ( $cust_main->get("${pre}address2") ) { 
-%   my $address2_label =
-%     ( $conf->exists('cust_main-require_address2')
-%       && ! ( $pre xor $cust_main->has_ship_address )
-%     )
-%       ? emt('Unit #')
-%       : ' ';
+% if ( $location->get('address2') ) {
+%   my $address2_label = $conf->exists('cust_main-require_address2') 
+%                        ? emt('Unit #')
+%                        : ' ';
 
-  <TR>
-    <TD ALIGN="right"><% $address2_label %></TD>
-    <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}address2") |h %></TD>
-  </TR>
+<TR>
+  <TD ALIGN="right"><% $address2_label %></TD>
+  <TD COLSPAN=7 BGCOLOR="#ffffff"><% $location->address2 |h %></TD>
+</TR>
 
 % } 
 
 <TR>
   <TD ALIGN="right"><% mt('City') |h %></TD>
-  <TD BGCOLOR="#ffffff"><% $cust_main->get("${pre}city") |h %></TD>
-% if ( $cust_main->get("${pre}county") ) {
+  <TD BGCOLOR="#ffffff"><% $location->city |h %></TD>
+% if ( $location->county ) {
     <TD ALIGN="right"><% mt('County') |h %></TD>
-    <TD BGCOLOR="#ffffff"><% $cust_main->get("${pre}county") |h %></TD>
+    <TD BGCOLOR="#ffffff"><% $location->county |h %></TD>
 % }
   <TD ALIGN="right"><% mt('State') |h %></TD>
-  <TD BGCOLOR="#ffffff"><% state_label( $cust_main->get("${pre}state"), $cust_main->get("${pre}country") ) |h %></TD>
+  <TD BGCOLOR="#ffffff"><% state_label( $location->state, $location->country ) |h %></TD>
   <TD ALIGN="right"><% mt('Zip') |h %></TD>
-  <TD BGCOLOR="#ffffff"><% $cust_main->get("${pre}zip") %></TD>
+  <TD BGCOLOR="#ffffff"><% $location->zip %></TD>
 </TR>
 <TR>
   <TD ALIGN="right"><% mt('Country') |h %></TD>
-  <TD BGCOLOR="#ffffff"><% code2country( $cust_main->get("${pre}country") ) %></TD>
+  <TD BGCOLOR="#ffffff"><% code2country( $location->country ) %></TD>
 </TR>
 
-% if ( $cust_main->get($pre.'latitude') && $cust_main->get($pre.'longitude') ) {
-  <& /elements/tr-coords.html, $cust_main->get($pre.'latitude'),
-                               $cust_main->get($pre.'longitude'),
+% if ( $location->latitude && $location->longitude ) {
+  <& /elements/tr-coords.html, $location->latitude,
+                               $location->longitude,
                                $cust_main->name_short,
                                $cust_main->agentnum,
   &>
 % }
+  
+% if ( $this eq 'bill' ) {
+%   # billing contact phone numbers
+%   foreach my $phone (qw(daytime night mobile)) {
+%     next if !$cust_main->get($phone);
+<TR>
+  <TD ALIGN="right"><% $phone_label{$phone} %></TD>
+  <TD COLSPAN=3 BGCOLOR="#ffffff">
+    <& /elements/phonenumber.html,
+        $cust_main->get($phone),
+        callable => 1,
+        calling_list_exempt => $cust_main->calling_list_exempt,
+    &>
+  </TD>
+</TR>
 
-% foreach my $phone (grep $cust_main->get($pre.$_), qw( daytime night mobile )){
-
-  <TR>
-    <TD ALIGN="right"><% $phone_label{$phone} %></TD>
-    <TD COLSPAN=3 BGCOLOR="#ffffff">
-      <& /elements/phonenumber.html,
-                    $cust_main->get($pre.$phone),
-                    'callable'=>1,
-                    'calling_list_exempt'=>$cust_main->calling_list_exempt,
-      &>
-    </TD>
-  </TR>
-
-% }
+%   } #foreach $phone
+%   if ( $cust_main->get('fax') ) {
 
-% if ( $cust_main->get("${pre}fax") ) {
   <TR>
     <TD ALIGN="right"><% mt('Fax') |h %></TD>
     <TD COLSPAN=3 BGCOLOR="#ffffff">
-      <% $cust_main->get("${pre}fax") || '&nbsp' %>
+      <% $cust_main->get('fax') || ' ' %>
     </TD>
   </TR>
-% }
 
-% if ( $which eq '' && $conf->exists('show_stateid') ) { 
-  <TR>
+%   }
+%
+%   if ( $conf->exists('show_stateid') ) { 
+
+<TR>
     <TD ALIGN="right"><% $stateid_label %></TD>
     <TD BGCOLOR="#ffffff"><% $cust_main->masked('stateid') || '&nbsp' %></TD>
     <TD ALIGN="right"><% $stateid_state_label %></TD>
     <TD BGCOLOR="#ffffff"><% $cust_main->stateid_state || '&nbsp' %></TD>
   </TR>
-% } 
 
+%   }
+% } #if $this eq 'bill'
 </TABLE>
-% if ( $which ne 'ship_' ) {
+% if ( @which ) {
 <BR>
 % }
-% } 
+% } #while @which
 <%once>
 
 my %phone_label = (
@@ -147,7 +153,7 @@ my $stateid_state_label = FS::Msgcat::_gettext('stateid_state') =~ /^(stateid_st
 </%once>
 <%init>
 
-my( $cust_main ) = @_;
+my $cust_main = shift;
 my $conf = new FS::Conf;
 my @invoicing_list = $cust_main->invoicing_list;
 my $no = emt('no');
diff --git a/httemplate/view/cust_main/locations.html b/httemplate/view/cust_main/locations.html
index 98c9336..b29d0ce 100755
--- a/httemplate/view/cust_main/locations.html
+++ b/httemplate/view/cust_main/locations.html
@@ -5,12 +5,17 @@ span.loclabel {
   background-color: #cccccc;
   border: 1px solid black
 }
+table.location {
+  width: 100%;
+  padding: 1px;
+  border-spacing: 0px;
+}
 </STYLE>
 % foreach my $locationnum (@sorted) {
 %   my $packages = $packages_in{$locationnum};
 %   my $loc = $locations{$locationnum};
 %   next if $loc->disabled and scalar(@$packages) == 0;
-<& /elements/table-grid.html &>
+<TABLE CLASS="grid location">
 <TR><TH COLSPAN=3 ALIGN="left" VALIGN="bottom" 
 STYLE="padding-bottom: 0px; 
   padding-left: 0px; 
@@ -18,10 +23,7 @@ STYLE="padding-bottom: 0px;
   border-bottom-color: black;
   border-bottom-width: 1px;">
 <SPAN CLASS="loclabel">
-%   if (! $locationnum) {
-<% mt('Default service location:') |h %> 
-%   }
-%   elsif ( $loc->disabled ) {
+%   if ( $loc->disabled ) {
 <FONT COLOR="#808080"><I>
 %   }
 <% $loc->location_label %></SPAN>
@@ -49,8 +51,7 @@ my %locations = map { $_->locationnum => $_ } qsearch({
     'order_by'  => 'ORDER BY country, state, city, address1, locationnum',
   });
 my @sections = keys %locations;
-$locations{''} = $cust_main;
-my %packages_in = map { $_ => [] } ('', @sections);
+my %packages_in = map { $_ => [] } (@sections);
 
 my %active = (); # groups with non-canceled packages
 foreach my $cust_pkg ( @$all_packages ) {
@@ -58,10 +59,13 @@ foreach my $cust_pkg ( @$all_packages ) {
   push @{ $packages_in{$key} }, $cust_pkg;
   $active{$key} = 1 if !$cust_pkg->getfield('cancel');
 }
+# prevent disabling these
+$active{$cust_main->ship_locationnum} = 1;
+$active{$cust_main->bill_locationnum} = 1;
 
 my @sorted = (
-  '',
-  grep ( { $active{$_} } @sections),
+  $cust_main->ship_locationnum,
+  grep ( { $active{$_} && $_ != $cust_main->ship_locationnum } @sections),
   grep ( { !$active{$_} } @sections),
 );
 
diff --git a/httemplate/view/cust_main/misc.html b/httemplate/view/cust_main/misc.html
index 12faa57..a0ab403 100644
--- a/httemplate/view/cust_main/misc.html
+++ b/httemplate/view/cust_main/misc.html
@@ -134,9 +134,9 @@
 
   <TR>
     <TD ALIGN="right">
-      <% mt('Census tract ([_1])', $cust_main->censusyear) |h %>
+      <% mt('Census tract ([_1])', $cust_main->ship_location->censusyear) |h %>
     </TD>
-    <TD BGCOLOR="#ffffff"><% $cust_main->censustract  %></TD>
+    <TD BGCOLOR="#ffffff"><% $cust_main->ship_location->censustract  %></TD>
   </TR>
 
 % }
@@ -145,7 +145,7 @@
 
   <TR>
     <TD ALIGN="right"><% mt('Tax district') |h %></TD>
-    <TD BGCOLOR="#ffffff"><% $cust_main->district %></TD>
+    <TD BGCOLOR="#ffffff"><% $cust_main->ship_location->district %></TD>
   </TR>
 
 % }

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/ClientAPI/MasonComponent.pm                  |    2 +-
 FS/FS/ClientAPI/MyAccount.pm                       |   79 +++-
 FS/FS/ClientAPI/Signup.pm                          |   49 ++-
 FS/FS/Schema.pm                                    |   23 +-
 FS/FS/UI/Web/small_custview.pm                     |   54 +--
 FS/FS/cust_bill.pm                                 |    8 +-
 FS/FS/cust_location.pm                             |  176 +++++--
 FS/FS/cust_main.pm                                 |  493 ++++++++------------
 FS/FS/cust_main/Billing.pm                         |   56 ++-
 FS/FS/cust_main/Location.pm                        |  252 ++++++++++
 FS/FS/cust_main/Packages.pm                        |    6 +-
 FS/FS/cust_main/Search.pm                          |  151 ++++---
 FS/FS/cust_pkg.pm                                  |   52 ++
 FS/FS/msg_template.pm                              |    5 +
 httemplate/edit/cust_main.cgi                      |  194 ++++----
 httemplate/edit/cust_main/after_bill_location.html |   12 +
 .../edit/cust_main/before_bill_location.html       |   10 +
 httemplate/edit/cust_main/birthdate.html           |    1 +
 httemplate/edit/cust_main/bottomfixup.js           |   28 +-
 httemplate/edit/cust_main/company.html             |    7 +
 httemplate/edit/cust_main/fax.html                 |    5 +
 httemplate/edit/cust_main/name.html                |   53 +++
 httemplate/edit/cust_main/phones.html              |   29 ++
 httemplate/edit/cust_main/stateid.html             |   39 ++
 httemplate/edit/cust_main/top_misc.html            |   27 +-
 httemplate/edit/msg_template.html                  |   16 +-
 httemplate/edit/process/cust_location.cgi          |    6 +-
 httemplate/edit/process/cust_main.cgi              |   43 ++-
 httemplate/elements/location.html                  |   99 ++--
 httemplate/elements/standardize_locations.js       |    5 +-
 httemplate/elements/tr-select-cust_location.html   |   50 +--
 httemplate/search/report_tax.cgi                   |   98 ++---
 httemplate/view/cust_main/contacts.html            |  146 +++---
 httemplate/view/cust_main/locations.html           |   22 +-
 httemplate/view/cust_main/misc.html                |    6 +-
 35 files changed, 1413 insertions(+), 889 deletions(-)
 create mode 100644 FS/FS/cust_main/Location.pm
 create mode 100644 httemplate/edit/cust_main/after_bill_location.html
 create mode 100644 httemplate/edit/cust_main/before_bill_location.html
 create mode 100644 httemplate/edit/cust_main/company.html
 create mode 100644 httemplate/edit/cust_main/fax.html
 create mode 100644 httemplate/edit/cust_main/name.html
 create mode 100644 httemplate/edit/cust_main/phones.html
 create mode 100644 httemplate/edit/cust_main/stateid.html




More information about the freeside-commits mailing list