eliminate some false laziness in FS::Misc::send_email vs. msg_template/email.pm send_...
[freeside.git] / FS / FS / tax_class.pm
index 0a939ad..904b575 100644 (file)
@@ -4,6 +4,9 @@ use strict;
 use vars qw( @ISA );
 use FS::UID qw(dbh);
 use FS::Record qw( qsearch qsearchs );
+use FS::Misc qw( csv_from_fixed );
+use FS::part_pkg_taxrate;
+use FS::part_pkg_taxoverride;
 
 @ISA = qw(FS::Record);
 
@@ -28,26 +31,24 @@ FS::tax_class - Object methods for tax_class records
 
 =head1 DESCRIPTION
 
-An FS::tax_class object represents a tax class.  FS::tax_class
-inherits from FS::Record.  The following fields are currently supported:
+An FS::tax_class object represents a class of tax definitions.  FS::tax_class
+inherits from FS::Record.
 
-=over 4
-
-=item taxclassnum
+This should not be confused with L<FS::part_pkg_taxclass>, which defines tax
+classes for I<package> definitions.  The two kinds of tax classes are 
+completely unrelated.
 
-Primary key
+The following fields are currently supported:
 
-=item data_vendor
-
-Vendor of the tax data
+=over 4
 
-=item taxclass
+=item taxclassnum - Primary key
 
-Tax class
+=item data_vendor - Vendor of the tax data ('cch' or 'billsoft')
 
-=item description
+=item taxclass - The identifier used in the tax tables for this class.
 
-Human readable description of the tax class
+=item description -  Human readable description of the tax class.
 
 =back
 
@@ -79,6 +80,44 @@ Delete this record from the database.
 
 =cut
 
+sub delete {
+  my $self = shift;
+
+  #return "Can't delete a tax class which has package tax rates!"
+  #if qsearch( 'part_pkg_taxrate', { 'taxclassnumtaxed' => $self->taxclassnum    
+  # If this tax class is manually assigned to a package,
+  # then return a useful error message instead of just having a conniption.
+  my @overrides = qsearch( 'part_pkg_taxoverride', {
+                    'taxclassnum' => $self->taxclassnum
+                  } );
+  if (@overrides) {
+    return "Tried to delete tax class " . $self->taxclass .
+      ", which is assigned to package definition " .
+      join(', ', map { '#'.$_->pkgpart} @overrides) .
+      ".";
+  }
+
+  # part_pkg_taxrate.taxclass identifies taxes belonging to this taxclass.
+  # part_pkg_taxrate.taxclassnumtaxed identifies taxes applying to this 
+  # taxclass.
+  # If this taxclass goes away, remove all of them. (CCH upgrade CAN'T 
+  # remove them, because it removes the tax_class first and then doesn't 
+  # know what the taxclassnum was. Yeah, I know. So it will just skip 
+  # over them at the TXMATRIX stage.)
+  my @part_pkg_taxrate = (
+    qsearch('part_pkg_taxrate', { 'taxclassnum' => $self->taxclassnum }),
+    qsearch('part_pkg_taxrate', { 'taxclassnumtaxed' => $self->taxclassnum })
+  );
+  foreach (@part_pkg_taxrate) {
+    my $error = $_->delete;
+    return "when deleting taxclass ".$self->taxclass.": $error"
+      if $error;
+  }
+
+  $self->SUPER::delete(@_);
+
+}
+
 =item replace OLD_RECORD
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
@@ -116,7 +155,7 @@ an error, returns the error, otherwise returns false.
 =cut 
 
 sub batch_import {
-  my $param = shift;
+  my ($param, $job) = @_;
 
   my $fh = $param->{filehandle};
   my $format = $param->{'format'};
@@ -126,44 +165,167 @@ sub batch_import {
   my $endhook;
   my $data = {};
   my $imported = 0;
+  my $dbh = dbh;
 
-  if ( $format eq 'cch' ) {
-    @fields = qw( table name pos number length value description );
+  my @column_lengths = ();
+  my @column_callbacks = ();
+  if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
+    $format =~ s/-fixed//;
+    push @column_lengths, qw( 8 10 3 2 2 10 100 );
+    push @column_lengths, 1 if $format eq 'cch-update';
+  }
+
+  my $line;
+  my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
+  if ( $job || scalar(@column_lengths) ) {
+    my $error = csv_from_fixed(\$fh, \$count, \@column_lengths);
+    return $error if $error;
+  }
+
+  if ( $format eq 'cch' || $format eq 'cch-update' ) {
+    @fields = qw( table name pos length number value description );
+    push @fields, 'actionflag' if $format eq 'cch-update';
 
     $hook = sub { 
       my $hash = shift;
 
       if ($hash->{'table'} eq 'DETAIL') {
         push @{$data->{'taxcat'}}, [ $hash->{'value'}, $hash->{'description'} ]
-          if $hash->{'name'} eq 'TAXCAT';
+          if ($hash->{'name'} eq 'TAXCAT' &&
+             (!exists($hash->{actionflag}) || $hash->{actionflag} eq 'I') );
 
         push @{$data->{'taxtype'}}, [ $hash->{'value'}, $hash->{'description'} ]
-          if $hash->{'name'} eq 'TAXTYPE';
+          if ($hash->{'name'} eq 'TAXTYPE' &&
+             (!exists($hash->{actionflag}) || $hash->{actionflag} eq 'I') );
+
+        if (exists($hash->{actionflag}) && $hash->{actionflag} eq 'D') {
+          my $name = $hash->{'name'};
+          my $value = $hash->{'value'};
+          return "Bad value for $name: $value"
+            unless $value =~ /^\d+$/;
+
+          if ($name eq 'TAXCAT' || $name eq 'TAXTYPE') {
+            my @tax_class = qsearch( 'tax_class',
+                                     { 'data_vendor' => 'cch' },
+                                     '',
+                                     "AND taxclass LIKE '".
+                                       ($name eq 'TAXTYPE' ? $value : '%').":".
+                                       ($name eq 'TAXCAT' ? $value : '%')."'",
+                                   );
+            foreach (@tax_class) {
+              my $error = $_->delete;
+              return $error if $error;
+            }
+          }
+        }
+
       }
 
       delete($hash->{$_})
-        for qw( data_vendor table name pos number length value description );
+        for qw( data_vendor table name pos length number value description );
+      delete($hash->{actionflag}) if exists($hash->{actionflag});
 
       '';
 
     };
 
     $endhook = sub { 
-      foreach my $type (@{$data->{'taxtype'}}) {
+
+      my $sql = "SELECT DISTINCT ".
+         "substring(taxclass from 1 for position(':' in taxclass)-1),".
+         "substring(description from 1 for position(':' in description)-1) ".
+         "FROM tax_class WHERE data_vendor='cch'";
+
+      my $sth = $dbh->prepare($sql) or die $dbh->errstr;
+      $sth->execute or die $sth->errstr;
+      my @old_types = @{$sth->fetchall_arrayref};
+
+      $sql = "SELECT DISTINCT ".
+         "substring(taxclass from position(':' in taxclass)+1),".
+         "substring(description from position(':' in description)+1) ".
+         "FROM tax_class WHERE data_vendor='cch'";
+
+      $sth = $dbh->prepare($sql) or die $dbh->errstr;
+      $sth->execute or die $sth->errstr;
+      my @old_cats = @{$sth->fetchall_arrayref};
+
+      my $catcount  = exists($data->{'taxcat'})  ? scalar(@{$data->{'taxcat'}})
+                                                 : 0;
+      my $typecount = exists($data->{'taxtype'}) ? scalar(@{$data->{'taxtype'}})
+                                                 : 0;
+
+      my $count = scalar(@old_types) * $catcount
+                + $typecount * (scalar(@old_cats) + $catcount);
+
+      $imported = 1 if $format eq 'cch-update';  #empty file ok
+
+      foreach my $type (@old_types) {
         foreach my $cat (@{$data->{'taxcat'}}) {
+
+          if ( $job ) {  # progress bar
+            if ( time - $min_sec > $last ) {
+              my $error = $job->update_statustext(
+                int( 100 * $imported / $count ). ",Importing tax classes"
+              );
+              die $error if $error;
+              $last = time;
+            }
+          }
+
+          my %hash = ( 'data_vendor' => 'cch',
+                       'taxclass'    => $type->[0].':'.$cat->[0],
+                       'description' => $type->[1].':'.$cat->[1],
+                     );
+          unless ( qsearchs('tax_class', \%hash) ) {
+            my $tax_class = new FS::tax_class \%hash;
+            my $error = $tax_class->insert;
+
+            return "can't insert tax_class for ".
+                   " old TAXTYPE ". $type->[0].':'.$type->[1].
+                   " and new TAXCAT ". $cat->[0].':'. $cat->[1].
+                   " : $error"
+              if $error;
+          }
+
+          $imported++;
+          
+        }
+      }
+
+      foreach my $type (@{$data->{'taxtype'}}) {
+        foreach my $cat (@old_cats, @{$data->{'taxcat'}}) {
+
+          if ( $job ) {  # progress bar
+            if ( time - $min_sec > $last ) {
+              my $error = $job->update_statustext(
+                int( 100 * $imported / $count ). ",Importing tax classes"
+              );
+              die $error if $error;
+              $last = time;
+            }
+          }
+
           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;
+          return "can't insert tax_class for new TAXTYPE $type and TAXCAT $cat: $error" if $error;
           $imported++;
         }
       }
+
       '';
     };
 
+  } elsif ( $format eq 'billsoft' ) {
+    # Billsoft doesn't actually have a format for this; it's just my own
+    # invention to have a way to load the list of tax classes from the 
+    # documentation.
+    @fields = qw( taxclass description );
+    $endhook = $hook = sub {};
+
   } elsif ( $format eq 'extended' ) {
     die "unimplemented\n";
     @fields = qw( );
@@ -186,10 +348,19 @@ sub batch_import {
 
   my $oldAutoCommit = $FS::UID::AutoCommit;
   local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
   
-  my $line;
   while ( defined($line=<$fh>) ) {
+
+    if ( $job ) {  # progress bar
+      if ( time - $min_sec > $last ) {
+        my $error = $job->update_statustext(
+          int( 100 * $imported / $count ). ",Importing tax classes"
+        );
+        die $error if $error;
+        $last = time;
+      }
+    }
+
     $csv->parse($line) or do {
       $dbh->rollback if $oldAutoCommit;
       return "can't parse: ". $csv->error_input();
@@ -201,16 +372,21 @@ sub batch_import {
     foreach my $field ( @fields ) {
       $tax_class{$field} = shift @columns; 
     }
+    if ( scalar( @columns ) ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Unexpected trailing columns in line (wrong format?) importing tax_class: $line";
+    }
+
     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";
@@ -222,18 +398,17 @@ sub batch_import {
   my $error = &{$endhook}();
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
-    return "can't insert tax_class for $line: $error";
+    return "can't run end hook: $error";
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
-  return "Empty file!" unless $imported;
+  return "Empty File!" unless ($imported || $format eq 'cch-update');
 
   ''; #no error
 
 }
 
-
 =back
 
 =head1 BUGS