checkpoint of new tax rating system
authorjeff <jeff>
Tue, 1 Apr 2008 00:54:44 +0000 (00:54 +0000)
committerjeff <jeff>
Tue, 1 Apr 2008 00:54:44 +0000 (00:54 +0000)
30 files changed:
FS/FS/Conf.pm
FS/FS/Schema.pm
FS/FS/cust_tax_location.pm [new file with mode: 0644]
FS/FS/part_pkg.pm
FS/FS/part_pkg_taxoverride.pm [new file with mode: 0644]
FS/FS/part_pkg_taxproduct.pm [new file with mode: 0644]
FS/FS/part_pkg_taxrate.pm [new file with mode: 0644]
FS/FS/tax_class.pm [new file with mode: 0644]
FS/FS/tax_rate.pm [new file with mode: 0644]
FS/MANIFEST
FS/t/cust_tax_location.t [new file with mode: 0644]
FS/t/part_pkg_taxoverride.t [new file with mode: 0644]
FS/t/part_pkg_taxproduct.t [new file with mode: 0644]
FS/t/part_pkg_taxrate.t [new file with mode: 0644]
FS/t/tax_class.t [new file with mode: 0644]
FS/t/tax_rate.t [new file with mode: 0644]
htetc/handler.pl
httemplate/browse/tax_rate.cgi [new file with mode: 0755]
httemplate/edit/part_pkg.cgi
httemplate/edit/part_pkg_taxoverride.html [new file with mode: 0644]
httemplate/edit/part_pkg_taxproduct.html [new file with mode: 0644]
httemplate/edit/process/part_pkg.cgi
httemplate/edit/process/tax_class.html [new file with mode: 0644]
httemplate/edit/process/tax_rate.html [new file with mode: 0644]
httemplate/edit/tax_class.html [new file with mode: 0644]
httemplate/edit/tax_rate.html [new file with mode: 0644]
httemplate/elements/menu.html
httemplate/misc/process/recharge_svc.new [new file with mode: 0755]
httemplate/misc/process/tax-import.cgi [new file with mode: 0644]
httemplate/misc/tax-import.cgi [new file with mode: 0644]

index 94f2f05..d1f64b2 100644 (file)
@@ -1406,6 +1406,13 @@ worry that config_items is freeside-specific and icky.
   },
 
   {
+    'key'         => 'enable_taxproducts',
+    'section'     => 'billing',
+    'description' => 'Enable per-package mapping to new style tax classes',
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'welcome_email',
     'section'     => '',
     'description' => 'Template file for welcome email.  Welcome emails are sent to the customer email invoice destination(s) each time a svc_acct record is created.  See the <a href="http://search.cpan.org/~mjd/Text-Template/lib/Text/Template.pm">Text::Template</a> documentation for details on the template substitution language.  The following variables are available<ul><li><code>$username</code> <li><code>$password</code> <li><code>$first</code> <li><code>$last</code> <li><code>$pkg</code></ul>',
index 9548aa7..e431b07 100644 (file)
@@ -303,6 +303,7 @@ sub tables_hashref {
   my @date_type  = ( 'int', 'NULL', ''     );
   my @perl_type = ( 'text', 'NULL', ''  ); 
   my @money_type = ( 'decimal',   '', '10,2' );
+  my @money_typen = ( 'decimal',   'NULL', '10,2' );
 
   my $username_len = 32; #usernamemax config file
 
@@ -665,6 +666,68 @@ sub tables_hashref {
       'index' => [ [ 'county' ], [ 'state' ], [ 'country' ] ],
     },
 
+    'tax_rate'    => {
+      'columns' => [
+        'taxnum',       'serial',     '',      '', '', '', 
+        'geocode',     'varchar', 'NULL', $char_d, '', '',#cch provides 10 char
+        'data_vendor', 'varchar', 'NULL', $char_d, '', '',#auto update source
+        'location',    'varchar', 'NULL', $char_d, '', '',#provided by tax authority
+        'taxclassnum', 'int',      '',      '', '', '', 
+        'effective_date', @date_type, '', '', 
+        'tax',        'real',  '',    '', '', '',        # tax %
+        'excessrate', 'real',  'NULL','', '', '',        # second tax %
+        'taxbase',    @money_typen, '', '',              # amount at first tax rate
+        'taxmax',     @money_typen, '', '',              # maximum about at both rates
+        'usetax',        'real',  'NULL',    '', '', '', # tax % when non-local
+        'useexcessrate', 'real',  'NULL',    '', '', '', # second tax % when non-local
+        'unittype',    'int',  'NULL', '', '', '',      # for fee
+        'fee',         'real', 'NULL', '', '', '',      # amount tax per unit
+        'excessfee',   'real', 'NULL', '', '', '',      # second amount tax per unit
+        'feebase',     'real', 'NULL', '', '', '',      # units taxed at first rate
+        'feemax',      'real', 'NULL', '', '', '',      # maximum number of unit taxed
+        'maxtype',     'int',  'NULL', '', '', '',      # indicator of how thresholds accumulate
+        'taxname', 'varchar',  'NULL', $char_d, '', '', # may appear on invoice
+        'taxauth',     'int',  'NULL', '', '', '',      # tax authority
+        'basetype',    'int',  'NULL', '', '', '', # indicator of basis for tax
+        'passtype',    'int',  'NULL', '', '', '', # indicator declaring how item should be shown
+        'passflag',    'char', 'NULL', 1, '', '',  # Y = required to list as line item, N = Prohibited
+        'setuptax',    'char', 'NULL', 1, '', '',  # Y = setup tax exempt
+        'recurtax',    'char', 'NULL', 1, '', '',  # Y = recur tax exempt
+        'manual',      'char', 'NULL', 1, '', '',  # Y = manually edited
+      ],
+      'primary_key' => 'taxnum',
+      'unique' => [],
+      'index' => [ ['taxclassnum'], ['data_vendor', 'geocode'] ],
+    },
+
+    'cust_tax_location' => { 
+      'columns' => [
+        'custlocationnum', 'serial',  '',     '', '', '', 
+        'data_vendor',     'varchar', 'NULL', $char_d, '', '', # update source
+        'zip',             'char',    '',     5,  '', '', 
+        'state',           'char',    '',     2,  '', '', 
+        'plus4hi',         'char',    '',     4,  '', '', 
+        'plus4lo',         'char',    '',     4,  '', '', 
+        'default_location','char',    'NULL', 1,  '', '', # Y = default for zip
+        'geocode',         'varchar', '',    20,  '', '', 
+      ],
+      'primary_key' => 'custlocationnum',
+      'unique' => [],
+      'index' => [ [ 'zip', 'plus4lo', 'plus4hi' ] ],
+    },
+
+    'tax_class' => { 
+      'columns' => [
+        'taxclassnum',  'serial',  '',            '', '', '',
+        'data_vendor',  'varchar', 'NULL',   $char_d, '', '',
+        'taxclass',     'varchar', '',       $char_d, '', '',          
+        'description',  'varchar', '',     2*$char_d, '', '',          
+      ],
+      'primary_key' => 'taxclassnum',
+      'unique' => [ [ 'data_vendor', 'taxclass' ] ],
+      'index' => [],
+    },
+
     'cust_pay_pending' => {
       'columns' => [
         'paypendingnum','serial',      '',  '', '', '',
@@ -933,6 +996,7 @@ sub tables_hashref {
         'disabled',      'char', 'NULL', 1, '', '', 
         'taxclass',      'varchar', 'NULL', $char_d, '', '', 
         'classnum',      'int',     'NULL', '', '', '', 
+        'taxproductnum', 'int',     'NULL', '', '', '', 
         'pay_weight',    'real',    'NULL', '', '', '',
         'credit_weight', 'real',    'NULL', '', '', '',
         'agentnum',      'int',     'NULL', '', '', '', 
@@ -953,6 +1017,51 @@ sub tables_hashref {
       'index'       => [],
     },
 
+    'part_pkg_taxproduct' => {
+      'columns' => [
+        'taxproductnum', 'serial',      '',        '', '', '',
+        'data_vendor',   'varchar', 'NULL',   $char_d, '', '', 
+        'taxproduct',    'varchar',     '',   $char_d, '', '', 
+        'description',   'varchar',     '', 2*$char_d, '', '', 
+      ],
+      'primary_key' => 'taxproductnum',
+      'unique'      => [ [ 'data_vendor', 'taxproduct' ] ],
+      'index'       => [],
+    },
+
+    'part_pkg_taxrate' => { 
+      'columns' => [
+        'pkgtaxratenum', 'serial',  '',     '',      '', '',
+        'data_vendor',   'varchar', 'NULL', $char_d, '', '', # update source
+        'geocode',       'varchar', 'NULL', $char_d, '', '', # cch provides 10
+        'taxproductnum', 'int',  '',     '',       '', '',          
+        'city',             'varchar', 'NULL', $char_d, '', '', # tax_location?
+        'county',           'varchar', 'NULL', $char_d, '', '', 
+        'state',            'varchar', 'NULL', $char_d, '', '', 
+        'local',            'varchar', 'NULL', $char_d, '', '', 
+        'country',          'char',    'NULL', 2,       '', '',
+        'taxclassnumtaxed', 'int',     'NULL', '',      '', '', 
+        'taxcattaxed',      'varchar', 'NULL', $char_d, '', '', 
+        'taxclassnum',      'int',     'NULL', '',      '', '', 
+        'effdate',          @date_type, '', '', 
+        'taxable',          'char',    'NULL', 1,       '', '', 
+      ],
+      'primary_key' => 'pkgtaxratenum',
+      'unique' => [],
+      'index' => [ [ 'data_vendor', 'geocode', 'taxproductnum' ] ],
+    },
+
+    'part_pkg_taxoverride' => { 
+      'columns' => [
+        'taxoverridenum', 'serial', '', '', '', '',
+        'pkgpart',        'serial', '', '', '', '',
+        'taxnum',         'serial', '', '', '', '',
+      ],
+      'primary_key' => 'taxoverridenum',
+      'unique' => [],
+      'index' => [ [ 'pkgpart' ], [ 'taxnum' ] ],
+    },
+
 #    'part_title' => {
 #      'columns' => [
 #        'titlenum',   'int',    '',   '',
@@ -1366,6 +1475,7 @@ sub tables_hashref {
         'routernum', 'serial', '', '', '', '', 
         'routername', 'varchar', '', $char_d, '', '', 
         'svcnum', 'int', 'NULL', '', '', '', 
+        'agentnum',   'int', 'NULL', '', '', '', 
       ],
       'primary_key' => 'routernum',
       'unique'      => [],
@@ -1389,6 +1499,7 @@ sub tables_hashref {
        'routernum', 'int', '', '', '', '', 
         'ip_gateway', 'varchar', '', 15, '', '', 
         'ip_netmask', 'int', '', '', '', '', 
+        'agentnum',   'int', 'NULL', '', '', '', 
       ],
       'primary_key' => 'blocknum',
       'unique'      => [ [ 'blocknum', 'routernum' ] ],
diff --git a/FS/FS/cust_tax_location.pm b/FS/FS/cust_tax_location.pm
new file mode 100644 (file)
index 0000000..11faa3f
--- /dev/null
@@ -0,0 +1,208 @@
+package FS::cust_tax_location;
+
+use strict;
+use vars qw( @ISA );
+use FS::Record qw( qsearch qsearchs dbh );
+
+@ISA = qw(FS::Record);
+
+=head1 NAME
+
+FS::cust_tax_location - Object methods for cust_tax_location records
+
+=head1 SYNOPSIS
+
+  use FS::cust_tax_location;
+
+  $record = new FS::cust_tax_location \%hash;
+  $record = new FS::cust_tax_location { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_tax_location object represents a mapping between a customer and
+a tax location.  FS::cust_tax_location inherits from FS::Record.  The
+following fields are currently supported:
+
+=over 4
+
+=item custlocationnum
+
+primary key
+
+=item data_vendor
+
+a tax data vendor
+
+=item zip 
+
+=item state
+
+=item plus4hi
+
+the upper bound of the last 4 zip code digits
+
+=item plus4lo
+
+the lower bound of the last 4 zip code digits
+
+=item default_location
+
+'Y' when this record represents the default for zip
+
+=item geocode - the foreign key into FS::part_pkg_tax_rate and FS::tax_rate
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new cust_tax_location.  To add the cust_tax_location to the database,
+see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_tax_location'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid cust_tax_location.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('custlocationnum')
+    || $self->ut_text('data_vendor')
+    || $self->ut_number('zip')
+    || $self->ut_text('state')
+    || $self->ut_number('plus4hi')
+    || $self->ut_number('plus4lo')
+    || $self->ut_enum('default', [ '', ' ', 'Y' ] )
+    || $self->ut_number('geocode')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+
+sub batch_import {
+  my $param = shift;
+
+  my $fh = $param->{filehandle};
+  my $format = $param->{'format'};
+
+  my @fields;
+  if ( $format eq 'cch' ) {
+    @fields = qw( zip state plus4lo plus4hi geocode default );
+  } elsif ( $format eq 'extended' ) {
+    die "unimplemented\n";
+    @fields = qw( );
+  } else {
+    die "unknown format $format";
+  }
+
+  eval "use Text::CSV_XS;";
+  die $@ if $@;
+
+  my $csv = new Text::CSV_XS;
+
+  my $imported = 0;
+
+  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;
+  
+  my $line;
+  while ( defined($line=<$fh>) ) {
+    $csv->parse($line) or do {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't parse: ". $csv->error_input();
+    };
+
+    my @columns = $csv->fields();
+
+    my %cust_tax_location = ( 'data_vendor' => $format );;
+    foreach my $field ( @fields ) {
+      $cust_tax_location{$field} = shift @columns; 
+    }
+
+    my $cust_tax_location = new FS::cust_tax_location( \%cust_tax_location );
+    my $error = $cust_tax_location->insert;
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't insert cust_tax_location for $line: $error";
+    }
+
+    $imported++;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return "Empty file!" unless $imported;
+
+  ''; #no error
+
+}
+
+=back
+
+=head1 BUGS
+
+The author should be informed of any you find.
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index 84502b7..dc0a4d5 100644 (file)
@@ -14,6 +14,8 @@ use FS::type_pkgs;
 use FS::part_pkg_option;
 use FS::pkg_class;
 use FS::agent;
+use FS::part_pkg_taxoverride;
+use FS::part_pkg_taxproduct;
 
 @ISA = qw( FS::m2m_Common FS::Record ); # FS::option_Common ); # this can use option_Common
                                                 # when all the plandata bs is
@@ -726,6 +728,34 @@ sub option {
   '';
 }
 
+=item part_pkg_taxoverride
+
+Returns all options as FS::part_pkg_taxoverride objects (see
+L<FS::part_pkg_taxoverride>).
+
+=cut
+
+sub part_pkg_taxoverride {
+  my $self = shift;
+  qsearch('part_pkg_taxoverride', { 'pkgpart' => $self->pkgpart } );
+}
+
+=item taxproduct_description
+
+Returns the description of the associated tax product for this package
+definition (see L<FS::part_pkg_taxproduct>).
+
+=cut
+
+sub taxproduct_description {
+  my $self = shift;
+  my $part_pkg_taxproduct =
+    qsearchs( 'part_pkg_taxproduct',
+              { 'taxproductnum' => $self->taxproductnum }
+            );
+  $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
+}
+
 =item _rebless
 
 Reblesses the object into the FS::part_pkg::PLAN class (if available), where
diff --git a/FS/FS/part_pkg_taxoverride.pm b/FS/FS/part_pkg_taxoverride.pm
new file mode 100644 (file)
index 0000000..656fe53
--- /dev/null
@@ -0,0 +1,119 @@
+package FS::part_pkg_taxoverride;
+
+use strict;
+use vars qw( @ISA );
+use FS::Record;
+
+@ISA = qw(FS::Record);
+
+=head1 NAME
+
+FS::part_pkg_taxoverride - Object methods for part_pkg_taxoverride records
+
+=head1 SYNOPSIS
+
+  use FS::part_pkg_taxoverride;
+
+  $record = new FS::part_pkg_taxoverride \%hash;
+  $record = new FS::part_pkg_taxoverride { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::part_pkg_taxoverride object represents a manual mapping of a
+package to tax rates.  FS::part_pkg_taxoverride inherits from FS::Record.
+The following fields are currently supported:
+
+=over 4
+
+=item taxoverridenum
+
+Primary key
+
+=item pkgpart
+
+The package definition id
+
+=item taxnum
+
+The tax rate definition id
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new tax override.  To add the tax product to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'part_pkg_taxoverride'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid tax product.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('taxoverridenum')
+    || $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart')
+    || $self->ut_foreign_key('taxnum', 'tax_rate', 'taxnum')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=cut
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/part_pkg_taxproduct.pm b/FS/FS/part_pkg_taxproduct.pm
new file mode 100644 (file)
index 0000000..000d0d4
--- /dev/null
@@ -0,0 +1,124 @@
+package FS::part_pkg_taxproduct;
+
+use strict;
+use vars qw( @ISA );
+use FS::Record;
+
+@ISA = qw(FS::Record);
+
+=head1 NAME
+
+FS::part_pkg_taxproduct - Object methods for part_pkg_taxproduct records
+
+=head1 SYNOPSIS
+
+  use FS::part_pkg_taxproduct;
+
+  $record = new FS::part_pkg_taxproduct \%hash;
+  $record = new FS::part_pkg_taxproduct { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::part_pkg_taxproduct object represents a tax product. 
+FS::part_pkg_taxproduct inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item taxproductnum
+
+Primary key
+
+=item data_vendor
+
+Tax data vendor
+
+=item taxproduct
+
+Tax product id from the vendor
+
+=item description
+
+A human readable description of the id in taxproduct
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new tax product.  To add the tax product to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'part_pkg_taxproduct'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid tax product.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('taxproductnum')
+    || $self->ut_textn('data_vendor')
+    || $self->ut_text('taxproduct')
+    || $self->ut_textn('description')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=cut
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/part_pkg_taxrate.pm b/FS/FS/part_pkg_taxrate.pm
new file mode 100644 (file)
index 0000000..aa1c3df
--- /dev/null
@@ -0,0 +1,336 @@
+package FS::part_pkg_taxrate;
+
+use strict;
+use vars qw( @ISA );
+use Date::Parse;
+use FS::UID qw(dbh);
+use FS::Record qw( qsearch qsearchs );
+use FS::part_pkg_taxproduct;
+
+@ISA = qw(FS::Record);
+
+=head1 NAME
+
+FS::part_pkg_taxrate - Object methods for part_pkg_taxrate records
+
+=head1 SYNOPSIS
+
+  use FS::part_pkg_taxrate;
+
+  $record = new FS::part_pkg_taxrate \%hash;
+  $record = new FS::part_pkg_taxrate { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::part_pkg_taxrate object maps packages onto tax rates.
+FS::part_pkg_taxrate inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item pkgtaxratenum
+
+Primary key
+
+=item data_vendor
+
+Tax data vendor
+
+=item geocode
+
+Tax vendor location code
+
+=item taxproductnum
+
+Class of package for tax purposes, Index into FS::part_pkg_taxproduct
+
+=item city
+
+city
+
+=item county
+
+county
+
+=item state
+
+state
+
+=item local
+
+local
+
+=item country
+
+country
+
+=item taxclassnum
+
+Class of tax index into FS::tax_taxclass and FS::tax_rate
+
+=item taxclassnumtaxed
+
+Class of tax taxed by this entry.
+
+=item taxable
+
+taxable
+
+=item effdate
+
+effdate
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new customer (location), package, tax rate mapping.  To add the
+mapping to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'part_pkg_taxrate'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid tax rate mapping.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('pkgtaxratenum')
+    || $self->ut_textn('data_vendor')
+    || $self->ut_textn('geocode')
+    || $self->
+         ut_foreign_key('taxproductnum', 'part_pkg_taxproduct', 'taxproductnum')
+    || $self->ut_textn('city')
+    || $self->ut_textn('county')
+    || $self->ut_textn('state')
+    || $self->ut_textn('local')
+    || $self->ut_text('country')
+    || $self->ut_foreign_keyn('taxclassnumtaxed', 'tax_class', 'taxclassnum')
+    || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
+    || $self->ut_numbern('effective_date')
+    || $self->ut_enum('taxable', [ 'Y', '' ])
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item batch_import
+
+Loads part_pkg_taxrate records from an external CSV file.  If there is
+an error, returns the error, otherwise returns false. 
+
+=cut 
+
+sub batch_import {
+  my $param = shift;
+
+  my $fh = $param->{filehandle};
+  my $format = $param->{'format'};
+
+  my @fields;
+  my $hook;
+  if ( $format eq 'cch' ) {
+    @fields = qw( city county state local geocode group groupdesc item
+                  itemdesc provider customer taxtypetaxed taxcattaxed
+                  taxable taxtype taxcat effdate rectype );
+
+    $hook = sub { 
+      my $hash = shift;
+
+      unless ( $hash->{'rectype'} eq 'R' or $hash->{'rectype'} eq 'T' ) {
+        delete($hash->{$_}) for (keys %$hash);
+        return;
+      }
+
+      my %providers = ( '00' => 'Regulated LEC',
+                        '01' => 'Regulated IXC',
+                        '02' => 'Unregulated LEC',
+                        '03' => 'Unregulated IXC',
+                        '04' => 'ISP',
+                        '05' => 'Wireless',
+                      );
+
+      my %customers = ( '00' => 'Residential',
+                        '01' => 'Commercial',
+                        '02' => 'Industrial',
+                        '09' => 'Lifeline',
+                        '10' => 'Senior Citizen',
+                      );
+
+      my $taxproduct =
+        join(':', map{ $hash->{$_} } qw(group item provider customer ) );
+
+      my %part_pkg_taxproduct = ( 'data_vendor' => 'cch', 
+                                  'taxproduct' => $taxproduct,
+                                );
+
+      my $part_pkg_taxproduct = qsearchs( 'part_pkg_taxproduct', 
+                                          { %part_pkg_taxproduct }
+                                        );
+      unless ($part_pkg_taxproduct) {
+        $part_pkg_taxproduct{'description'} = 
+          join(' : ', map{ $hash->{$_} } qw(groupdesc itemdesc),
+                      $providers{$hash->{'provider'}} || 'Unknown',
+                      $customers{$hash->{'customer'}} || 'Unknown',
+              );
+        $part_pkg_taxproduct = new FS::part_pkg_taxproduct \%part_pkg_taxproduct;
+        my $error = $part_pkg_taxproduct->insert;
+        return "Error inserting tax product (part_pkg_taxproduct): $error"
+          if $error;
+
+      }
+      $hash->{'taxproductnum'} = $part_pkg_taxproduct->taxproductnum;
+
+      delete($hash->{$_})
+        for qw(group groupdesc item itemdesc provider customer rectype );
+
+      my %map = ( 'taxclassnum'      => [ 'taxtype', 'taxcat' ],
+                  'taxclassnumtaxed' => [ 'taxtypetaxed', 'taxcattaxed' ],
+                );
+
+      for my $item (keys %map) {
+        my $tax_class =
+          qsearchs( 'tax_class',
+                    { data_vendor => 'cch',
+                      'taxclass' => join(':', map($hash->{$_}, @{$map{$item}})),
+                    }
+                  );
+        $hash->{$item} = $tax_class->taxclassnum
+          if $tax_class;
+
+        delete($hash->{$_}) foreach @{$map{$item}};
+      }
+
+      $hash->{'effdate'} = str2time($hash->{'effdate'});
+
+      $hash->{'effdate'} = str2time($hash->{'effdate'});
+      $hash->{'country'} = 'US'; # CA is available
+
+      delete($hash->{'taxable'}) if ($hash->{'taxable'} eq 'N');
+
+      '';
+    };
+
+  } elsif ( $format eq 'extended' ) {
+    die "unimplemented\n";
+    @fields = qw( );
+    $hook = sub {};
+  } else {
+    die "unknown format $format";
+  }
+
+  eval "use Text::CSV_XS;";
+  die $@ if $@;
+
+  my $csv = new Text::CSV_XS;
+
+  my $imported = 0;
+
+  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;
+  
+  my $line;
+  while ( defined($line=<$fh>) ) {
+    $csv->parse($line) or do {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't parse: ". $csv->error_input();
+    };
+
+    my @columns = $csv->fields();
+
+    my %part_pkg_taxrate = ( 'data_vendor' => $format );
+    foreach my $field ( @fields ) {
+      $part_pkg_taxrate{$field} = shift @columns; 
+    }
+    my $error = &{$hook}(\%part_pkg_taxrate);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+    next unless scalar(keys %part_pkg_taxrate);
+
+
+    my $part_pkg_taxrate = new FS::part_pkg_taxrate( \%part_pkg_taxrate );
+    $error = $part_pkg_taxrate->insert;
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't insert part_pkg_taxrate for $line: $error";
+    }
+
+    $imported++;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return "Empty file!" unless $imported;
+
+  ''; #no error
+
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
+
diff --git a/FS/FS/tax_class.pm b/FS/FS/tax_class.pm
new file mode 100644 (file)
index 0000000..0a939ad
--- /dev/null
@@ -0,0 +1,249 @@
+package FS::tax_class;
+
+use strict;
+use vars qw( @ISA );
+use FS::UID qw(dbh);
+use FS::Record qw( qsearch qsearchs );
+
+@ISA = qw(FS::Record);
+
+=head1 NAME
+
+FS::tax_class - Object methods for tax_class records
+
+=head1 SYNOPSIS
+
+  use FS::tax_class;
+
+  $record = new FS::tax_class \%hash;
+  $record = new FS::tax_class { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::tax_class object represents a tax class.  FS::tax_class
+inherits from FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item taxclassnum
+
+Primary key
+
+=item data_vendor
+
+Vendor of the tax data
+
+=item taxclass
+
+Tax class
+
+=item description
+
+Human readable description of the tax class
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new tax class.  To add the tax class to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'tax_class'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid tax class.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('taxclassnum')
+    || $self->ut_text('taxclass')
+    || $self->ut_textn('data_vendor')
+    || $self->ut_textn('description')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item batch_import
+
+Loads part_pkg_taxrate records from an external CSV file.  If there is
+an error, returns the error, otherwise returns false. 
+
+=cut 
+
+sub batch_import {
+  my $param = shift;
+
+  my $fh = $param->{filehandle};
+  my $format = $param->{'format'};
+
+  my @fields;
+  my $hook;
+  my $endhook;
+  my $data = {};
+  my $imported = 0;
+
+  if ( $format eq 'cch' ) {
+    @fields = qw( table name pos number length value description );
+
+    $hook = sub { 
+      my $hash = shift;
+
+      if ($hash->{'table'} eq 'DETAIL') {
+        push @{$data->{'taxcat'}}, [ $hash->{'value'}, $hash->{'description'} ]
+          if $hash->{'name'} eq 'TAXCAT';
+
+        push @{$data->{'taxtype'}}, [ $hash->{'value'}, $hash->{'description'} ]
+          if $hash->{'name'} eq 'TAXTYPE';
+      }
+
+      delete($hash->{$_})
+        for qw( data_vendor table name pos number length value description );
+
+      '';
+
+    };
+
+    $endhook = sub { 
+      foreach my $type (@{$data->{'taxtype'}}) {
+        foreach my $cat (@{$data->{'taxcat'}}) {
+          my $tax_class =
+            new FS::tax_class( { 'data_vendor' => 'cch',
+                                 'taxclass'    => $type->[0].':'.$cat->[0],
+                                 'description' => $type->[1].':'.$cat->[1],
+                             } );
+          my $error = $tax_class->insert;
+          return $error if $error;
+          $imported++;
+        }
+      }
+      '';
+    };
+
+  } elsif ( $format eq 'extended' ) {
+    die "unimplemented\n";
+    @fields = qw( );
+    $hook = sub {};
+  } else {
+    die "unknown format $format";
+  }
+
+  eval "use Text::CSV_XS;";
+  die $@ if $@;
+
+  my $csv = new Text::CSV_XS;
+
+  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;
+  
+  my $line;
+  while ( defined($line=<$fh>) ) {
+    $csv->parse($line) or do {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't parse: ". $csv->error_input();
+    };
+
+    my @columns = $csv->fields();
+
+    my %tax_class = ( 'data_vendor' => $format );
+    foreach my $field ( @fields ) {
+      $tax_class{$field} = shift @columns; 
+    }
+    my $error = &{$hook}(\%tax_class);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+    next unless scalar(keys %tax_class);
+
+    my $tax_class = new FS::tax_class( \%tax_class );
+    $error = $tax_class->insert;
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't insert tax_class for $line: $error";
+    }
+
+    $imported++;
+  }
+
+  my $error = &{$endhook}();
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return "can't insert tax_class for $line: $error";
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return "Empty file!" unless $imported;
+
+  ''; #no error
+
+}
+
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
+
diff --git a/FS/FS/tax_rate.pm b/FS/FS/tax_rate.pm
new file mode 100644 (file)
index 0000000..38e5343
--- /dev/null
@@ -0,0 +1,605 @@
+package FS::tax_rate;
+
+use strict;
+use vars qw( @ISA @EXPORT_OK $conf $DEBUG $me
+             %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
+             %tax_passtypes
+             @tax_rate %tax_rate $countyflag );
+use Exporter;
+use Date::Parse;
+use Tie::IxHash;
+use FS::Record qw( qsearchs qsearch dbh );
+use FS::tax_class;
+
+@ISA = qw( FS::Record );
+@EXPORT_OK = qw( regionselector );
+
+$DEBUG = 1;
+$me = '[FS::tax_rate]';
+
+@tax_rate = ();
+$countyflag = '';
+
+#ask FS::UID to run this stuff for us later
+$FS::UID::callback{'FS::tax_rate'} = sub { 
+  $conf = new FS::Conf;
+};
+
+=head1 NAME
+
+FS::tax_rate - Object methods for tax_rate objects
+
+=head1 SYNOPSIS
+
+  use FS::tax_rate;
+
+  $record = new FS::tax_rate \%hash;
+  $record = new FS::tax_rate { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+  ($county_html, $state_html, $country_html) =
+    FS::tax_rate::regionselector( $county, $state, $country );
+
+=head1 DESCRIPTION
+
+An FS::tax_rate object represents a tax rate, defined by locale.
+FS::tax_rate inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item taxnum
+
+primary key (assigned automatically for new tax rates)
+
+=item geocode
+
+a geographic location code provided by a tax data vendor
+
+=item data_vendor
+
+the tax data vendor
+
+=item location
+
+a location code provided by a tax authority
+
+=item taxclassnum
+
+a foreign key into FS::tax_class - the type of tax
+referenced but FS::part_pkg_taxrate
+
+=item effective_date
+
+the time after which the tax applies
+
+=item tax
+
+percentage
+
+=item excessrate
+
+second bracket percentage 
+
+=item taxbase
+
+the amount to which the tax applies (first bracket)
+
+=item taxmax
+
+a cap on the amount of tax if a cap exists
+
+=item usetax
+
+percentage on out of jurisdiction purchases
+
+=item useexcessrate
+
+second bracket percentage on out of jurisdiction purchases
+
+=item unittype
+
+one of the values in %tax_unittypes
+
+=item fee
+
+amount of tax per unit
+
+=item excessfee
+
+second bracket amount of tax per unit
+
+=item feebase
+
+the number of units to which the fee applies (first bracket)
+
+=item feemax
+
+the most units to which fees apply (first and second brackets)
+
+=item maxtype
+
+a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)
+
+=item taxname
+
+if defined, printed on invoices instead of "Tax"
+
+=item taxauth
+
+a value from %tax_authorities
+
+=item basetype
+
+a value from %tax_basetypes indicating the tax basis
+
+=item passtype
+
+a value from %tax_passtypes indicating how the tax should displayed to the customer
+
+=item passflag
+
+'Y', 'N', or blank indicating the tax can be passed to the customer
+
+=item setuptax
+
+if 'Y', this tax does not apply to setup fees
+
+=item recurtax
+
+if 'Y', this tax does not apply to recurring fees
+
+=item manual
+
+if 'Y', has been manually edited
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.
+
+=cut
+
+sub table { 'tax_rate'; }
+
+=item insert
+
+Adds this tax rate to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Deletes this tax rate from the database.  If there is an error, returns the
+error, otherwise returns false.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid tax rate.  If there is an error,
+returns the error, otherwise returns false.  Called by the insert and replace
+methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  foreach (qw( taxbase taxmax )) {
+    $self->$_(0) unless $self->$_;
+  }
+
+  $self->ut_numbern('taxnum')
+    || $self->ut_text('geocode')
+    || $self->ut_textn('data_vendor')
+    || $self->ut_textn('location')
+    || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
+    || $self->ut_numbern('effective_date')
+    || $self->ut_float('tax')
+    || $self->ut_floatn('excessrate')
+    || $self->ut_money('taxbase')
+    || $self->ut_money('taxmax')
+    || $self->ut_floatn('usetax')
+    || $self->ut_floatn('useexcessrate')
+    || $self->ut_numbern('unittype')
+    || $self->ut_floatn('fee')
+    || $self->ut_floatn('excessfee')
+    || $self->ut_floatn('feemax')
+    || $self->ut_numbern('maxtype')
+    || $self->ut_textn('taxname')
+    || $self->ut_numbern('taxauth')
+    || $self->ut_numbern('basetype')
+    || $self->ut_numbern('passtype')
+    || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
+    || $self->ut_enum('setuptax', [ '', 'Y' ] )
+    || $self->ut_enum('recurtax', [ '', 'Y' ] )
+    || $self->ut_enum('manual', [ '', 'Y' ] )
+    || $self->SUPER::check
+    ;
+
+}
+
+=item taxclass_description
+
+Returns the human understandable value associated with the related
+FS::tax_class.
+
+=cut
+
+sub taxclass_description {
+  my $self = shift;
+  my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
+  $tax_class ? $tax_class->description : '';
+}
+
+=item unittype_name
+
+Returns the human understandable value associated with the unittype column
+
+=cut
+
+%tax_unittypes = ( '0' => 'access line',
+                   '1' => 'minute',
+                   '2' => 'account',
+);
+
+sub unittype_name {
+  my $self = shift;
+  $tax_unittypes{$self->unittype};
+}
+
+=item maxtype_name
+
+Returns the human understandable value associated with the maxtype column
+
+=cut
+
+%tax_maxtypes = ( '0' => 'receipts per invoice',
+                  '1' => 'receipts per item',
+                  '2' => 'total utility charges per utility tax year',
+                  '3' => 'total charges per utility tax year',
+                  '4' => 'receipts per access line',
+                  '9' => 'monthly receipts per location',
+);
+
+sub maxtype_name {
+  my $self = shift;
+  $tax_maxtypes{$self->maxtype};
+}
+
+=item basetype_name
+
+Returns the human understandable value associated with the basetype column
+
+=cut
+
+%tax_basetypes = ( '0'  => 'sale price',
+                   '1'  => 'gross receipts',
+                   '2'  => 'sales taxable telecom revenue',
+                   '3'  => 'minutes carried',
+                   '4'  => 'minutes billed',
+                   '5'  => 'gross operating revenue',
+                   '6'  => 'access line',
+                   '7'  => 'account',
+                   '8'  => 'gross revenue',
+                   '9'  => 'portion gross receipts attributable to interstate service',
+                   '10' => 'access line',
+                   '11' => 'gross profits',
+                   '12' => 'tariff rate',
+                   '14' => 'account',
+);
+
+sub basetype_name {
+  my $self = shift;
+  $tax_basetypes{$self->basetype};
+}
+
+=item taxauth_name
+
+Returns the human understandable value associated with the taxauth column
+
+=cut
+
+%tax_authorities = ( '0' => 'federal',
+                     '1' => 'state',
+                     '2' => 'county',
+                     '3' => 'city',
+                     '4' => 'local',
+                     '5' => 'county administered by state',
+                     '6' => 'city administered by state',
+                     '7' => 'city administered by county',
+                     '8' => 'local administered by state',
+                     '9' => 'local administered by county',
+);
+
+sub taxauth_name {
+  my $self = shift;
+  $tax_authorities{$self->taxauth};
+}
+
+=item passtype_name
+
+Returns the human understandable value associated with the passtype column
+
+=cut
+
+%tax_passtypes = ( '0' => 'separate tax line',
+                   '1' => 'separate surcharge line',
+                   '2' => 'surcharge not separated',
+                   '3' => 'included in base rate',
+);
+
+sub passtype_name {
+  my $self = shift;
+  $tax_passtypes{$self->passtype};
+}
+
+=back
+
+=head1 SUBROUTINES
+
+=over 4
+
+=item regionselector [ COUNTY STATE COUNTRY [ PREFIX [ ONCHANGE [ DISABLED ] ] ] ]
+
+=cut
+
+sub regionselector {
+  my ( $selected_county, $selected_state, $selected_country,
+       $prefix, $onchange, $disabled ) = @_;
+
+  $prefix = '' unless defined $prefix;
+
+  $countyflag = 0;
+
+#  unless ( @tax_rate ) { #cache 
+    @tax_rate = qsearch('tax_rate', {} );
+    foreach my $c ( @tax_rate ) {
+      $countyflag=1 if $c->county;
+      #push @{$tax_rate{$c->country}{$c->state}}, $c->county;
+      $tax_rate{$c->country}{$c->state}{$c->county} = 1;
+    }
+#  }
+  $countyflag=1 if $selected_county;
+
+  my $script_html = <<END;
+    <SCRIPT>
+    function opt(what,value,text) {
+      var optionName = new Option(text, value, false, false);
+      var length = what.length;
+      what.options[length] = optionName;
+    }
+    function ${prefix}country_changed(what) {
+      country = what.options[what.selectedIndex].text;
+      for ( var i = what.form.${prefix}state.length; i >= 0; i-- )
+          what.form.${prefix}state.options[i] = null;
+END
+      #what.form.${prefix}state.options[0] = new Option('', '', false, true);
+
+  foreach my $country ( sort keys %tax_rate ) {
+    $script_html .= "\nif ( country == \"$country\" ) {\n";
+    foreach my $state ( sort keys %{$tax_rate{$country}} ) {
+      ( my $dstate = $state ) =~ s/[\n\r]//g;
+      my $text = $dstate || '(n/a)';
+      $script_html .= qq!opt(what.form.${prefix}state, "$dstate", "$text");\n!;
+    }
+    $script_html .= "}\n";
+  }
+
+  $script_html .= <<END;
+    }
+    function ${prefix}state_changed(what) {
+END
+
+  if ( $countyflag ) {
+    $script_html .= <<END;
+      state = what.options[what.selectedIndex].text;
+      country = what.form.${prefix}country.options[what.form.${prefix}country.selectedIndex].text;
+      for ( var i = what.form.${prefix}county.length; i >= 0; i-- )
+          what.form.${prefix}county.options[i] = null;
+END
+
+    foreach my $country ( sort keys %tax_rate ) {
+      $script_html .= "\nif ( country == \"$country\" ) {\n";
+      foreach my $state ( sort keys %{$tax_rate{$country}} ) {
+        $script_html .= "\nif ( state == \"$state\" ) {\n";
+          #foreach my $county ( sort @{$tax_rate{$country}{$state}} ) {
+          foreach my $county ( sort keys %{$tax_rate{$country}{$state}} ) {
+            my $text = $county || '(n/a)';
+            $script_html .=
+              qq!opt(what.form.${prefix}county, "$county", "$text");\n!;
+          }
+        $script_html .= "}\n";
+      }
+      $script_html .= "}\n";
+    }
+  }
+
+  $script_html .= <<END;
+    }
+    </SCRIPT>
+END
+
+  my $county_html = $script_html;
+  if ( $countyflag ) {
+    $county_html .= qq!<SELECT NAME="${prefix}county" onChange="$onchange" $disabled>!;
+    $county_html .= '</SELECT>';
+  } else {
+    $county_html .=
+      qq!<INPUT TYPE="hidden" NAME="${prefix}county" VALUE="$selected_county">!;
+  }
+
+  my $state_html = qq!<SELECT NAME="${prefix}state" !.
+                   qq!onChange="${prefix}state_changed(this); $onchange" $disabled>!;
+  foreach my $state ( sort keys %{ $tax_rate{$selected_country} } ) {
+    my $text = $state || '(n/a)';
+    my $selected = $state eq $selected_state ? 'SELECTED' : '';
+    $state_html .= qq(\n<OPTION $selected VALUE="$state">$text</OPTION>);
+  }
+  $state_html .= '</SELECT>';
+
+  $state_html .= '</SELECT>';
+
+  my $country_html = qq!<SELECT NAME="${prefix}country" !.
+                     qq!onChange="${prefix}country_changed(this); $onchange" $disabled>!;
+  my $countrydefault = $conf->config('countrydefault') || 'US';
+  foreach my $country (
+    sort { ($b eq $countrydefault) <=> ($a eq $countrydefault) or $a cmp $b }
+      keys %tax_rate
+  ) {
+    my $selected = $country eq $selected_country ? ' SELECTED' : '';
+    $country_html .= qq(\n<OPTION$selected VALUE="$country">$country</OPTION>");
+  }
+  $country_html .= '</SELECT>';
+
+  ($county_html, $state_html, $country_html);
+
+}
+
+sub batch_import {
+  my $param = shift;
+
+  my $fh = $param->{filehandle};
+  my $format = $param->{'format'};
+
+  my @fields;
+  my $hook;
+  if ( $format eq 'cch' ) {
+    @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
+                  excessrate effective_date taxauth taxtype taxcat taxname
+                  usetax useexcessrate fee unittype feemax maxtype passflag
+                  passtype basetype );
+    $hook = sub {
+      my $hash = shift;
+
+      $hash->{'effective_date'} = str2time($hash->{'effective_date'});
+
+      my $taxclassid =
+        join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
+
+      my %tax_class = ( 'data_vendor'  => 'cch', 
+                        'taxclass' => $taxclassid,
+                      );
+
+      my $tax_class = qsearchs( 'tax_class', \%tax_class );
+      return "Error inserting tax rate: no tax class $taxclassid"
+        unless $tax_class;
+
+      $hash->{'taxclassnum'} = $tax_class->taxclassnum;
+
+      foreach (qw( inoutcity inoutlocal taxtype taxcat )) {
+        delete($hash->{$_});
+      }
+
+      my %passflagmap = ( '0' => '',
+                          '1' => 'Y',
+                          '2' => 'N',
+                        );
+      $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
+        if exists $passflagmap{$hash->{'passflag'}};
+
+      foreach (keys %$hash) {
+        $hash->{$_} = substr($hash->{$_}, 0, 80)
+          if length($hash->{$_}) > 80;
+      }
+
+    };
+
+  } elsif ( $format eq 'extended' ) {
+    die "unimplemented\n";
+    @fields = qw( );
+    $hook = sub {};
+  } else {
+    die "unknown format $format";
+  }
+
+  eval "use Text::CSV_XS;";
+  die $@ if $@;
+
+  my $csv = new Text::CSV_XS;
+
+  my $imported = 0;
+
+  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;
+  
+  my $line;
+  while ( defined($line=<$fh>) ) {
+    $csv->parse($line) or do {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't parse: ". $csv->error_input();
+    };
+
+    warn "$me batch_import: $imported\n" 
+      if (!($imported % 100) && $DEBUG);
+
+    my @columns = $csv->fields();
+
+    my %tax_rate = ( 'data_vendor' => $format );
+    foreach my $field ( @fields ) {
+      $tax_rate{$field} = shift @columns; 
+    }
+    my $error = &{$hook}(\%tax_rate);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+    my $tax_rate = new FS::tax_rate( \%tax_rate );
+    $error = $tax_rate->insert;
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't insert tax_rate for $line: $error";
+    }
+
+    $imported++;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return "Empty file!" unless $imported;
+
+  ''; #no error
+
+}
+
+=back
+
+=head1 BUGS
+
+regionselector?  putting web ui components in here?  they should probably live
+somewhere else...
+
+=head1 SEE ALSO
+
+L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
+documentation.
+
+=cut
+
+1;
+
index 635bc04..23df385 100644 (file)
@@ -48,6 +48,7 @@ FS/UID.pm
 FS/Msgcat.pm
 FS/Pony.pm
 FS/acct_snarf.pm
+FS/addr_block.pm
 FS/agent.pm
 FS/agent_type.pm
 FS/cust_bill.pm
@@ -167,6 +168,7 @@ FS/cust_tax_exempt.pm
 FS/cust_tax_exempt_pkg.pm
 FS/clientapi_session.pm
 FS/clientapi_session_field.pm
+t/addr_block.t
 t/agent.t
 t/agent_type.t
 t/AccessRight.t
@@ -286,6 +288,7 @@ t/rate_prefix.t
 t/radius_usergroup.t
 t/reg_code.t
 t/reg_code_pkg.t
+t/router.t
 t/session.t
 t/svc_acct.t
 t/svc_acct_pop.t
@@ -394,3 +397,15 @@ FS/cust_pay_pending.pm
 t/cust_pay_pending.t
 FS/part_pkg_taxclass.pm
 t/part_pkg_taxclass.t
+FS/tax_rate.pm
+t/tax_rate.t
+FS/tax_class.pm
+t/tax_class.t
+FS/cust_tax_location.pm
+t/cust_tax_location.t
+FS/part_pkg_taxproduct.pm
+t/part_pkg_taxproduct.t
+FS/part_pkg_taxoverride.pm
+t/part_pkg_taxoverride.t
+FS/part_pkg_taxrate.pm
+t/part_pkg_taxrate.t
diff --git a/FS/t/cust_tax_location.t b/FS/t/cust_tax_location.t
new file mode 100644 (file)
index 0000000..83a1362
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_tax_location;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/part_pkg_taxoverride.t b/FS/t/part_pkg_taxoverride.t
new file mode 100644 (file)
index 0000000..d3b385d
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::part_pkg_taxoverride;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/part_pkg_taxproduct.t b/FS/t/part_pkg_taxproduct.t
new file mode 100644 (file)
index 0000000..a0aaa1d
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::part_pkg_taxproduct;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/part_pkg_taxrate.t b/FS/t/part_pkg_taxrate.t
new file mode 100644 (file)
index 0000000..6e5bee0
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::part_pkg_taxrate;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/tax_class.t b/FS/t/tax_class.t
new file mode 100644 (file)
index 0000000..ddd8d9f
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::tax_class;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/tax_rate.t b/FS/t/tax_rate.t
new file mode 100644 (file)
index 0000000..d498812
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::tax_rate;
+$loaded=1;
+print "ok 1\n";
index caa266d..d2fe52d 100644 (file)
@@ -149,9 +149,9 @@ sub handler
       use FS::cust_credit_bill;
       use FS::cust_main qw(smart_search);
       use FS::cust_main_county;
-      use FS::part_pkg_taxclass;
       use FS::cust_pay;
       use FS::cust_pkg;
+      use FS::part_pkg_taxclass;
       use FS::cust_pkg_reason;
       use FS::cust_refund;
       use FS::cust_svc;
@@ -207,6 +207,12 @@ sub handler
       use FS::reason_type;
       use FS::reason;
       use FS::cust_main_note;
+      use FS::tax_class;
+      use FS::cust_tax_location;
+      use FS::part_pkg_taxproduct;
+      use FS::part_pkg_taxoverride;
+      use FS::part_pkg_taxrate;
+      use FS::tax_rate;
 
       if ( %%%RT_ENABLED%%% ) {
         eval '
diff --git a/httemplate/browse/tax_rate.cgi b/httemplate/browse/tax_rate.cgi
new file mode 100755 (executable)
index 0000000..b401b37
--- /dev/null
@@ -0,0 +1,205 @@
+<% include( 'elements/browse.html',
+     'title'          => "Tax Rates $title",
+     'name_singular'  => 'tax rate',
+     'menubar'        => \@menubar,
+     'html_init'      => $html_init,
+     'query'          => {
+                           'table'    => 'tax_rate',
+                           'hashref'  => $hashref,
+                           'order_by' => 'ORDER BY geocode, taxclassnum',
+                         },
+     'count_query'    => $count_query,
+     'header'         => \@header,
+     'header2'        => \@header2,
+     'fields'         => \@fields,
+     'align'          => $align,
+     'color'          => \@color,
+     'cell_style'     => \@cell_style,
+     'links'          => \@links,
+     'link_onclicks'  => \@link_onclicks,
+  )
+%>
+<%once>
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+
+my $exempt_sub = sub {
+  my $tax_rate = shift;
+
+  my @exempt = ();
+  push @exempt,
+       sprintf("$money_char%.2f&nbsp;per&nbsp;month", $tax_rate->exempt_amount )
+    if $tax_rate->exempt_amount > 0;
+
+  push @exempt, 'Setup&nbsp;fee'
+    if $tax_rate->setuptax =~ /^Y$/i;
+
+  push @exempt, 'Recurring&nbsp;fee'
+    if $tax_rate->recurtax =~ /^Y$/i;
+
+  [ map [ {'data'=>$_} ], @exempt ];
+};
+
+my $oldrow;
+my $cell_style;
+my $cell_style_sub = sub {
+  my $row = shift;
+  if ( $oldrow ne $row ) {
+    if ( $oldrow ) {
+      if ( $oldrow->country ne $row->country ) {
+        $cell_style = 'border-top:1px solid #000000';
+      } elsif ( $oldrow->state ne $row->state ) {
+        $cell_style = 'border-top:1px solid #cccccc'; #default?
+      } elsif ( $oldrow->state eq $row->state ) {
+        #$cell_style = 'border-top:dashed 1px dark gray';
+        $cell_style = 'border-top:1px dashed #cccccc';
+      }
+    }
+    $oldrow = $row;
+  }
+  return $cell_style;
+};
+
+my $select_link = [ 'javascript:void(0);', sub { ''; } ];
+
+my $select_onclick = sub {
+  my $row = shift;
+  my $taxnum = $row->taxnum;
+  my $color = '#333399';
+  qq!overlib( OLiframeContent('${p}edit/tax_rate.html?$taxnum', 540, 420, 'edit_tax_rate_popup' ), CAPTION, 'Edit tax rate', STICKY, AUTOSTATUSCAP, MIDX, 0, MIDY, 0, DRAGGABLE, CLOSECLICK, BGCOLOR, '$color', CGCOLOR, '$color' ); return false;!;
+};
+
+my $separate_taxclasses_link  = sub {
+  my( $row ) = @_;
+  my $taxnum = $row->taxnum;
+  my $url = "${p}edit/process/tax_rate-expand.cgi?taxclassnum=1;taxnum=$taxnum";
+
+  qq!<FONT SIZE="-1"><A HREF="$url">!;
+};
+
+</%once>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my @menubar;
+
+my $html_init =
+  "Click on <u>geocodes</u> to specify rates for a new area.";
+$html_init .= "<BR>Click on <u>separate taxclasses</u> to specify taxes per taxclass.";
+$html_init .= '<BR><BR>';
+
+$html_init .= qq(
+  <SCRIPT TYPE="text/javascript" SRC="${fsurl}elements/overlibmws.js"></SCRIPT>
+  <SCRIPT TYPE="text/javascript" SRC="${fsurl}elements/overlibmws_iframe.js"></SCRIPT>
+  <SCRIPT TYPE="text/javascript" SRC="${fsurl}elements/overlibmws_draggable.js"></SCRIPT>
+  <SCRIPT TYPE="text/javascript" SRC="${fsurl}elements/iframecontentmws.js"></SCRIPT>
+);
+
+my $title = '';
+my $select_word = 'edit';
+
+my $geocode = '';
+if ( $cgi->param('geocode') =~ /^(\w+)$/ ) {
+  $geocode = $1;
+  $title = "$geocode";
+}
+$cgi->delete('geocode');
+
+$title = " for $title" if $title;
+
+my $taxclassnum = '';
+if ( $cgi->param('taxclassnum') =~ /^(\d+)$/ ) {
+  $taxclassnum = $1;
+  my $tax_class = qsearchs('tax_class', {'taxclassnum' => $taxclassnum});
+  if ($tax_class) {
+    $title .= " for ". $tax_class->taxclass.
+              " (".  $tax_class->description. ") tax class";
+  }else{
+    $taxclassnum = '';
+  }
+}
+$cgi->delete('taxclassnum');
+
+if ( $geocode || $taxclassnum ) {
+  push @menubar, 'View all tax rates' => $p.'browse/tax_rate.cgi';
+}
+
+$cgi->param('dummy', 1);
+
+#restore this so pagination works
+$cgi->param('geocode',  $geocode) if $geocode;
+$cgi->param('taxclassnum', $taxclassnum ) if $taxclassnum;
+
+my $hashref = {};
+my $count_query = 'SELECT COUNT(*) FROM tax_rate';
+if ( $geocode ) {
+  $hashref->{'geocode'} = $geocode;
+  $count_query .= ' WHERE geocode = '. dbh->quote($geocode);
+}
+if ( $taxclassnum ) {
+  $hashref->{'taxclassnum'} = $taxclassnum;
+  $count_query .= ( $count_query =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
+                  ' taxclassnum  = '. dbh->quote($taxclassnum);
+}
+
+
+$cell_style = '';
+
+my @header        = ( 'Location Code',  );
+my @header2       = ( '', );
+my @links         = ( '', );
+my @link_onclicks = ( '', );
+my $align = 'l';
+
+my @fields = (
+  'geocode',
+);
+
+my @color = (
+  '000000',
+);
+
+push @header, qq!Tax class (<A HREF="${p}edit/tax_class.html">add new</A>)!;
+push @header2, '(per-tax classification)';
+push @fields, sub { $_[0]->taxclass_description || '(all)&nbsp'.
+                     &{$separate_taxclasses_link}($_[0], 'Separate Taxclasses').
+                     'separate&nbsp;taxclasses</A></FONT>'
+                  };
+push @color, sub { shift->taxclass ? '000000' : '999999' };
+push @links, '';
+push @link_onclicks, '';
+$align .= 'l';
+
+push @header, 'Tax name',
+              'Rate', #'Tax',
+              'Exemptions',
+              ;
+
+push @header2, '(printed on invoices)',
+               '',
+               '',
+               ;
+
+push @fields, 
+  sub { shift->taxname || 'Tax' },
+  sub { shift->tax. '%&nbsp;<FONT SIZE="-1">('. $select_word. ')</FONT>' },
+  $exempt_sub,
+;
+
+push @color,
+  sub { shift->taxname ? '000000' : '666666' },
+  sub { shift->tax     ? '000000' : '666666' },
+  '000000',
+;
+
+$align .= 'lrl';
+
+my @cell_style = map $cell_style_sub, (1..scalar(@header));
+
+push @links,         '', $select_link,    '';
+push @link_onclicks, '', $select_onclick, '';
+
+</%init>
index b1851a7..84f7498 100755 (executable)
@@ -3,6 +3,11 @@
 )) %>
 % #), ' onLoad="visualize()"'); 
 
+<SCRIPT TYPE="text/javascript" SRC="<%$fsurl%>elements/overlibmws.js"></SCRIPT>
+<SCRIPT TYPE="text/javascript" SRC="<%$fsurl%>elements/overlibmws_iframe.js"></SCRIPT>
+<SCRIPT TYPE="text/javascript" SRC="<%$fsurl%>elements/overlibmws_draggable.js"></SCRIPT>
+<SCRIPT TYPE="text/javascript" SRC="<%$fsurl%>elements/iframecontentmws.js"></SCRIPT>
+
 <% include('/elements/error.html') %>
 
 <FORM NAME="dummy">
@@ -81,6 +86,34 @@ Tax information
 
 % } 
 
+% if ( $conf->exists('enable_taxproducts') ) { 
+
+  <TR><TD colspan="2">
+    <% ntable("#cccccc", 2) %>
+      <TR>
+        <TD align="right">Tax product</TD>
+        <TD>
+          <INPUT name="part_pkg_taxproduct_taxproductnum" id="taxproductnum" type="hidden" value="<% $hashref->{'taxproductnum'}%>">
+          <INPUT name="part_pkg_taxproduct_description" id="taxproduct_description" type="text" value="<% $taxproduct_description %>" size="12" onclick="overlib( OLiframeContent('part_pkg_taxproduct.html?'+document.getElementById('taxproductnum').value, 800, 400, 'tax_product_popup'), CAPTION, 'Select product', STICKY, AUTOSTATUSCAP, MIDX, 0, MIDY, 0, DRAGGABLE, CLOSECLICK); return false;">
+        </TD>
+      </TR>
+      <TR>
+        <TD colspan="2" align="right">
+          <INPUT name="tax_override" id="tax_override" type="hidden" value="<% $tax_override %>">
+          <A href="javascript:void(0)" onclick="overlib( OLiframeContent('part_pkg_taxoverride.html?'+document.getElementById('tax_override').value, 800, 400, 'tax_product_popup'), CAPTION, 'Edit product tax overrides', STICKY, AUTOSTATUSCAP, MIDX, 0, MIDY, 0, DRAGGABLE, CLOSECLICK); return false;">
+            <% $tax_override ? 'Edit tax overrides' : 'Override taxes' %>
+          </A>
+        </TD>
+      </TR>
+    </TABLE>
+  </TD></TR>
+
+% } else { 
+
+  <INPUT TYPE="hidden" NAME="taxproductnum" VALUE="<% $hashref->{taxproductnum} %>">
+
+% } 
+
 </TABLE>
 <BR>
 
@@ -234,7 +267,7 @@ Line-item revenue recognition
 %#} else {
 %#  push @fixups, 'taxclass'; #hidden
 %#}
-%my @form_elements = ( 'classnum', 'taxclass', 'agent_type' );
+%my @form_elements = ( 'classnum', 'taxclass', 'agent_type', 'tax_override' );
 %
 %my @form_radio = ( 'pkg_svc_primary' );
 %
@@ -252,6 +285,7 @@ Line-item revenue recognition
 %  'form_elements'  => \@form_elements,
 %  'form_text'      => [ qw(pkg comment promo_code clone pkgnum pkgpart),
 %                        qw(pay_weight credit_weight), #keys(%weight),
+%                        qw(taxproductnum),
 %                        @fixups,
 %                      ],
 %  'form_checkbox'  => [ qw(setuptax recurtax disabled) ],
@@ -409,12 +443,14 @@ my ($query) = $cgi->keywords;
 my $conf = new FS::Conf; 
 my $part_pkg = '';
 my @agent_type = ();
+my $tax_override;
 my @all_agent_types = map {$_->typenum} qsearch('agent_type',{});
 if ( $cgi->param('error') ) {
   $part_pkg = new FS::part_pkg ( {
     map { $_, scalar($cgi->param($_)) } fields('part_pkg')
   } );
   (@agent_type) = $cgi->param('agent_type');
+  $tax_override = $cgi->param('tax_override');
 }
 
 my $action = '';
@@ -430,6 +466,9 @@ if ( $cgi->param('clone') ) {
 } elsif ( $query && $query =~ /^(\d+)$/ ) {
   (@agent_type) = map {$_->typenum} qsearch('type_pkgs',{'pkgpart'=>$1})
     unless $part_pkg;
+  $tax_override =
+    join (",", map {$_->taxnum} qsearch('part_pkg_taxoverride',{'pkgpart'=>$1}))
+    unless $part_pkg;
   $part_pkg ||= qsearchs('part_pkg',{'pkgpart'=>$1});
   $pkgpart = $part_pkg->pkgpart;
 } else {
@@ -447,5 +486,6 @@ unless ( $part_pkg->plan ) { #backwards-compat
 }
 $action ||= $part_pkg->pkgpart ? 'Edit' : 'Add';
 my $hashref = $part_pkg->hashref;
+my $taxproduct_description = $part_pkg->taxproduct_description;
 
 </%init>
diff --git a/httemplate/edit/part_pkg_taxoverride.html b/httemplate/edit/part_pkg_taxoverride.html
new file mode 100644 (file)
index 0000000..61cca1f
--- /dev/null
@@ -0,0 +1,61 @@
+<%doc>
+
+  The crappy version
+  
+</%doc>
+<% include('/elements/header-popup.html', 'Select tax product') %>
+<SCRIPT>
+  function saveit2() {
+    var num = parent.document.getElementById('tax_override');
+    var sel = document.getElementById('taxoverride_popup_select');
+    var value = '';
+    for (i=0; i< sel.length; i++) {
+      if (sel.options[i].selected) {
+        value = value + sel.options[i].value + ",";
+      }
+    }
+    if (value.length > 0) {
+      value = value.substr(0, value.length-1);
+    }
+      
+    num.value = value;
+    parent.cClick();
+  }
+</SCRIPT>
+<FORM="dummy" METHOD="POST" onsubmit="saveit2();return false;" >
+
+<% ntable("#cccccc", 2) %>
+<TR>
+  <TD align="left">Tax override</TD>
+  <TD>
+    <% include( '/elements/select-table.html',
+                'table'       => 'tax_rate',
+                'name_col'    => 'taxname',
+                'curr_value'  => \@curr_value,
+                'element_etc' => "id='taxoverride_popup_select'",
+                'multiple'    => '1',
+              )
+    %>
+  </TD>
+</TR>
+</TABLE>
+<BR><BR>
+<CENTER><INPUT type="submit" value="Select"></CENTER>
+</FORM>
+<% include('/elements/footer.html') %>
+
+<%init>
+
+my $conf = new FS::Conf;
+
+my @curr_value;
+my ( $query ) = $cgi->keywords;
+$query =~ /^([\d,]+)$/;
+push @curr_value, split ',', $1
+  if $1;
+
+unless (scalar(@curr_value)) {
+  #push @curr_value, map {$_=>taxnum} $part_pkg->tax_rate;
+}
+
+</%init>
diff --git a/httemplate/edit/part_pkg_taxproduct.html b/httemplate/edit/part_pkg_taxproduct.html
new file mode 100644 (file)
index 0000000..033c37f
--- /dev/null
@@ -0,0 +1,42 @@
+<% include('/elements/header-popup.html', 'Select tax product') %>
+<SCRIPT>
+  function saveit() {
+    var num = parent.document.getElementById('taxproductnum');
+    var disp = parent.document.getElementById('taxproduct_description');
+    var sel = document.getElementById('taxproduct_popup_select');
+    num.value = sel.options[sel.selectedIndex].value;
+    disp.value = sel.options[sel.selectedIndex].text;
+    parent.cClick();
+  }
+</SCRIPT>
+<FORM="dummy" METHOD="POST" onsubmit="saveit();return false;" >
+
+<% ntable("#cccccc", 2) %>
+<TR>
+  <TD align="left">Tax product</TD>
+  <TD>
+    <% include( '/elements/select-table.html',
+                'empty_label' => '(select product)',
+                'table'       => 'part_pkg_taxproduct',
+                'name_col'    => 'description',
+                'curr_value'  => $curr_value,
+                'element_etc' => "id='taxproduct_popup_select'",
+              )
+    %>
+  </TD>
+</TR>
+</TABLE>
+<BR><BR>
+<CENTER><INPUT type="submit" value="Select"></CENTER>
+</FORM>
+<% include('/elements/footer.html') %>
+
+<%init>
+
+my $conf = new FS::Conf;
+
+my ( $query ) = $cgi->keywords;
+$query =~ /^(\d+)$/;
+my $curr_value = $1;
+
+</%init>
index 36debfc..eac20af 100755 (executable)
@@ -53,6 +53,9 @@ $error = "At least one agent type must be specified."
           !$pkgpart && $conf->exists('agent-defaultpkg')
         );
 
+$cgi->param('tax_override') =~ /^([\d,]+)$/;
+my (@tax_overrides) = (grep "$_", split (",", $1));
+
 my $new = new FS::part_pkg ( {
   map {
     $_ => scalar($cgi->param($_));
@@ -103,11 +106,19 @@ if ( $error ) {
 }
 
 unless ( $error || $conf->exists('agent_defaultpkg') ) {
-  my $error = $new->process_m2m(
+  $error = $new->process_m2m(
     'link_table'   => 'type_pkgs',
     'target_table' => 'agent_type',
     'params'       => \@agents,
   );
 }
 
+unless ( $error  ) {
+  $error = $new->process_m2m(
+    'link_table'   => 'part_pkg_taxoverride',
+    'target_table' => 'tax_rate',
+    'params'       => \@tax_overrides,
+  );
+}
+
 </%init>
diff --git a/httemplate/edit/process/tax_class.html b/httemplate/edit/process/tax_class.html
new file mode 100644 (file)
index 0000000..339c908
--- /dev/null
@@ -0,0 +1,49 @@
+% if ( $error ) {
+%  $cgi->param('error', $error);
+<% $cgi->redirect(popurl(2). "tax_class.html?". $cgi->query_string ) %>
+%} else {
+<% $cgi->redirect(popurl(3). "browse/tax_rate.cgi?taxclassnum=". uri_escape($tax_class->taxclassnum) ) %>
+%}
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $tax_class = new FS::tax_class {
+  'taxclass' => $cgi->param('taxclass'),
+  'description' => $cgi->param('description'),
+};
+
+#maybe this whole thing should be in a transaction.  at some point, no biggie
+#none of the follow-up stuff will fail unless there's a more serious problem
+#than a hanging record in tax_class...
+
+my $error = $tax_class->insert;
+
+# all of this is highly dubious at the moment
+
+#unless ( $error ) {
+#  #auto-add the new taxclass to any regions that have taxclasses already
+#
+#  my $sth = dbh->prepare("
+#    SELECT geocode FROM tax_rate
+#      WHERE taxclass IS NOT NULL AND taxclass != ''
+#      GROUP BY geocode
+#  ") or die dbh->errstr;
+#  $sth->execute or die $sth->errstr;
+#
+#  while ( my $row = $sth->fetchrow_hashref ) {
+#    warn "inserting for $row";
+#    my $cust_main_county = new FS::tax_rate {
+#      'geocode'     => $row->{geocode},
+#      'tax'         => 0,
+#      'taxclassnum' => $tax_class->taxclassnum,
+#    };
+#    $error = $cust_main_county->insert;
+#    #last if $error;
+#    die $error if $error;
+#  }
+#
+#}
+
+</%init>
diff --git a/httemplate/edit/process/tax_rate.html b/httemplate/edit/process/tax_rate.html
new file mode 100644 (file)
index 0000000..933bf07
--- /dev/null
@@ -0,0 +1,13 @@
+<% include( 'elements/process.html',
+              'table' => 'tax_rate',
+              'popup_reload' => 'Tax changed', #a popup "parent reload" for now
+              #someday change the individual element and go away instead
+          )
+%>
+<%init>
+
+my $conf = new FS::Conf;
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+</%init>
diff --git a/httemplate/edit/tax_class.html b/httemplate/edit/tax_class.html
new file mode 100644 (file)
index 0000000..d3e2e82
--- /dev/null
@@ -0,0 +1,36 @@
+<% include('/elements/header.html', "$action taxclass") %>
+
+<% include('/elements/error.html') %>
+
+<FORM ACTION="<% $p1 %>process/tax_class.html" METHOD=POST>
+
+<INPUT TYPE="hidden" NAME="taxclassnum" VALUE="">
+<INPUT TYPE="hidden" NAME="data_vendor" VALUE="">
+
+Tax class <INPUT TYPE="text" NAME="taxclass" VALUE="<% $taxclass |h %>"><BR>
+Description <INPUT TYPE="text" NAME="description" VALUE="<% $description |h %>">
+
+<BR><BR>
+<INPUT TYPE="submit" VALUE="<% $action %> taxclass">
+
+</FORM>
+
+<% include('/elements/footer.html') %>
+
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $taxclass = '';
+my $description = '';
+if ( $cgi->param('error') ) {
+  $taxclass = $cgi->param('taxclass');
+  $description = $cgi->param('description');
+}
+
+my $action = 'Add';
+
+my $p1 = popurl(1);
+
+</%init>
diff --git a/httemplate/edit/tax_rate.html b/httemplate/edit/tax_rate.html
new file mode 100644 (file)
index 0000000..e1d8d4f
--- /dev/null
@@ -0,0 +1,105 @@
+<% include('elements/edit.html',
+     'popup'  => 1,
+     'name'   => 'Tax rate', #Edit tax rate
+     'table'  => 'tax_rate',
+     'labels' => $labels,
+     'fields' => \@fields,
+   )
+%>
+<%once>
+
+my $conf = new FS::Conf;
+
+</%once>
+
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $taxnum;
+if ( $cgi->param('error') ) {
+  $cgi->param('taxnum') =~ /^(\d+)$/ or die 'error, but no taxnum';
+  $taxnum = $1;
+} else {
+  my($query) = $cgi->keywords;
+  $query =~ /^(\d+)$/ or die 'no taxnum';
+  $taxnum = $1;
+}
+
+my $tax_rate = qsearchs('tax_rate', { 'taxnum' => $taxnum })
+  or die "unknown taxnum $1";
+
+my $labels =  { 'taxnum'               => 'Tax',
+                'data_vendor'          => 'Data vendor',
+                'geocode'              => 'Vendor location code',
+                'location'             => 'Tax auth loc code',
+                'taxclass_description' => 'Tax class',
+                'taxname'              => 'Tax name',
+                'effective_date'       => 'Effective date',
+                'tax'                  => 'Tax rate (1st bracket)',
+                'excessrate'           => 'Tax rate (2nd bracket)',
+                'taxbase'              => 'First bracket',
+                'taxmax'               => 'Max tax',
+                'usetax'               => 'Use tax rate (1st bracket)',
+                'useexcessrate'        => 'Use tax rate (2nd bracket)',
+                'unittype_name'        => 'Units',
+                'fee'                  => 'Fee per unit (1st bracket)',
+                'excessfee'            => 'Fee per unit (2st bracket)',
+                'feebase'              => 'Units in first bracket',
+                'feemax'               => 'Max Units',
+                'maxtype_name'         => 'Threshold accumulation',
+                'taxauth_name',        => 'Tax authority',
+                'basetype_name'        => 'Basis',
+                'passtype_name'        => 'Passthru',
+                'passflag'             => 'Passable',
+                'setuptax'             => 'This tax not applicable to setup fees',
+                'recurtax'             => 'This tax not applicable to recurring fees',
+              };
+
+my @fields = (
+    { type=>'tablebreak-tr-title',   value=>'Location' },
+    { field=>'data_vendor',          type=>'hidden',},
+    { field=>'geocode',              type=>'fixed' },
+    { field=>'taxclassnum',          type=>'hidden' } ,
+    { field=>'taxclass_description', type=>'fixed' } ,
+    { field=>'taxname',              type=>'text' } ,
+    { field=>'effective_date',       type=>'fixed' } ,
+    { field=>'location',             type=>'text' },
+    { type=>'tablebreak-tr-title',   value=>'Money based rates' },
+    { field=>'tax',                  type=>'percentage' } ,
+    { field=>'excessrate',           type=>'percentage' } ,
+    { field=>'taxbase',              type=>'money' } ,
+    { field=>'taxmax',               type=>'money' } ,
+    { field=>'usetax',               type=>'percentage' } ,
+    { field=>'useexcessrate',        type=>'percentage' } ,
+    { type=>'tablebreak-tr-title',   value=>'Service based rates' },
+    { field=>'unittype',             type=>'hidden' } ,
+    { field=>'unittype_name',        type=>'fixed' } ,
+    { field=>'fee',                  type=>'money' } ,
+    { field=>'excessfee',            type=>'money' } ,
+    { field=>'feebase',              type=>'text' } ,
+    { field=>'feemax',               type=>'text' } ,
+    { type=>'tablebreak-tr-title',   value=>'Taxation rules' },
+    { field=>'maxtype',              type=>'hidden' } ,
+    { field=>'maxtype_name',         type=>'fixed' } ,
+    { field=>'taxauth',              type=>'hidden' } ,
+    { field=>'taxauth_name',         type=>'fixed' } ,
+    { field=>'basetype',             type=>'hidden' } ,
+    { field=>'basetype_name',        type=>'fixed' } ,
+    { field=>'passtype',             type=>'hidden' } ,
+    { field=>'passtype_name',        type=>'fixed' } ,
+    { field=>'passflag',             type=>'fixed' } ,
+    { field=>'setuptax',             type=>'checkbox' } ,
+    { field=>'recurtax',             type=>'checkbox' } ,
+    { field=>'manual',               type=>'hidden', value=>'Y' } ,
+);
+
+#push @fields,
+#  { type=>'tablebreak-tr-title', value=>'Exemptions' },
+#  { field=>'setuptax', type=>'checkbox', value=>'Y', },
+#  { field=>'recurtax', type=>'checkbox', value=>'Y', },
+#  { field=>'exempt_amount', type=>'money', },
+#;
+
+</%init>
index 047671a..4e4d687 100644 (file)
@@ -217,6 +217,7 @@ $report_menu{'SQL Query'}  = [ $fsurl.'search/report_sql.html', 'SQL Query' ]
   if $curuser->access_right('Raw SQL');
 
 tie my %tools_importing, 'Tie::IxHash',
+  'Import tax rates from CSV files' => [ $fsurl.'misc/tax-import.cgi', '' ],
   'Import customers from CSV file' => [ $fsurl.'misc/cust_main-import.cgi', '' ],
   'Import payments from CSV file' => [ $fsurl.'misc/cust_pay-import.cgi', '' ],
 
diff --git a/httemplate/misc/process/recharge_svc.new b/httemplate/misc/process/recharge_svc.new
new file mode 100755 (executable)
index 0000000..bc916e5
--- /dev/null
@@ -0,0 +1,85 @@
+%
+%
+%#untaint svcnum
+%my $svcnum = $cgi->param('svcnum');
+%$svcnum =~ /^(\d+)$/ || die "Illegal svcnum";
+%$svcnum = $1;
+%
+%#untaint prepaid
+%my $prepaid = $cgi->param('prepaid');
+%$prepaid =~ /^(\w*)$/;
+%$prepaid = $1;
+
+%#untaint payby
+%my $payby = $cgi->param('payby');
+%$payby =~ /^([A-Z]*)$/;
+%$payby = $1;
+%
+%my $error = '';
+%my $svc_acct = qsearchs( 'svc_acct', {'svcnum'=>$svcnum} );
+%$error = "Can't recharge service $svcnum. " unless $svc_acct;
+%
+%my $cust_main = $svc_acct->cust_svc->cust_pkg->cust_main;
+%
+%my $oldAutoCommit = $FS::UID::AutoCommit;
+%local $FS::UID::AutoCommit = 0;
+%my $dbh = dbh;
+%
+%
+%unless ($error) {
+%
+%  my ($amount, $seconds, $up, $down, $total) = (0, 0, 0, 0, 0);
+%  #should probably use payby.pm but whatever
+%  if ($payby eq 'PREP') {
+%    $error = $cust_main->get_prepay($prepaid, \$amount, \$seconds, \$up, \$down, \$total)
+%          || $svc_acct->increment_seconds($seconds)
+%          || $svc_acct->increment_upbytes($up)
+%          || $svc_acct->increment_downbytes($down)
+%          || $svc_acct->increment_totalbytes($total)
+%          || $cust_main->insert_cust_pay_prepay( $amount, $prepaid );
+%  } elsif ( $payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP)$/ ) {
+%    my $part_pkg = $svc_acct->cust_svc->cust_pkg->part_pkg;
+%    $amount = $part_pkg->option('recharge_amount', 1);
+%    my %rhash = map { $_ =~ /^recharge_(.*)$/; $1, $part_pkg->option($_, 1) }
+%      qw ( recharge_seconds recharge_upbytes recharge_downbytes
+%           recharge_totalbytes );
+%
+%    my $description = "Recharge";
+%    $description .= " $rhash{seconds}s" if $rhash{seconds};
+%    $description .= " $rhash{upbytes} up" if $rhash{upbytes};
+%    $description .= " $rhash{downbytes} down" if $rhash{downbytes};
+%    $description .= " $rhash{totalbytes} total" if $rhash{totalbytes};
+%
+%    $error = $cust_main->charge($amount, "Recharge " . $svc_acct->label,
+%                                $description, $part_pkg->taxclass);
+%
+%    $error ||= $svc_acct->recharge(\%rhash);
+%
+%    my $old_balance = $cust_main->balance;
+%    $error ||= $cust_main->bill;
+%    $error ||= $cust_main->apply_payments_and_credits;
+%    my $bill_error = $cust_main->collect('realtime' => 1) unless $error;
+%    $error ||= "Failed to collect - $bill_error"
+%      if $cust_main->balance > $old_balance && $cust_main->balance > 0
+%          && $payby ne 'BILL';
+%
+%  } else {
+%    $error = "fatal error - unknown payby: $payby";
+%  }
+%}
+%
+%if ($error) {
+%  $cgi->param('error', $error);
+%  $dbh->rollback if $oldAutoCommit;
+%  print $cgi->redirect(popurl(2). "recharge_svc.html?". $cgi->query_string );
+%}
+%$dbh->commit or die $dbh->errstr if $oldAutoCommit;
+%
+<% header("Package recharged") %>
+  <SCRIPT TYPE="text/javascript">
+    window.top.location.reload();
+  </SCRIPT>
+  </BODY></HTML>
+<%init>
+my $conf = new FS::Conf;
+</%init>
diff --git a/httemplate/misc/process/tax-import.cgi b/httemplate/misc/process/tax-import.cgi
new file mode 100644 (file)
index 0000000..77fba61
--- /dev/null
@@ -0,0 +1,58 @@
+% if ( $error ) {
+%   warn $error;
+%   errorpage($error);
+%  } else {
+    <% include('/elements/header.html','Import successful') %> 
+    <% include('/elements/footer.html') %> 
+%  }
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Import');
+
+my $cfh = $cgi->upload('codefile');
+my $zfh = $cgi->upload('plus4file');
+my $tfh = $cgi->upload('txmatrix');
+my $dfh = $cgi->upload('detail');
+#warn $cgi;
+#warn $fh;
+
+my $oldAutoCommit = $FS::UID::AutoCommit;
+local $FS::UID::AutoCommit = 0;
+my $dbh = dbh;
+
+my $error = defined($cfh)
+  ? FS::tax_class::batch_import( {
+      filehandle => $cfh,
+      'format'   => scalar($cgi->param('format')),
+    } )
+  : 'No code file';
+
+$error ||= defined($zfh)
+  ? FS::cust_tax_location::batch_import( {
+      filehandle => $zfh,
+      'format'   => scalar($cgi->param('format')),
+    } )
+  : 'No plus4 file';
+
+$error ||= defined($tfh)
+  ? FS::part_pkg_taxrate::batch_import( {
+      filehandle => $tfh,
+      'format'   => scalar($cgi->param('format')),
+    } )
+  : 'No tax matrix file';
+
+$error ||= defined($dfh)
+  ? FS::tax_rate::batch_import( {
+      filehandle => $dfh,
+      'format'   => scalar($cgi->param('format')),
+    } )
+  : 'No tax detail file';
+
+if ($error) {
+  $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+}else{
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+}
+
+</%init>
diff --git a/httemplate/misc/tax-import.cgi b/httemplate/misc/tax-import.cgi
new file mode 100644 (file)
index 0000000..1f60dbe
--- /dev/null
@@ -0,0 +1,91 @@
+<% include("/elements/header.html",'Batch Tax Rate Import') %>
+
+Import a CSV file set containing tax rate records.
+<BR><BR>
+
+<FORM ACTION="process/tax-import.cgi" METHOD="post" ENCTYPE="multipart/form-data">
+
+<% &ntable("#cccccc", 2) %>
+
+<TR>
+  <TH ALIGN="right">Format</TH>
+  <TD>
+    <SELECT NAME="format">
+      <OPTION VALUE="cch">CCH
+<!--      <OPTION VALUE="extended" SELECTED>Extended
+      <OPTION VALUE="extended-plus_company">Extended plus company -->
+    </SELECT>
+  </TD>
+</TR>
+
+<TR>
+  <TH ALIGN="right">code CSV filename</TH>
+  <TD><INPUT TYPE="file" NAME="codefile"></TD>
+</TR>
+
+<TR>
+  <TH ALIGN="right">plus4 CSV filename</TH>
+  <TD><INPUT TYPE="file" NAME="plus4file"></TD>
+</TR>
+
+<TR>
+  <TH ALIGN="right">txmatrix CSV filename</TH>
+  <TD><INPUT TYPE="file" NAME="txmatrix"></TD>
+</TR>
+
+<TR>
+  <TH ALIGN="right">detail CSV filename</TH>
+  <TD><INPUT TYPE="file" NAME="detail"></TD>
+</TR>
+
+
+<TR><TD COLSPAN=2 ALIGN="center" STYLE="padding-top:6px"><INPUT TYPE="submit" VALUE="Import CSV files"></TD></TR>
+
+</TABLE>
+
+</FORM>
+
+<BR>
+
+<!-- Simple file format is CSV, with the following field order: <i>cust_pkg.setup, dayphone, first, last, address1, address2, city, state, zip, comments</i>
+<BR><BR> -->
+
+<%$req%> Required fields
+<BR><BR>
+
+Field information:
+
+<ul>
+
+  <li><i>refnum</i>: Advertising source number - where a customer heard about your service.  Configuration -&gt; Miscellaneous -&gt; View/Edit advertising sources.  This field has special treatment upon import: If a string is passed instead
+of an integer, the string is searched for and if necessary auto-created in the
+advertising source table.
+
+  <li><i>payinfo</i>: Credit card number, or leave this, <i>paycvv</i> and <i>paydate</i> blank for email/paper invoicing.
+
+  <li><i>paycvv</i>: CVV2 number (three digits on the back of the credit card)
+
+  <li><i>paydate</i>: Credit card expiration date, MM/YYYY or MM/YY (M/YY and M/YYYY are also accepted).
+
+  <li><i>invoicing_list</i>: Email address for invoices, or POST for postal invoices.
+
+  <li><i>pkgpart</i>: Package definition.  Configuration -&gt; Provisioning, services and packages -&gt; View/Edit package definitions
+
+  <li><i>username</i> and <i>_password</i> are required if <i>pkgpart</i> is specified.
+</ul>
+
+<BR>
+
+<% include('/elements/footer.html') %>
+
+<%once>
+
+my $req = qq!<font color="#ff0000">*</font>!;
+
+</%once>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Import');
+
+</%init>