should be better error message for inability to find tax rates, RT#4681. also pull...
[freeside.git] / FS / FS / cust_main.pm
index 8b57b93..038577a 100644 (file)
@@ -9,7 +9,7 @@ use Safe;
 use Carp;
 use Exporter;
 use Scalar::Util qw( blessed );
 use Carp;
 use Exporter;
 use Scalar::Util qw( blessed );
-use Time::Local qw(timelocal_nocheck);
+use Time::Local qw(timelocal);
 use Data::Dumper;
 use Tie::IxHash;
 use Digest::MD5 qw(md5_base64);
 use Data::Dumper;
 use Tie::IxHash;
 use Digest::MD5 qw(md5_base64);
@@ -28,6 +28,7 @@ use FS::cust_svc;
 use FS::cust_bill;
 use FS::cust_bill_pkg;
 use FS::cust_bill_pkg_display;
 use FS::cust_bill;
 use FS::cust_bill_pkg;
 use FS::cust_bill_pkg_display;
+use FS::cust_bill_pkg_tax_location;
 use FS::cust_pay;
 use FS::cust_pay_pending;
 use FS::cust_pay_void;
 use FS::cust_pay;
 use FS::cust_pay_pending;
 use FS::cust_pay_void;
@@ -36,7 +37,10 @@ use FS::cust_credit;
 use FS::cust_refund;
 use FS::part_referral;
 use FS::cust_main_county;
 use FS::cust_refund;
 use FS::part_referral;
 use FS::cust_main_county;
+use FS::cust_location;
+use FS::tax_rate;
 use FS::cust_tax_location;
 use FS::cust_tax_location;
+use FS::part_pkg_taxrate;
 use FS::agent;
 use FS::cust_main_invoice;
 use FS::cust_credit_bill;
 use FS::agent;
 use FS::cust_main_invoice;
 use FS::cust_credit_bill;
@@ -133,101 +137,181 @@ FS::Record.  The following fields are currently supported:
 
 =over 4
 
 
 =over 4
 
-=item custnum - primary key (assigned automatically for new customers)
+=item custnum
 
 
-=item agentnum - agent (see L<FS::agent>)
+Primary key (assigned automatically for new customers)
 
 
-=item refnum - Advertising source (see L<FS::part_referral>)
+=item agentnum
+
+Agent (see L<FS::agent>)
+
+=item refnum
+
+Advertising source (see L<FS::part_referral>)
+
+=item first
+
+First name
+
+=item last
 
 
-=item first - name
+Last name
 
 
-=item last - name
+=item ss
 
 
-=item ss - social security number (optional)
+Cocial security number (optional)
 
 
-=item company - (optional)
+=item company
+
+(optional)
 
 =item address1
 
 
 =item address1
 
-=item address2 - (optional)
+=item address2
+
+(optional)
 
 =item city
 
 
 =item city
 
-=item county - (optional, see L<FS::cust_main_county>)
+=item county
+
+(optional, see L<FS::cust_main_county>)
+
+=item state
 
 
-=item state - (see L<FS::cust_main_county>)
+(see L<FS::cust_main_county>)
 
 =item zip
 
 
 =item zip
 
-=item country - (see L<FS::cust_main_county>)
+=item country
+
+(see L<FS::cust_main_county>)
+
+=item daytime
+
+phone (optional)
 
 
-=item daytime - phone (optional)
+=item night
 
 
-=item night - phone (optional)
+phone (optional)
 
 
-=item fax - phone (optional)
+=item fax
 
 
-=item ship_first - name
+phone (optional)
 
 
-=item ship_last - name
+=item ship_first
 
 
-=item ship_company - (optional)
+Shipping first name
+
+=item ship_last
+
+Shipping last name
+
+=item ship_company
+
+(optional)
 
 =item ship_address1
 
 
 =item ship_address1
 
-=item ship_address2 - (optional)
+=item ship_address2
+
+(optional)
 
 =item ship_city
 
 
 =item ship_city
 
-=item ship_county - (optional, see L<FS::cust_main_county>)
+=item ship_county
+
+(optional, see L<FS::cust_main_county>)
+
+=item ship_state
 
 
-=item ship_state - (see L<FS::cust_main_county>)
+(see L<FS::cust_main_county>)
 
 =item ship_zip
 
 
 =item ship_zip
 
-=item ship_country - (see L<FS::cust_main_county>)
+=item ship_country
 
 
-=item ship_daytime - phone (optional)
+(see L<FS::cust_main_county>)
 
 
-=item ship_night - phone (optional)
+=item ship_daytime
+
+phone (optional)
+
+=item ship_night
+
+phone (optional)
+
+=item ship_fax
+
+phone (optional)
+
+=item payby
 
 
-=item ship_fax - phone (optional)
+Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
 
 
-=item payby - Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
+=item payinfo
 
 
-=item payinfo - Payment Information (See L<FS::payinfo_Mixin> for data format)
+Payment Information (See L<FS::payinfo_Mixin> for data format)
 
 
-=item paymask - Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
+=item paymask
+
+Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
 
 =item paycvv
 
 Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
 
 
 =item paycvv
 
 Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
 
-=item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
+=item paydate
+
+Expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
+
+=item paystart_month
+
+Start date month (maestro/solo cards only)
+
+=item paystart_year
+
+Start date year (maestro/solo cards only)
+
+=item payissue
+
+Issue number (maestro/solo cards only)
+
+=item payname
+
+Name on card or billing name
+
+=item payip
+
+IP address from which payment information was received
+
+=item tax
+
+Tax exempt, empty or `Y'
 
 
-=item paystart_month - start date month (maestro/solo cards only)
+=item otaker
 
 
-=item paystart_year - start date year (maestro/solo cards only)
+Order taker (assigned automatically, see L<FS::UID>)
 
 
-=item payissue - issue number (maestro/solo cards only)
+=item comments
 
 
-=item payname - name on card or billing name
+Comments (optional)
 
 
-=item payip - IP address from which payment information was received
+=item referral_custnum
 
 
-=item tax - tax exempt, empty or `Y'
+Referring customer number
 
 
-=item otaker - order taker (assigned automatically, see L<FS::UID>)
+=item spool_cdr
 
 
-=item comments - comments (optional)
+Enable individual CDR spooling, empty or `Y'
 
 
-=item referral_custnum - referring customer number
+=item dundate
 
 
-=item spool_cdr - Enable individual CDR spooling, empty or `Y'
+A suggestion to events (see L<FS::part_bill_event">) to delay until this unix timestamp
 
 
-=item dundate - a suggestion to events (see L<FS::part_bill_event">) to delay until this unix timestamp
+=item squelch_cdr
 
 
-=item squelch_cdr - Discourage individual CDR printing, empty or `Y'
+Discourage individual CDR printing, empty or `Y'
 
 =back
 
 
 =back
 
@@ -576,12 +660,115 @@ sub _copy_skel {
 
 }
 
 
 }
 
+=item order_pkg HASHREF | OPTION => VALUE ... 
+
+Orders a single package.
+
+Options may be passed as a list of key/value pairs or as a hash reference.
+Options are:
+
+=over 4
+
+=item cust_pkg
+
+FS::cust_pkg object
+
+=item cust_location
+
+Optional FS::cust_location object
+
+=item svcs
+
+Optional arryaref of FS::svc_* service objects.
+
+=item depend_jobnum
+
+If this option is set to a job queue jobnum (see L<FS::queue>), all provisioning
+jobs will have a dependancy on the supplied job (they will not run until the
+specific job completes).  This can be used to defer provisioning until some
+action completes (such as running the customer's credit card successfully).
+
+=back
+
+=cut
+
+sub order_pkg {
+  my $self = shift;
+  my $opt = ref($_[0]) ? shift : { @_ };
+
+  warn "$me order_pkg called with options ".
+       join(', ', map { "$_: $opt->{$_}" } keys %$opt ). "\n"
+    if $DEBUG;
+
+  my $cust_pkg = $opt->{'cust_pkg'};
+  my $seconds  = $opt->{'seconds'};
+  my $svcs     = $opt->{'svcs'} || [];
+
+  my %svc_options = ();
+  $svc_options{'depend_jobnum'} = $opt->{'depend_jobnum'}
+    if exists($opt->{'depend_jobnum'}) && $opt->{'depend_jobnum'};
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  if ( $opt->{'cust_location'} &&
+       ( ! $cust_pkg->locationnum || $cust_pkg->locationnum == -1 ) ) {
+    my $error = $opt->{'cust_location'}->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "inserting cust_location (transaction rolled back): $error";
+    }
+    $cust_pkg->locationnum($opt->{'cust_location'}->locationnum);
+  }
+
+  $cust_pkg->custnum( $self->custnum );
+
+  my $error = $cust_pkg->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return "inserting cust_pkg (transaction rolled back): $error";
+  }
+
+  foreach my $svc_something ( @{ $opt->{'svcs'} } ) {
+    if ( $svc_something->svcnum ) {
+      my $old_cust_svc = $svc_something->cust_svc;
+      my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
+      $new_cust_svc->pkgnum( $cust_pkg->pkgnum);
+      $error = $new_cust_svc->replace($old_cust_svc);
+    } else {
+      $svc_something->pkgnum( $cust_pkg->pkgnum );
+      if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) {
+        $svc_something->seconds( $svc_something->seconds + $$seconds );
+        $$seconds = 0;
+      }
+      $error = $svc_something->insert(%svc_options);
+    }
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "inserting svc_ (transaction rolled back): $error";
+    }
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  ''; #no error
+
+}
+
 =item order_pkgs HASHREF, [ SECONDSREF, [ , OPTION => VALUE ... ] ]
 
 =item order_pkgs HASHREF, [ SECONDSREF, [ , OPTION => VALUE ... ] ]
 
-Like the insert method on an existing record, this method orders a package
-and included services atomicaly.  Pass a Tie::RefHash data structure to this
-method containing FS::cust_pkg and FS::svc_I<tablename> objects.  There should
-be a better explanation of this, but until then, here's an example:
+Like the insert method on an existing record, this method orders multiple
+packages and included services atomicaly.  Pass a Tie::RefHash data structure
+to this method containing FS::cust_pkg and FS::svc_I<tablename> objects.
+There should be a better explanation of this, but until then, here's an
+example:
 
   use Tie::RefHash;
   tie %hash, 'Tie::RefHash'; #this part is important
 
   use Tie::RefHash;
   tie %hash, 'Tie::RefHash'; #this part is important
@@ -614,9 +801,7 @@ sub order_pkgs {
   my $cust_pkgs = shift;
   my $seconds = shift;
   my %options = @_;
   my $cust_pkgs = shift;
   my $seconds = shift;
   my %options = @_;
-  my %svc_options = ();
-  $svc_options{'depend_jobnum'} = $options{'depend_jobnum'}
-    if exists $options{'depend_jobnum'};
+
   warn "$me order_pkgs called with options ".
        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
     if $DEBUG;
   warn "$me order_pkgs called with options ".
        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
     if $DEBUG;
@@ -635,32 +820,17 @@ sub order_pkgs {
   local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
 
   foreach my $cust_pkg ( keys %$cust_pkgs ) {
   local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
 
   foreach my $cust_pkg ( keys %$cust_pkgs ) {
-    $cust_pkg->custnum( $self->custnum );
-    my $error = $cust_pkg->insert;
+
+    my $error = $self->order_pkg( 'cust_pkg'      => $cust_pkg,
+                                  'svcs'          => $cust_pkgs->{$cust_pkg},
+                                  'seconds'       => $seconds,
+                                  'depend_jobnum' => $options{'depend_jobnum'},
+                                );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "inserting cust_pkg (transaction rolled back): $error";
-    }
-    foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
-      if ( $svc_something->svcnum ) {
-        my $old_cust_svc = $svc_something->cust_svc;
-        my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
-        $new_cust_svc->pkgnum( $cust_pkg->pkgnum);
-        $error = $new_cust_svc->replace($old_cust_svc);
-      } else {
-        $svc_something->pkgnum( $cust_pkg->pkgnum );
-        if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) {
-          $svc_something->seconds( $svc_something->seconds + $$seconds );
-          $$seconds = 0;
-        }
-        $error = $svc_something->insert(%svc_options);
-      }
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        #return "inserting svc_ (transaction rolled back): $error";
-        return $error;
-      }
+      return $error;
     }
     }
+
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -1262,6 +1432,7 @@ sub check {
     || $self->ut_textn('stateid')
     || $self->ut_textn('stateid_state')
     || $self->ut_textn('invoice_terms')
     || $self->ut_textn('stateid')
     || $self->ut_textn('stateid_state')
     || $self->ut_textn('invoice_terms')
+    || $self->ut_alphan('geocode')
   ;
 
   #barf.  need message catalogs.  i18n.  etc.
   ;
 
   #barf.  need message catalogs.  i18n.  etc.
@@ -1638,6 +1809,17 @@ sub cust_pkg {
   shift->all_pkgs(@_);
 }
 
   shift->all_pkgs(@_);
 }
 
+=item cust_location
+
+Returns all locations (see L<FS::cust_location>) for this customer.
+
+=cut
+
+sub cust_location {
+  my $self = shift;
+  qsearch('cust_location', { 'custnum' => $self->custnum } );
+}
+
 =item ncancelled_pkgs
 
 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
 =item ncancelled_pkgs
 
 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
@@ -2097,6 +2279,7 @@ sub bill {
     if $DEBUG;
 
   my $time = $options{'time'} || time;
     if $DEBUG;
 
   my $time = $options{'time'} || time;
+  my $invoice_time = $options{'invoice_time'} || $time;
 
   #put below somehow?
   local $SIG{HUP} = 'IGNORE';
 
   #put below somehow?
   local $SIG{HUP} = 'IGNORE';
@@ -2120,9 +2303,7 @@ sub bill {
   ###
 
   my( $total_setup, $total_recur, $postal_charge ) = ( 0, 0, 0 );
   ###
 
   my( $total_setup, $total_recur, $postal_charge ) = ( 0, 0, 0 );
-  my %tax;
   my %taxlisthash;
   my %taxlisthash;
-  my %taxname;
   my @precommit_hooks = ();
 
   my @cust_pkgs = qsearch('cust_pkg', { 'custnum' => $self->custnum } );
   my @precommit_hooks = ();
 
   my @cust_pkgs = qsearch('cust_pkg', { 'custnum' => $self->custnum } );
@@ -2204,26 +2385,65 @@ sub bill {
   }
 
   warn "having a look at the taxes we found...\n" if $DEBUG > 2;
   }
 
   warn "having a look at the taxes we found...\n" if $DEBUG > 2;
+
+  # keys are tax names (as printed on invoices / itemdesc )
+  # values are listrefs of taxlisthash keys (internal identifiers)
+  my %taxname = ();
+
+  # keys are taxlisthash keys (internal identifiers)
+  # values are (cumulative) amounts
+  my %tax = ();
+
+  # keys are taxlisthash keys (internal identifiers)
+  # values are listrefs of cust_bill_pkg_tax_location hashrefs
+  my %tax_location = ();
+
   foreach my $tax ( keys %taxlisthash ) {
     my $tax_object = shift @{ $taxlisthash{$tax} };
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
   foreach my $tax ( keys %taxlisthash ) {
     my $tax_object = shift @{ $taxlisthash{$tax} };
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
-    my $listref_or_error = $tax_object->taxline( @{ $taxlisthash{$tax} } );
-    unless (ref($listref_or_error)) {
+    my $hashref_or_error =
+      $tax_object->taxline( $taxlisthash{$tax},
+                            'custnum'      => $self->custnum,
+                            'invoice_time' => $invoice_time
+                          );
+    unless ( ref($hashref_or_error) ) {
       $dbh->rollback if $oldAutoCommit;
       $dbh->rollback if $oldAutoCommit;
-      return $listref_or_error;
+      return $hashref_or_error;
     }
     unshift @{ $taxlisthash{$tax} }, $tax_object;
 
     }
     unshift @{ $taxlisthash{$tax} }, $tax_object;
 
-    warn "adding ". $listref_or_error->[1].
-         " as ". $listref_or_error->[0]. "\n"
-      if $DEBUG > 2;
-    $tax{ $tax_object->taxname } += $listref_or_error->[1];
-    if ( $taxname{ $listref_or_error->[0] } ) {
-      push @{ $taxname{ $listref_or_error->[0] } }, $tax_object->taxname;
-    }else{
-      $taxname{ $listref_or_error->[0] } = [ $tax_object->taxname ];
+    my $name   = $hashref_or_error->{'name'};
+    my $amount = $hashref_or_error->{'amount'};
+
+    #warn "adding $amount as $name\n";
+    $taxname{ $name } ||= [];
+    push @{ $taxname{ $name } }, $tax;
+
+    $tax{ $tax } += $amount;
+
+    $tax_location{ $tax } ||= [];
+    if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
+      push @{ $tax_location{ $tax }  },
+        {
+          'taxnum'      => $tax_object->taxnum, 
+          'taxtype'     => ref($tax_object),
+          'pkgnum'      => $tax_object->get('pkgnum'),
+          'locationnum' => $tax_object->get('locationnum'),
+          'amount'      => sprintf('%.2f', $amount ),
+        };
+    }
+
+  }
+
+  #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
+  my %packagemap = map { $_->pkgnum => $_ } @cust_bill_pkg;
+  foreach my $tax ( keys %taxlisthash ) {
+    foreach ( @{ $taxlisthash{$tax} }[1 ... scalar(@{ $taxlisthash{$tax} })] ) {
+      next unless ref($_) eq 'FS::cust_bill_pkg'; # shouldn't happen
+
+      push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg }, 
+        splice( @{ $_->_cust_tax_exempt_pkg } );
     }
     }
-  
   }
 
   #some taxes are taxed
   }
 
   #some taxes are taxed
@@ -2245,9 +2465,9 @@ sub bill {
                                                       # existing taxes
       warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
       if ( exists( $totlisthash{ $totname } ) ) {
                                                       # existing taxes
       warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
       if ( exists( $totlisthash{ $totname } ) ) {
-        push @{ $totlisthash{ $totname  } }, $tax{ $tax_object->taxname };
+        push @{ $totlisthash{ $totname  } }, $tax{ $tax };
       }else{
       }else{
-        $totlisthash{ $totname } = [ $tot, $tax{ $tax_object->taxname } ];
+        $totlisthash{ $totname } = [ $tot, $tax{ $tax } ];
       }
     }
   }
       }
     }
   }
@@ -2257,7 +2477,11 @@ sub bill {
     my $tax_object = shift @{ $totlisthash{$tax} };
     warn "found previously found taxed tax ". $tax_object->taxname. "\n"
       if $DEBUG > 2;
     my $tax_object = shift @{ $totlisthash{$tax} };
     warn "found previously found taxed tax ". $tax_object->taxname. "\n"
       if $DEBUG > 2;
-    my $listref_or_error = $tax_object->taxline( @{ $totlisthash{$tax} } );
+    my $listref_or_error =
+      $tax_object->taxline( $totlisthash{$tax},
+                            'custnum'      => $self->custnum,
+                            'invoice_time' => $invoice_time
+                          );
     unless (ref($listref_or_error)) {
       $dbh->rollback if $oldAutoCommit;
       return $listref_or_error;
     unless (ref($listref_or_error)) {
       $dbh->rollback if $oldAutoCommit;
       return $listref_or_error;
@@ -2266,7 +2490,7 @@ sub bill {
     warn "adding taxed tax amount ". $listref_or_error->[1].
          " as ". $tax_object->taxname. "\n"
       if $DEBUG;
     warn "adding taxed tax amount ". $listref_or_error->[1].
          " as ". $tax_object->taxname. "\n"
       if $DEBUG;
-    $tax{ $tax_object->taxname } += $listref_or_error->[1];
+    $tax{ $tax } += $listref_or_error->[1];
   }
   
   #consolidate and create tax line items
   }
   
   #consolidate and create tax line items
@@ -2274,10 +2498,15 @@ sub bill {
   foreach my $taxname ( keys %taxname ) {
     my $tax = 0;
     my %seen = ();
   foreach my $taxname ( keys %taxname ) {
     my $tax = 0;
     my %seen = ();
+    my @cust_bill_pkg_tax_location = ();
     warn "adding $taxname\n" if $DEBUG > 1;
     foreach my $taxitem ( @{ $taxname{$taxname} } ) {
     warn "adding $taxname\n" if $DEBUG > 1;
     foreach my $taxitem ( @{ $taxname{$taxname} } ) {
-      $tax += $tax{$taxitem} unless $seen{$taxitem};
+      next if $seen{$taxitem}++;
       warn "adding $tax{$taxitem}\n" if $DEBUG > 1;
       warn "adding $tax{$taxitem}\n" if $DEBUG > 1;
+      $tax += $tax{$taxitem};
+      push @cust_bill_pkg_tax_location,
+        map { new FS::cust_bill_pkg_tax_location $_ }
+            @{ $tax_location{ $taxitem } };
     }
     next unless $tax;
 
     }
     next unless $tax;
 
@@ -2291,6 +2520,7 @@ sub bill {
       'sdate'    => '',
       'edate'    => '',
       'itemdesc' => $taxname,
       'sdate'    => '',
       'edate'    => '',
       'itemdesc' => $taxname,
+      'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
     };
 
   }
     };
 
   }
@@ -2300,7 +2530,7 @@ sub bill {
   #create the new invoice
   my $cust_bill = new FS::cust_bill ( {
     'custnum' => $self->custnum,
   #create the new invoice
   my $cust_bill = new FS::cust_bill ( {
     'custnum' => $self->custnum,
-    '_date'   => ( $options{'invoice_time'} || $time ),
+    '_date'   => ( $invoice_time ),
     'charged' => $charged,
   } );
   my $error = $cust_bill->insert;
     'charged' => $charged,
   } );
   my $error = $cust_bill->insert;
@@ -2432,36 +2662,19 @@ sub _make_lines {
       if ( $@ );
 
     if ( $increment_next_bill ) {
       if ( $@ );
 
     if ( $increment_next_bill ) {
-  
-      #change this bit to use Date::Manip? CAREFUL with timezones (see
-      # mailing list archive)
-      my ($sec,$min,$hour,$mday,$mon,$year) =
-        (localtime($sdate) )[0,1,2,3,4,5];
 
 
-      #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
+      my $next_bill = $part_pkg->add_freq($sdate);
+      return "unparsable frequency: ". $part_pkg->freq
+        if $next_bill == -1;
+  
+      #pro-rating magic - if $recur_prog fiddled $sdate, want to use that
       # only for figuring next bill date, nothing else, so, reset $sdate again
       # here
       $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
       #no need, its in $hash{last_bill}# my $last_bill = $cust_pkg->last_bill;
       $cust_pkg->last_bill($sdate);
 
       # only for figuring next bill date, nothing else, so, reset $sdate again
       # here
       $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
       #no need, its in $hash{last_bill}# my $last_bill = $cust_pkg->last_bill;
       $cust_pkg->last_bill($sdate);
 
-      if ( $part_pkg->freq =~ /^\d+$/ ) {
-        $mon += $part_pkg->freq;
-        until ( $mon < 12 ) { $mon -= 12; $year++; }
-      } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) {
-        my $weeks = $1;
-        $mday += $weeks * 7;
-      } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) {
-        my $days = $1;
-        $mday += $days;
-      } elsif ( $part_pkg->freq =~ /^(\d+)h$/ ) {
-        my $hours = $1;
-        $hour += $hours;
-      } else {
-        return "unparsable frequency: ". $part_pkg->freq;
-      }
-      $cust_pkg->setfield('bill',
-        timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
+      $cust_pkg->setfield('bill', $next_bill );
 
     }
 
 
     }
 
@@ -2564,71 +2777,98 @@ sub _handle_taxes {
   my %cust_bill_pkg = ();
   my %taxes = ();
     
   my %cust_bill_pkg = ();
   my %taxes = ();
     
-  my $prefix = 
-    ( $conf->exists('tax-ship_address') && length($self->ship_last) )
-    ? 'ship_'
-    : '';
-
   my @classes;
   #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
   push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
   push @classes, 'setup' if $cust_bill_pkg->setup;
   push @classes, 'recur' if $cust_bill_pkg->recur;
 
   my @classes;
   #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
   push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
   push @classes, 'setup' if $cust_bill_pkg->setup;
   push @classes, 'recur' if $cust_bill_pkg->recur;
 
-  if ( $conf->exists('enable_taxproducts')
-       && (scalar($part_pkg->part_pkg_taxoverride) || $part_pkg->has_taxproduct)
-       && ( $self->tax !~ /Y/i && $self->payby ne 'COMP' )
-     )
-  { 
+  if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
 
 
-    foreach my $class (@classes) {
-      my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $prefix );
-      return $err_or_ref unless ref($err_or_ref);
-      $taxes{$class} = $err_or_ref;
-    }
+    if ( $conf->exists('enable_taxproducts')
+         && ( scalar($part_pkg->part_pkg_taxoverride)
+              || $part_pkg->has_taxproduct
+            )
+       )
+    {
 
 
-    unless (exists $taxes{''}) {
-      my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $prefix );
-      return $err_or_ref unless ref($err_or_ref);
-      $taxes{''} = $err_or_ref;
-    }
+      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
+        return "fatal: Can't (yet) use tax-pkg_address with taxproducts";
+      }
 
 
-  } elsif ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
+      foreach my $class (@classes) {
+        my $err_or_ref = $self->_gather_taxes( $part_pkg, $class );
+        return $err_or_ref unless ref($err_or_ref);
+        $taxes{$class} = $err_or_ref;
+      }
 
 
-    my %taxhash = map { $_ => $self->get("$prefix$_") }
-                      qw( state county country );
+      unless (exists $taxes{''}) {
+        my $err_or_ref = $self->_gather_taxes( $part_pkg, '' );
+        return $err_or_ref unless ref($err_or_ref);
+        $taxes{''} = $err_or_ref;
+      }
 
 
-    $taxhash{'taxclass'} = $part_pkg->taxclass;
+    } else {
 
 
-    my @taxes = qsearch( 'cust_main_county', \%taxhash );
+      my @loc_keys = qw( state county 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;
+      }
 
 
-    unless ( @taxes ) {
-      $taxhash{'taxclass'} = '';
-      @taxes =  qsearch( 'cust_main_county', \%taxhash );
-    }
+      $taxhash{'taxclass'} = $part_pkg->taxclass;
 
 
-    #one more try at a whole-country tax rate
-    unless ( @taxes ) {
-      $taxhash{$_} = '' foreach qw( state county );
-      @taxes =  qsearch( 'cust_main_county', \%taxhash );
-    }
+      my @taxes = qsearch( 'cust_main_county', \%taxhash );
 
 
-    $taxes{''} = [ @taxes ];
-    $taxes{'setup'} = [ @taxes ];
-    $taxes{'recur'} = [ @taxes ];
-    $taxes{$_} = [ @taxes ] foreach (@classes);
-
-    # maybe eliminate this entirely, along with all the 0% records
-    unless ( @taxes ) {
-      return
-        "fatal: can't find tax rate for state/county/country/taxclass ".
-        join('/', ( map $self->get("$prefix$_"),
-                        qw(state county country)
-                  ),
-                  $part_pkg->taxclass ). "\n";
-    }
+      my %taxhash_elim = %taxhash;
+
+      # no, unexpected change in behavior.
+      #my @elim = qw( taxclass county state );
+      #while ( !scalar(@taxes) && scalar(@elim) ) {
+      #  $taxhash_elim{ shift(@elim) } = '';
+      #  @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
+      #}
+
+      #just try taxclass first, then state+county, not county in the middle
+      unless ( @taxes ) {
+        $taxhash_elim{'taxclass'} = '';
+        @taxes =  qsearch( 'cust_main_county', \%taxhash_elim );
+      }
+      #one more try at a whole-country tax rate
+      unless ( @taxes ) {
+        $taxhash_elim{$_} = '' foreach qw( state county );
+        @taxes =  qsearch( 'cust_main_county', \%taxhash_elim );
+      }
 
 
-  } #if $conf->exists('enable_taxproducts') ...
+      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
+        foreach (@taxes) {
+          $_->set('pkgnum',      $cust_pkg->pkgnum );
+          $_->set('locationnum', $cust_pkg->locationnum );
+        }
+      }
+
+      $taxes{''} = [ @taxes ];
+      $taxes{'setup'} = [ @taxes ];
+      $taxes{'recur'} = [ @taxes ];
+      $taxes{$_} = [ @taxes ] foreach (@classes);
+
+      # maybe eliminate this entirely, along with all the 0% records
+      unless ( @taxes ) {
+        return
+          "fatal: can't find tax rate for state/county/country/taxclass ".
+          join('/', map $taxhash{$_}, qw(state county country taxclass) );
+      }
+
+    } #if $conf->exists('enable_taxproducts') ...
+
+  }
  
   my @display = ();
   if ( $conf->exists('separate_usage') ) {
  
   my @display = ();
   if ( $conf->exists('separate_usage') ) {
@@ -2654,7 +2894,12 @@ sub _handle_taxes {
     my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
 
     foreach my $tax ( @taxes ) {
     my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
 
     foreach my $tax ( @taxes ) {
-      my $taxname = ref( $tax ). ' '. $tax->taxnum;
+
+      my $taxname = ref( $tax ). ' taxnum'. $tax->taxnum;
+#      $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
+#                  ' locationnum'. $cust_pkg->locationnum
+#        if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
+
       if ( exists( $taxlisthash->{ $taxname } ) ) {
         push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
       }else{
       if ( exists( $taxlisthash->{ $taxname } ) ) {
         push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
       }else{
@@ -2670,7 +2915,6 @@ sub _gather_taxes {
   my $self = shift;
   my $part_pkg = shift;
   my $class = shift;
   my $self = shift;
   my $part_pkg = shift;
   my $class = shift;
-  my $prefix = shift;
 
   my @taxes = ();
   my $geocode = $self->geocode('cch');
 
   my @taxes = ();
   my $geocode = $self->geocode('cch');
@@ -2698,12 +2942,11 @@ sub _gather_taxes {
   # maybe eliminate this entirely, along with all the 0% records
   unless ( @taxes ) {
     return 
   # maybe eliminate this entirely, along with all the 0% records
   unless ( @taxes ) {
     return 
-      "fatal: can't find tax rate for zip/taxproduct/pkgpart ".
-      join('/', ( map $self->get("$prefix$_"),
-                      qw(zip)
-                ),
+      "fatal: can't find tax rate for geocode/taxproduct/pkgpart ".
+      join('/', $geocode,
                 $part_pkg->taxproduct_description,
                 $part_pkg->taxproduct_description,
-                $part_pkg->pkgpart ). "\n";
+                $part_pkg->pkgpart
+          );
   }
 
   warn "Found taxes ".
   }
 
   warn "Found taxes ".
@@ -2881,6 +3124,11 @@ Only return events for the specified eventtable (by default, events of all event
 
 Explicitly pass the objects to be tested (typically used with eventtable).
 
 
 Explicitly pass the objects to be tested (typically used with eventtable).
 
+=item testonly
+
+Set to true to return the objects, but not actually insert them into the
+database.
+
 =back
 
 =cut
 =back
 
 =cut
@@ -2911,7 +3159,8 @@ sub due_cust_event {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  $self->select_for_update; #mutex
+  $self->select_for_update #mutex
+    unless $opt{testonly};
 
   ###
   # 1: find possible events (initial search)
 
   ###
   # 1: find possible events (initial search)
@@ -3687,7 +3936,7 @@ sub realtime_bop {
       my $templ_hash = { error => $transaction->error_message };
 
       my $error = send_email(
       my $templ_hash = { error => $transaction->error_message };
 
       my $error = send_email(
-        'from'    => $conf->config('invoice_from'),
+        'from'    => $conf->config('invoice_from', $self->agentnum ),
         'to'      => [ grep { $_ ne 'POST' } $self->invoicing_list ],
         'subject' => 'Your payment could not be processed',
         'body'    => [ $template->fill_in(HASH => $templ_hash) ],
         'to'      => [ grep { $_ ne 'POST' } $self->invoicing_list ],
         'subject' => 'Your payment could not be processed',
         'body'    => [ $template->fill_in(HASH => $templ_hash) ],
@@ -5182,6 +5431,35 @@ sub ship_name {
   }
 }
 
   }
 }
 
+=item name_short
+
+Returns a name string for this customer, either "Company" or "First Last".
+
+=cut
+
+sub name_short {
+  my $self = shift;
+  $self->company !~ /^\s*$/ ? $self->company : $self->contact_firstlast;
+}
+
+=item ship_name_short
+
+Returns a name string for this (service/shipping) contact, either "Company"
+or "First Last".
+
+=cut
+
+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;
+  }
+}
+
 =item contact
 
 Returns this customer's full (billing) contact name only, "Last, First"
 =item contact
 
 Returns this customer's full (billing) contact name only, "Last, First"
@@ -5206,6 +5484,30 @@ sub ship_contact {
     : $self->contact;
 }
 
     : $self->contact;
 }
 
+=item contact_firstlast
+
+Returns this customers full (billing) contact name only, "First Last".
+
+=cut
+
+sub contact_firstlast {
+  my $self = shift;
+  $self->first. ' '. $self->get('last');
+}
+
+=item ship_contact_firstlast
+
+Returns this customer's full (shipping) contact name only, "First Last".
+
+=cut
+
+sub ship_contact_firstlast {
+  my $self = shift;
+  $self->get('ship_last')
+    ? $self->first. ' '. $self->get('ship_last')
+    : $self->contact_firstlast;
+}
+
 =item country_full
 
 Returns this customer's full country name
 =item country_full
 
 Returns this customer's full country name
@@ -5227,6 +5529,9 @@ Currently this only makes sense for "CCH" as DATA_VENDOR.
 sub geocode {
   my ($self, $data_vendor) = (shift, shift);  #always cch for now
 
 sub geocode {
   my ($self, $data_vendor) = (shift, shift);  #always cch for now
 
+  my $geocode = $self->get('geocode');  #XXX only one data_vendor for geocode
+  return $geocode if $geocode;
+
   my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) )
                ? 'ship_'
                : '';
   my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) )
                ? 'ship_'
                : '';
@@ -5237,7 +5542,6 @@ sub geocode {
   #CCH specific location stuff
   my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
 
   #CCH specific location stuff
   my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
 
-  my $geocode = '';
   my @cust_tax_location =
     qsearch( {
                'table'     => 'cust_tax_location', 
   my @cust_tax_location =
     qsearch( {
                'table'     => 'cust_tax_location', 
@@ -5337,22 +5641,24 @@ sub tickets {
   my $num = $conf->config('cust_main-max_tickets') || 10;
   my @tickets = ();
 
   my $num = $conf->config('cust_main-max_tickets') || 10;
   my @tickets = ();
 
-  unless ( $conf->config('ticket_system-custom_priority_field') ) {
+  if ( $conf->config('ticket_system') ) {
+    unless ( $conf->config('ticket_system-custom_priority_field') ) {
 
 
-    @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
+      @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
 
 
-  } else {
+    } else {
 
 
-    foreach my $priority (
-      $conf->config('ticket_system-custom_priority_field-values'), ''
-    ) {
-      last if scalar(@tickets) >= $num;
-      push @tickets, 
-        @{ FS::TicketSystem->customer_tickets( $self->custnum,
-                                               $num - scalar(@tickets),
-                                               $priority,
-                                             )
-         };
+      foreach my $priority (
+        $conf->config('ticket_system-custom_priority_field-values'), ''
+      ) {
+        last if scalar(@tickets) >= $num;
+        push @tickets, 
+          @{ FS::TicketSystem->customer_tickets( $self->custnum,
+                                                 $num - scalar(@tickets),
+                                                 $priority,
+                                               )
+           };
+      }
     }
   }
   (@tickets);
     }
   }
   (@tickets);
@@ -6506,18 +6812,19 @@ I<$expdate> - the expiration of the customer payment in seconds from epoch
 =cut
 
 sub notify {
 =cut
 
 sub notify {
-  my ($customer, $template, %options) = @_;
+  my ($self, $template, %options) = @_;
 
   return unless $conf->exists($template);
 
 
   return unless $conf->exists($template);
 
-  my $from = $conf->config('invoice_from') if $conf->exists('invoice_from');
+  my $from = $conf->config('invoice_from', $self->agentnum)
+    if $conf->exists('invoice_from', $self->agentnum);
   $from = $options{from} if exists($options{from});
 
   $from = $options{from} if exists($options{from});
 
-  my $to = join(',', $customer->invoicing_list_emailonly);
+  my $to = join(',', $self->invoicing_list_emailonly);
   $to = $options{to} if exists($options{to});
   
   $to = $options{to} if exists($options{to});
   
-  my $subject = "Notice from " . $conf->config('company_name')
-    if $conf->exists('company_name');
+  my $subject = "Notice from " . $conf->config('company_name', $self->agentnum)
+    if $conf->exists('company_name', $self->agentnum);
   $subject = $options{subject} if exists($options{subject});
 
   my $notify_template = new Text::Template (TYPE => 'ARRAY',
   $subject = $options{subject} if exists($options{subject});
 
   my $notify_template = new Text::Template (TYPE => 'ARRAY',
@@ -6528,16 +6835,17 @@ sub notify {
   $notify_template->compile()
     or die "can't compile template: Text::Template::ERROR";
 
   $notify_template->compile()
     or die "can't compile template: Text::Template::ERROR";
 
-  $FS::notify_template::_template::company_name = $conf->config('company_name');
+  $FS::notify_template::_template::company_name =
+    $conf->config('company_name', $self->agentnum);
   $FS::notify_template::_template::company_address =
   $FS::notify_template::_template::company_address =
-    join("\n", $conf->config('company_address') ). "\n";
-
-  my $paydate = $customer->paydate || '2037-12-31';
-  $FS::notify_template::_template::first = $customer->first;
-  $FS::notify_template::_template::last = $customer->last;
-  $FS::notify_template::_template::company = $customer->company;
-  $FS::notify_template::_template::payinfo = $customer->mask_payinfo;
-  my $payby = $customer->payby;
+    join("\n", $conf->config('company_address', $self->agentnum) ). "\n";
+
+  my $paydate = $self->paydate || '2037-12-31';
+  $FS::notify_template::_template::first = $self->first;
+  $FS::notify_template::_template::last = $self->last;
+  $FS::notify_template::_template::company = $self->company;
+  $FS::notify_template::_template::payinfo = $self->mask_payinfo;
+  my $payby = $self->payby;
   my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
   my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
 
   my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
   my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
 
@@ -6637,10 +6945,10 @@ sub generate_letter {
                      );
     if ( length($retadd) ) {
       $letter_data{returnaddress} = $retadd;
                      );
     if ( length($retadd) ) {
       $letter_data{returnaddress} = $retadd;
-    } elsif ( grep /\S/, $conf->config('company_address') ) {
+    } elsif ( grep /\S/, $conf->config('company_address', $self->agentnum) ) {
       $letter_data{returnaddress} =
         join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg,
       $letter_data{returnaddress} =
         join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg,
-                          $conf->config('company_address')
+                          $conf->config('company_address', $self->agentnum)
         );
     } else {
       $letter_data{returnaddress} = '~';
         );
     } else {
       $letter_data{returnaddress} = '~';
@@ -6649,9 +6957,9 @@ sub generate_letter {
 
   $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
 
 
   $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
 
-  $letter_data{company_name} = $conf->config('company_name');
+  $letter_data{company_name} = $conf->config('company_name', $self->agentnum);
 
 
-  my $dir = $FS::UID::conf_dir."cache.". $FS::UID::datasrc;
+  my $dir = $FS::UID::conf_dir."/cache.". $FS::UID::datasrc;
   my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
                            DIR      => $dir,
                            SUFFIX   => '.tex',
   my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
                            DIR      => $dir,
                            SUFFIX   => '.tex',
@@ -6699,6 +7007,8 @@ sub print {
   do_print [ $self->print_ps($template) ];
 }
 
   do_print [ $self->print_ps($template) ];
 }
 
+#these three subs should just go away once agent stuff is all config overrides
+
 sub agent_template {
   my $self = shift;
   $self->_agent_plandata('agent_templatename');
 sub agent_template {
   my $self = shift;
   $self->_agent_plandata('agent_templatename');
@@ -6739,9 +7049,13 @@ sub _agent_plandata {
                AND peo_agentnum.optionname = 'agentnum'
                AND peo_agentnum.optionvalue }. $regexp. q{ '(^|,)}. $agentnum. q{(,|$)'
              )
                AND peo_agentnum.optionname = 'agentnum'
                AND peo_agentnum.optionvalue }. $regexp. q{ '(^|,)}. $agentnum. q{(,|$)'
              )
-        LEFT JOIN part_event_option AS peo_cust_bill_age
-          ON ( part_event.eventpart = peo_cust_bill_age.eventpart
-               AND peo_cust_bill_age.optionname = 'cust_bill_age'
+        LEFT JOIN part_event_condition
+          ON ( part_event.eventpart = part_event_condition.eventpart
+               AND part_event_condition.conditionname = 'cust_bill_age'
+             )
+        LEFT JOIN part_event_condition_option
+          ON ( part_event_condition.eventconditionnum = part_event_condition_option.eventconditionnum
+               AND part_event_condition_option.optionname = 'age'
              )
       },
       #'hashref'   => { 'optionname' => $option },
              )
       },
       #'hashref'   => { 'optionname' => $option },
@@ -6753,9 +7067,9 @@ sub _agent_plandata {
         " AND peo_agentnum.optionname = 'agentnum' ".
         " AND ( agentnum IS NULL OR agentnum = $agentnum ) ".
         " ORDER BY
         " AND peo_agentnum.optionname = 'agentnum' ".
         " AND ( agentnum IS NULL OR agentnum = $agentnum ) ".
         " ORDER BY
-           CASE WHEN peo_cust_bill_age.optionname != 'cust_bill_age'
+           CASE WHEN part_event_condition_option.optionname IS NULL
            THEN -1
            THEN -1
-          ELSE ". FS::part_event::Condition->age2seconds_sql('peo_cust_bill_age.optionvalue').
+          ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue').
         " END
           , part_event.weight".
         " LIMIT 1"
         " END
           , part_event.weight".
         " LIMIT 1"