Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / tax_class.pm
1 package FS::tax_class;
2
3 use strict;
4 use vars qw( @ISA );
5 use FS::UID qw(dbh);
6 use FS::Record qw( qsearch qsearchs );
7 use FS::Misc qw( csv_from_fixed );
8 use FS::part_pkg_taxrate;
9 use FS::part_pkg_taxoverride;
10
11 @ISA = qw(FS::Record);
12
13 =head1 NAME
14
15 FS::tax_class - Object methods for tax_class records
16
17 =head1 SYNOPSIS
18
19   use FS::tax_class;
20
21   $record = new FS::tax_class \%hash;
22   $record = new FS::tax_class { 'column' => 'value' };
23
24   $error = $record->insert;
25
26   $error = $new_record->replace($old_record);
27
28   $error = $record->delete;
29
30   $error = $record->check;
31
32 =head1 DESCRIPTION
33
34 An FS::tax_class object represents a class of tax definitions.  FS::tax_class
35 inherits from FS::Record.
36
37 This should not be confused with L<FS::part_pkg_taxclass>, which defines tax
38 classes for I<package> definitions.  The two kinds of tax classes are 
39 completely unrelated.
40
41 The following fields are currently supported:
42
43 =over 4
44
45 =item taxclassnum - Primary key
46
47 =item data_vendor - Vendor of the tax data ('cch' or 'billsoft')
48
49 =item taxclass - The identifier used in the tax tables for this class.
50
51 =item description -  Human readable description of the tax class.
52
53 =back
54
55 =head1 METHODS
56
57 =over 4
58
59 =item new HASHREF
60
61 Creates a new tax class.  To add the tax class to the database, see L<"insert">.
62
63 Note that this stores the hash reference, not a distinct copy of the hash it
64 points to.  You can ask the object for a copy with the I<hash> method.
65
66 =cut
67
68 sub table { 'tax_class'; }
69
70 =item insert
71
72 Adds this record to the database.  If there is an error, returns the error,
73 otherwise returns false.
74
75 =cut
76
77 =item delete
78
79 Delete this record from the database.
80
81 =cut
82
83 sub delete {
84   my $self = shift;
85
86   #return "Can't delete a tax class which has package tax rates!"
87   #if qsearch( 'part_pkg_taxrate', { 'taxclassnumtaxed' => $self->taxclassnum    
88   # If this tax class is manually assigned to a package,
89   # then return a useful error message instead of just having a conniption.
90   my @overrides = qsearch( 'part_pkg_taxoverride', {
91                     'taxclassnum' => $self->taxclassnum
92                   } );
93   if (@overrides) {
94     return "Tried to delete tax class " . $self->taxclass .
95       ", which is assigned to package definition " .
96       join(', ', map { '#'.$_->pkgpart} @overrides) .
97       ".";
98   }
99
100   # part_pkg_taxrate.taxclass identifies taxes belonging to this taxclass.
101   # part_pkg_taxrate.taxclassnumtaxed identifies taxes applying to this 
102   # taxclass.
103   # If this taxclass goes away, remove all of them. (CCH upgrade CAN'T 
104   # remove them, because it removes the tax_class first and then doesn't 
105   # know what the taxclassnum was. Yeah, I know. So it will just skip 
106   # over them at the TXMATRIX stage.)
107   my @part_pkg_taxrate = (
108     qsearch('part_pkg_taxrate', { 'taxclassnum' => $self->taxclassnum }),
109     qsearch('part_pkg_taxrate', { 'taxclassnumtaxed' => $self->taxclassnum })
110   );
111   foreach (@part_pkg_taxrate) {
112     my $error = $_->delete;
113     return "when deleting taxclass ".$self->taxclass.": $error"
114       if $error;
115   }
116
117   $self->SUPER::delete(@_);
118
119 }
120
121 =item replace OLD_RECORD
122
123 Replaces the OLD_RECORD with this one in the database.  If there is an error,
124 returns the error, otherwise returns false.
125
126 =cut
127
128 =item check
129
130 Checks all fields to make sure this is a valid tax class.  If there is
131 an error, returns the error, otherwise returns false.  Called by the insert
132 and replace methods.
133
134 =cut
135
136 sub check {
137   my $self = shift;
138
139   my $error = 
140     $self->ut_numbern('taxclassnum')
141     || $self->ut_text('taxclass')
142     || $self->ut_textn('data_vendor')
143     || $self->ut_textn('description')
144   ;
145   return $error if $error;
146
147   $self->SUPER::check;
148 }
149
150 =item batch_import
151
152 Loads part_pkg_taxrate records from an external CSV file.  If there is
153 an error, returns the error, otherwise returns false. 
154
155 =cut 
156
157 sub batch_import {
158   my ($param, $job) = @_;
159
160   my $fh = $param->{filehandle};
161   my $format = $param->{'format'};
162
163   my @fields;
164   my $hook;
165   my $endhook;
166   my $data = {};
167   my $imported = 0;
168   my $dbh = dbh;
169
170   my @column_lengths = ();
171   my @column_callbacks = ();
172   if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
173     $format =~ s/-fixed//;
174     push @column_lengths, qw( 8 10 3 2 2 10 100 );
175     push @column_lengths, 1 if $format eq 'cch-update';
176   }
177
178   my $line;
179   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
180   if ( $job || scalar(@column_lengths) ) {
181     my $error = csv_from_fixed(\$fh, \$count, \@column_lengths);
182     return $error if $error;
183   }
184
185   if ( $format eq 'cch' || $format eq 'cch-update' ) {
186     @fields = qw( table name pos length number value description );
187     push @fields, 'actionflag' if $format eq 'cch-update';
188
189     $hook = sub { 
190       my $hash = shift;
191
192       if ($hash->{'table'} eq 'DETAIL') {
193         push @{$data->{'taxcat'}}, [ $hash->{'value'}, $hash->{'description'} ]
194           if ($hash->{'name'} eq 'TAXCAT' &&
195              (!exists($hash->{actionflag}) || $hash->{actionflag} eq 'I') );
196
197         push @{$data->{'taxtype'}}, [ $hash->{'value'}, $hash->{'description'} ]
198           if ($hash->{'name'} eq 'TAXTYPE' &&
199              (!exists($hash->{actionflag}) || $hash->{actionflag} eq 'I') );
200
201         if (exists($hash->{actionflag}) && $hash->{actionflag} eq 'D') {
202           my $name = $hash->{'name'};
203           my $value = $hash->{'value'};
204           return "Bad value for $name: $value"
205             unless $value =~ /^\d+$/;
206
207           if ($name eq 'TAXCAT' || $name eq 'TAXTYPE') {
208             my @tax_class = qsearch( 'tax_class',
209                                      { 'data_vendor' => 'cch' },
210                                      '',
211                                      "AND taxclass LIKE '".
212                                        ($name eq 'TAXTYPE' ? $value : '%').":".
213                                        ($name eq 'TAXCAT' ? $value : '%')."'",
214                                    );
215             foreach (@tax_class) {
216               my $error = $_->delete;
217               return $error if $error;
218             }
219           }
220         }
221
222       }
223
224       delete($hash->{$_})
225         for qw( data_vendor table name pos length number value description );
226       delete($hash->{actionflag}) if exists($hash->{actionflag});
227
228       '';
229
230     };
231
232     $endhook = sub { 
233
234       my $sql = "SELECT DISTINCT ".
235          "substring(taxclass from 1 for position(':' in taxclass)-1),".
236          "substring(description from 1 for position(':' in description)-1) ".
237          "FROM tax_class WHERE data_vendor='cch'";
238
239       my $sth = $dbh->prepare($sql) or die $dbh->errstr;
240       $sth->execute or die $sth->errstr;
241       my @old_types = @{$sth->fetchall_arrayref};
242
243       $sql = "SELECT DISTINCT ".
244          "substring(taxclass from position(':' in taxclass)+1),".
245          "substring(description from position(':' in description)+1) ".
246          "FROM tax_class WHERE data_vendor='cch'";
247
248       $sth = $dbh->prepare($sql) or die $dbh->errstr;
249       $sth->execute or die $sth->errstr;
250       my @old_cats = @{$sth->fetchall_arrayref};
251
252       my $catcount  = exists($data->{'taxcat'})  ? scalar(@{$data->{'taxcat'}})
253                                                  : 0;
254       my $typecount = exists($data->{'taxtype'}) ? scalar(@{$data->{'taxtype'}})
255                                                  : 0;
256
257       my $count = scalar(@old_types) * $catcount
258                 + $typecount * (scalar(@old_cats) + $catcount);
259
260       $imported = 1 if $format eq 'cch-update';  #empty file ok
261
262       foreach my $type (@old_types) {
263         foreach my $cat (@{$data->{'taxcat'}}) {
264
265           if ( $job ) {  # progress bar
266             if ( time - $min_sec > $last ) {
267               my $error = $job->update_statustext(
268                 int( 100 * $imported / $count ). ",Importing tax classes"
269               );
270               die $error if $error;
271               $last = time;
272             }
273           }
274
275           my %hash = ( 'data_vendor' => 'cch',
276                        'taxclass'    => $type->[0].':'.$cat->[0],
277                        'description' => $type->[1].':'.$cat->[1],
278                      );
279           unless ( qsearchs('tax_class', \%hash) ) {
280             my $tax_class = new FS::tax_class \%hash;
281             my $error = $tax_class->insert;
282
283             return "can't insert tax_class for ".
284                    " old TAXTYPE ". $type->[0].':'.$type->[1].
285                    " and new TAXCAT ". $cat->[0].':'. $cat->[1].
286                    " : $error"
287               if $error;
288           }
289
290           $imported++;
291           
292         }
293       }
294
295       foreach my $type (@{$data->{'taxtype'}}) {
296         foreach my $cat (@old_cats, @{$data->{'taxcat'}}) {
297
298           if ( $job ) {  # progress bar
299             if ( time - $min_sec > $last ) {
300               my $error = $job->update_statustext(
301                 int( 100 * $imported / $count ). ",Importing tax classes"
302               );
303               die $error if $error;
304               $last = time;
305             }
306           }
307
308           my $tax_class =
309             new FS::tax_class( { 'data_vendor' => 'cch',
310                                  'taxclass'    => $type->[0].':'.$cat->[0],
311                                  'description' => $type->[1].':'.$cat->[1],
312                              } );
313           my $error = $tax_class->insert;
314           return "can't insert tax_class for new TAXTYPE $type and TAXCAT $cat: $error" if $error;
315           $imported++;
316         }
317       }
318
319       '';
320     };
321
322   } elsif ( $format eq 'billsoft' ) {
323     # Billsoft doesn't actually have a format for this; it's just my own
324     # invention to have a way to load the list of tax classes from the 
325     # documentation.
326     @fields = qw( taxclass description );
327     $endhook = $hook = sub {};
328
329   } elsif ( $format eq 'extended' ) {
330     die "unimplemented\n";
331     @fields = qw( );
332     $hook = sub {};
333   } else {
334     die "unknown format $format";
335   }
336
337   eval "use Text::CSV_XS;";
338   die $@ if $@;
339
340   my $csv = new Text::CSV_XS;
341
342   local $SIG{HUP} = 'IGNORE';
343   local $SIG{INT} = 'IGNORE';
344   local $SIG{QUIT} = 'IGNORE';
345   local $SIG{TERM} = 'IGNORE';
346   local $SIG{TSTP} = 'IGNORE';
347   local $SIG{PIPE} = 'IGNORE';
348
349   my $oldAutoCommit = $FS::UID::AutoCommit;
350   local $FS::UID::AutoCommit = 0;
351   
352   while ( defined($line=<$fh>) ) {
353
354     if ( $job ) {  # progress bar
355       if ( time - $min_sec > $last ) {
356         my $error = $job->update_statustext(
357           int( 100 * $imported / $count ). ",Importing tax classes"
358         );
359         die $error if $error;
360         $last = time;
361       }
362     }
363
364     $csv->parse($line) or do {
365       $dbh->rollback if $oldAutoCommit;
366       return "can't parse: ". $csv->error_input();
367     };
368
369     my @columns = $csv->fields();
370
371     my %tax_class = ( 'data_vendor' => $format );
372     foreach my $field ( @fields ) {
373       $tax_class{$field} = shift @columns; 
374     }
375     if ( scalar( @columns ) ) {
376       $dbh->rollback if $oldAutoCommit;
377       return "Unexpected trailing columns in line (wrong format?) importing tax_class: $line";
378     }
379
380     my $error = &{$hook}(\%tax_class);
381     if ( $error ) {
382       $dbh->rollback if $oldAutoCommit;
383       return $error;
384     }
385
386     next unless scalar(keys %tax_class);
387
388     my $tax_class = new FS::tax_class( \%tax_class );
389     $error = $tax_class->insert;
390     if ( $error ) {
391       $dbh->rollback if $oldAutoCommit;
392       return "can't insert tax_class for $line: $error";
393     }
394
395     $imported++;
396   }
397
398   my $error = &{$endhook}();
399   if ( $error ) {
400     $dbh->rollback if $oldAutoCommit;
401     return "can't run end hook: $error";
402   }
403
404   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
405
406   return "Empty File!" unless ($imported || $format eq 'cch-update');
407
408   ''; #no error
409
410 }
411
412 =back
413
414 =head1 BUGS
415
416 =head1 SEE ALSO
417
418 L<FS::Record>, schema.html from the base documentation.
419
420 =cut
421
422 1;
423
424