(finally) wrap up new tax rate engine (for now)
[freeside.git] / FS / FS / tax_rate.pm
1 package FS::tax_rate;
2
3 use strict;
4 use vars qw( @ISA $DEBUG $me
5              %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
6              %tax_passtypes );
7 use Date::Parse;
8 use Storable qw( thaw );
9 use MIME::Base64;
10 use FS::Record qw( qsearchs dbh );
11 use FS::tax_class;
12 use FS::cust_bill_pkg;
13 use FS::cust_tax_location;
14 use FS::part_pkg_taxrate;
15
16 @ISA = qw( FS::Record );
17
18 $DEBUG = 0;
19 $me = '[FS::tax_rate]';
20
21 =head1 NAME
22
23 FS::tax_rate - Object methods for tax_rate objects
24
25 =head1 SYNOPSIS
26
27   use FS::tax_rate;
28
29   $record = new FS::tax_rate \%hash;
30   $record = new FS::tax_rate { 'column' => 'value' };
31
32   $error = $record->insert;
33
34   $error = $new_record->replace($old_record);
35
36   $error = $record->delete;
37
38   $error = $record->check;
39
40 =head1 DESCRIPTION
41
42 An FS::tax_rate object represents a tax rate, defined by locale.
43 FS::tax_rate inherits from FS::Record.  The following fields are
44 currently supported:
45
46 =over 4
47
48 =item taxnum
49
50 primary key (assigned automatically for new tax rates)
51
52 =item geocode
53
54 a geographic location code provided by a tax data vendor
55
56 =item data_vendor
57
58 the tax data vendor
59
60 =item location
61
62 a location code provided by a tax authority
63
64 =item taxclassnum
65
66 a foreign key into FS::tax_class - the type of tax
67 referenced but FS::part_pkg_taxrate
68 eitem effective_date
69
70 the time after which the tax applies
71
72 =item tax
73
74 percentage
75
76 =item excessrate
77
78 second bracket percentage 
79
80 =item taxbase
81
82 the amount to which the tax applies (first bracket)
83
84 =item taxmax
85
86 a cap on the amount of tax if a cap exists
87
88 =item usetax
89
90 percentage on out of jurisdiction purchases
91
92 =item useexcessrate
93
94 second bracket percentage on out of jurisdiction purchases
95
96 =item unittype
97
98 one of the values in %tax_unittypes
99
100 =item fee
101
102 amount of tax per unit
103
104 =item excessfee
105
106 second bracket amount of tax per unit
107
108 =item feebase
109
110 the number of units to which the fee applies (first bracket)
111
112 =item feemax
113
114 the most units to which fees apply (first and second brackets)
115
116 =item maxtype
117
118 a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)
119
120 =item taxname
121
122 if defined, printed on invoices instead of "Tax"
123
124 =item taxauth
125
126 a value from %tax_authorities
127
128 =item basetype
129
130 a value from %tax_basetypes indicating the tax basis
131
132 =item passtype
133
134 a value from %tax_passtypes indicating how the tax should displayed to the customer
135
136 =item passflag
137
138 'Y', 'N', or blank indicating the tax can be passed to the customer
139
140 =item setuptax
141
142 if 'Y', this tax does not apply to setup fees
143
144 =item recurtax
145
146 if 'Y', this tax does not apply to recurring fees
147
148 =item manual
149
150 if 'Y', has been manually edited
151
152 =back
153
154 =head1 METHODS
155
156 =over 4
157
158 =item new HASHREF
159
160 Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.
161
162 =cut
163
164 sub table { 'tax_rate'; }
165
166 =item insert
167
168 Adds this tax rate to the database.  If there is an error, returns the error,
169 otherwise returns false.
170
171 =item delete
172
173 Deletes this tax rate from the database.  If there is an error, returns the
174 error, otherwise returns false.
175
176 =item replace OLD_RECORD
177
178 Replaces the OLD_RECORD with this one in the database.  If there is an error,
179 returns the error, otherwise returns false.
180
181 =item check
182
183 Checks all fields to make sure this is a valid tax rate.  If there is an error,
184 returns the error, otherwise returns false.  Called by the insert and replace
185 methods.
186
187 =cut
188
189 sub check {
190   my $self = shift;
191
192   foreach (qw( taxbase taxmax )) {
193     $self->$_(0) unless $self->$_;
194   }
195
196   $self->ut_numbern('taxnum')
197     || $self->ut_text('geocode')
198     || $self->ut_textn('data_vendor')
199     || $self->ut_textn('location')
200     || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
201     || $self->ut_numbern('effective_date')
202     || $self->ut_float('tax')
203     || $self->ut_floatn('excessrate')
204     || $self->ut_money('taxbase')
205     || $self->ut_money('taxmax')
206     || $self->ut_floatn('usetax')
207     || $self->ut_floatn('useexcessrate')
208     || $self->ut_numbern('unittype')
209     || $self->ut_floatn('fee')
210     || $self->ut_floatn('excessfee')
211     || $self->ut_floatn('feemax')
212     || $self->ut_numbern('maxtype')
213     || $self->ut_textn('taxname')
214     || $self->ut_numbern('taxauth')
215     || $self->ut_numbern('basetype')
216     || $self->ut_numbern('passtype')
217     || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
218     || $self->ut_enum('setuptax', [ '', 'Y' ] )
219     || $self->ut_enum('recurtax', [ '', 'Y' ] )
220     || $self->ut_enum('manual', [ '', 'Y' ] )
221     || $self->SUPER::check
222     ;
223
224 }
225
226 =item taxclass_description
227
228 Returns the human understandable value associated with the related
229 FS::tax_class.
230
231 =cut
232
233 sub taxclass_description {
234   my $self = shift;
235   my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
236   $tax_class ? $tax_class->description : '';
237 }
238
239 =item unittype_name
240
241 Returns the human understandable value associated with the unittype column
242
243 =cut
244
245 %tax_unittypes = ( '0' => 'access line',
246                    '1' => 'minute',
247                    '2' => 'account',
248 );
249
250 sub unittype_name {
251   my $self = shift;
252   $tax_unittypes{$self->unittype};
253 }
254
255 =item maxtype_name
256
257 Returns the human understandable value associated with the maxtype column
258
259 =cut
260
261 %tax_maxtypes = ( '0' => 'receipts per invoice',
262                   '1' => 'receipts per item',
263                   '2' => 'total utility charges per utility tax year',
264                   '3' => 'total charges per utility tax year',
265                   '4' => 'receipts per access line',
266                   '9' => 'monthly receipts per location',
267 );
268
269 sub maxtype_name {
270   my $self = shift;
271   $tax_maxtypes{$self->maxtype};
272 }
273
274 =item basetype_name
275
276 Returns the human understandable value associated with the basetype column
277
278 =cut
279
280 %tax_basetypes = ( '0'  => 'sale price',
281                    '1'  => 'gross receipts',
282                    '2'  => 'sales taxable telecom revenue',
283                    '3'  => 'minutes carried',
284                    '4'  => 'minutes billed',
285                    '5'  => 'gross operating revenue',
286                    '6'  => 'access line',
287                    '7'  => 'account',
288                    '8'  => 'gross revenue',
289                    '9'  => 'portion gross receipts attributable to interstate service',
290                    '10' => 'access line',
291                    '11' => 'gross profits',
292                    '12' => 'tariff rate',
293                    '14' => 'account',
294 );
295
296 sub basetype_name {
297   my $self = shift;
298   $tax_basetypes{$self->basetype};
299 }
300
301 =item taxauth_name
302
303 Returns the human understandable value associated with the taxauth column
304
305 =cut
306
307 %tax_authorities = ( '0' => 'federal',
308                      '1' => 'state',
309                      '2' => 'county',
310                      '3' => 'city',
311                      '4' => 'local',
312                      '5' => 'county administered by state',
313                      '6' => 'city administered by state',
314                      '7' => 'city administered by county',
315                      '8' => 'local administered by state',
316                      '9' => 'local administered by county',
317 );
318
319 sub taxauth_name {
320   my $self = shift;
321   $tax_authorities{$self->taxauth};
322 }
323
324 =item passtype_name
325
326 Returns the human understandable value associated with the passtype column
327
328 =cut
329
330 %tax_passtypes = ( '0' => 'separate tax line',
331                    '1' => 'separate surcharge line',
332                    '2' => 'surcharge not separated',
333                    '3' => 'included in base rate',
334 );
335
336 sub passtype_name {
337   my $self = shift;
338   $tax_passtypes{$self->passtype};
339 }
340
341 =item taxline CUST_BILL_PKG, ...
342
343 Returns a listref of a name and an amount of tax calculated for the list
344 of packages.  If an error occurs, a message is returned as a scalar.
345
346 =cut
347
348 sub taxline {
349   my $self = shift;
350   my @cust_bill_pkg = @_;
351
352   if ($self->passflag eq 'N') {
353     return "fatal: can't (yet) handle taxes not passed to the customer";
354   }
355
356   if ($self->maxtype != 0 && $self->maxtype != 9) {
357     return qq!fatal: can't (yet) handle tax with "!. $self->maxtype_name. 
358       '" threshold';
359   }
360
361   if ($self->maxtype == 9) {
362     return qq!fatal: can't (yet) handle tax with "!. $self->maxtype_name. 
363       '" threshold';  # "texas" tax
364   }
365
366   if ($self->basetype != 0 && $self->basetype != 1 &&
367       $self->basetype != 6 && $self->basetype != 7 &&
368       $self->basetype != 14
369   ) {
370     return qq!fatal: can't (yet) handle tax with "!. $self->basetype_name. 
371       '" basis';
372   }
373
374   my $name = $self->taxname;
375   $name = 'Other surcharges'
376     if ($self->passtype == 2);
377   my $amount = 0;
378   
379   my $taxable_charged = 0;
380   unless ($self->setuptax =~ /^Y$/i) {
381     $taxable_charged += $_->setup foreach @cust_bill_pkg;
382   }
383   unless ($self->recurtax =~ /^Y$/i) {
384     $taxable_charged += $_->recur foreach @cust_bill_pkg;
385   }
386
387   my $taxable_units = 0;
388   unless ($self->recurtax =~ /^Y$/i) {
389     $taxable_units += $_->units foreach @cust_bill_pkg;
390   }
391
392   #
393   # XXX insert exemption handling here
394   #
395   # the tax or fee is applied to taxbase or feebase and then
396   # the excessrate or excess fee is applied to taxmax or feemax
397   #
398
399   $amount += $taxable_charged * $self->tax;
400   $amount += $taxable_units * $self->fee;
401   
402   return [$name, $amount];
403
404 }
405
406 =back
407
408 =head1 SUBROUTINES
409
410 =over 4
411
412 =item batch_import
413
414 =cut
415
416 sub batch_import {
417   my ($param, $job) = @_;
418
419   my $fh = $param->{filehandle};
420   my $format = $param->{'format'};
421
422   my %insert = ();
423   my %delete = ();
424
425   my @fields;
426   my $hook;
427
428   my $line;
429   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
430   if ( $job ) {
431     $count++
432       while ( defined($line=<$fh>) );
433     seek $fh, 0, 0;
434   }
435   $count *=2;
436
437   if ( $format eq 'cch' || $format eq 'cch-update' ) {
438     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
439                   excessrate effective_date taxauth taxtype taxcat taxname
440                   usetax useexcessrate fee unittype feemax maxtype passflag
441                   passtype basetype );
442     push @fields, 'actionflag' if $format eq 'cch-update';
443
444     $hook = sub {
445       my $hash = shift;
446
447       $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
448       $hash->{'data_vendor'} ='cch';
449       $hash->{'effective_date'} = str2time($hash->{'effective_date'});
450
451       my $taxclassid =
452         join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
453
454       my %tax_class = ( 'data_vendor'  => 'cch', 
455                         'taxclass' => $taxclassid,
456                       );
457
458       my $tax_class = qsearchs( 'tax_class', \%tax_class );
459       return "Error updating tax rate: no tax class $taxclassid"
460         unless $tax_class;
461
462       $hash->{'taxclassnum'} = $tax_class->taxclassnum;
463
464       foreach (qw( inoutcity inoutlocal taxtype taxcat )) {
465         delete($hash->{$_});
466       }
467
468       my %passflagmap = ( '0' => '',
469                           '1' => 'Y',
470                           '2' => 'N',
471                         );
472       $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
473         if exists $passflagmap{$hash->{'passflag'}};
474
475       foreach (keys %$hash) {
476         $hash->{$_} = substr($hash->{$_}, 0, 80)
477           if length($hash->{$_}) > 80;
478       }
479
480       my $actionflag = delete($hash->{'actionflag'});
481       if ($actionflag eq 'I') {
482         $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = $hash;
483       }elsif ($actionflag eq 'D') {
484         $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = $hash;
485       }else{
486         return "Unexpected action flag: ". $hash->{'actionflag'};
487       }
488
489       '';
490
491     };
492
493   } elsif ( $format eq 'extended' ) {
494     die "unimplemented\n";
495     @fields = qw( );
496     $hook = sub {};
497   } else {
498     die "unknown format $format";
499   }
500
501   eval "use Text::CSV_XS;";
502   die $@ if $@;
503
504   my $csv = new Text::CSV_XS;
505
506   my $imported = 0;
507
508   local $SIG{HUP} = 'IGNORE';
509   local $SIG{INT} = 'IGNORE';
510   local $SIG{QUIT} = 'IGNORE';
511   local $SIG{TERM} = 'IGNORE';
512   local $SIG{TSTP} = 'IGNORE';
513   local $SIG{PIPE} = 'IGNORE';
514
515   my $oldAutoCommit = $FS::UID::AutoCommit;
516   local $FS::UID::AutoCommit = 0;
517   my $dbh = dbh;
518   
519   while ( defined($line=<$fh>) ) {
520     $csv->parse($line) or do {
521       $dbh->rollback if $oldAutoCommit;
522       return "can't parse: ". $csv->error_input();
523     };
524
525     if ( $job ) {  # progress bar
526       if ( time - $min_sec > $last ) {
527         my $error = $job->update_statustext(
528           int( 100 * $imported / $count )
529         );
530         die $error if $error;
531         $last = time;
532       }
533     }
534
535     my @columns = $csv->fields();
536
537     my %tax_rate = ( 'data_vendor' => $format );
538     foreach my $field ( @fields ) {
539       $tax_rate{$field} = shift @columns; 
540     }
541     if ( scalar( @columns ) ) {
542       $dbh->rollback if $oldAutoCommit;
543       return "Unexpected trailing columns in line (wrong format?): $line";
544     }
545
546     my $error = &{$hook}(\%tax_rate);
547     if ( $error ) {
548       $dbh->rollback if $oldAutoCommit;
549       return $error;
550     }
551
552     $imported++;
553
554   }
555
556   for (grep { !exists($delete{$_}) } keys %insert) {
557     if ( $job ) {  # progress bar
558       if ( time - $min_sec > $last ) {
559         my $error = $job->update_statustext(
560           int( 100 * $imported / $count )
561         );
562         die $error if $error;
563         $last = time;
564       }
565     }
566
567     my $tax_rate = new FS::tax_rate( $insert{$_} );
568     my $error = $tax_rate->insert;
569
570     if ( $error ) {
571       $dbh->rollback if $oldAutoCommit;
572       return "can't insert tax_rate for $line: $error";
573     }
574
575     $imported++;
576   }
577
578   for (grep { exists($delete{$_}) } keys %insert) {
579     if ( $job ) {  # progress bar
580       if ( time - $min_sec > $last ) {
581         my $error = $job->update_statustext(
582           int( 100 * $imported / $count )
583         );
584         die $error if $error;
585         $last = time;
586       }
587     }
588
589     my $old = qsearchs( 'tax_rate', $delete{$_} );
590     unless ($old) {
591       $dbh->rollback if $oldAutoCommit;
592       $old = $delete{$_};
593       return "can't find tax_rate to replace for: ".
594         #join(" ", map { "$_ => ". $old->{$_} } @fields);
595         join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
596     }
597     my $new = new FS::tax_rate( $insert{$_} );
598     $new->taxnum($old->taxnum);
599     my $error = $new->replace($old);
600
601     if ( $error ) {
602       $dbh->rollback if $oldAutoCommit;
603       return "can't insert tax_rate for $line: $error";
604     }
605
606     $imported++;
607     $imported++;
608   }
609
610   for (grep { !exists($insert{$_}) } keys %delete) {
611     if ( $job ) {  # progress bar
612       if ( time - $min_sec > $last ) {
613         my $error = $job->update_statustext(
614           int( 100 * $imported / $count )
615         );
616         die $error if $error;
617         $last = time;
618       }
619     }
620
621     my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
622     unless ($tax_rate) {
623       $dbh->rollback if $oldAutoCommit;
624       $tax_rate = $delete{$_};
625       return "can't find tax_rate to delete for: ".
626         #join(" ", map { "$_ => ". $tax_rate->{$_} } @fields);
627         join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) );
628     }
629     my $error = $tax_rate->delete;
630
631     if ( $error ) {
632       $dbh->rollback if $oldAutoCommit;
633       return "can't insert tax_rate for $line: $error";
634     }
635
636     $imported++;
637   }
638
639   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
640
641   return "Empty file!" unless $imported;
642
643   ''; #no error
644
645 }
646
647 =item process_batch
648
649 Load an batch import as a queued JSRPC job
650
651 =cut
652
653 sub process_batch {
654   my $job = shift;
655
656   my $param = thaw(decode_base64(shift));
657   my $format = $param->{'format'};        #well... this is all cch specific
658
659   my $files = $param->{'uploaded_files'}
660     or die "No files provided.";
661
662   my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
663
664   if ($format eq 'cch') {
665
666     my $oldAutoCommit = $FS::UID::AutoCommit;
667     local $FS::UID::AutoCommit = 0;
668     my $dbh = dbh;
669     my $error = '';
670
671     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
672                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
673                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
674                  'DETAIL',   'detail',    \&FS::tax_rate::batch_import,
675                );
676     while( scalar(@list) ) {
677       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
678       unless ($files{$file}) {
679         $error = "No $name supplied";
680         next;
681       }
682       my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
683       my $filename = "$dir/".  $files{$file};
684       open my $fh, "< $filename" or $error ||= "Can't open $name file: $!";
685
686       $error ||= &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
687       close $fh;
688       unlink $filename or warn "Can't delete $filename: $!";
689     }
690     
691     if ($error) {
692       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
693       die $error;
694     }else{
695       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
696     }
697
698   }elsif ($format eq 'cch-update') {
699
700     my $oldAutoCommit = $FS::UID::AutoCommit;
701     local $FS::UID::AutoCommit = 0;
702     my $dbh = dbh;
703     my $error = '';
704     my @insert_list = ();
705     my @delete_list = ();
706
707     my @list = ( 'CODE',     'codefile',  \&FS::tax_class::batch_import,
708                  'PLUS4',    'plus4file', \&FS::cust_tax_location::batch_import,
709                  'TXMATRIX', 'txmatrix',  \&FS::part_pkg_taxrate::batch_import,
710                );
711     my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
712     while( scalar(@list) ) {
713       my ($name, $file, $import_sub) = (shift @list, shift @list, shift @list);
714       unless ($files{$file}) {
715         $error = "No $name supplied";
716         next;
717       }
718       my $filename = "$dir/".  $files{$file};
719       open my $fh, "< $filename" or $error ||= "Can't open $name file $filename: $!";
720       unlink $filename or warn "Can't delete $filename: $!";
721
722       my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
723                                 DIR      => $dir,
724                                 UNLINK   => 0,     #meh
725                               ) or die "can't open temp file: $!\n";
726
727       my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
728                                 DIR      => $dir,
729                                 UNLINK   => 0,     #meh
730                               ) or die "can't open temp file: $!\n";
731
732       while(<$fh>) {
733         my $handle = '';
734         $handle = $ifh if $_ =~ /"I"\s*$/;
735         $handle = $dfh if $_ =~ /"D"\s*$/;
736         unless ($handle) {
737           $error = "bad input line: $_" unless $handle;
738           last;
739         }
740         print $handle $_;
741       }
742       close $fh;
743       close $ifh;
744       close $dfh;
745
746       push @insert_list, $name, $ifh->filename, $import_sub;
747       unshift @delete_list, $name, $dfh->filename, $import_sub;
748
749     }
750     while( scalar(@insert_list) ) {
751       my ($name, $file, $import_sub) =
752         (shift @insert_list, shift @insert_list, shift @insert_list);
753
754       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
755       $error ||=
756         &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
757       close $fh;
758       unlink $file or warn "Can't delete $file: $!";
759     }
760     
761     $error = "No DETAIL supplied"
762       unless ($files{detail});
763     open my $fh, "< $dir/". $files{detail}
764       or $error ||= "Can't open DETAIL file: $!";
765     $error ||=
766       &FS::tax_rate::batch_import({ 'filehandle' => $fh, 'format' => $format },
767                                   $job);
768     close $fh;
769     unlink "$dir/". $files{detail} or warn "Can't delete $files{detail}: $!"
770       if $files{detail};
771
772     while( scalar(@delete_list) ) {
773       my ($name, $file, $import_sub) =
774         (shift @delete_list, shift @delete_list, shift @delete_list);
775
776       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
777       $error ||=
778         &{$import_sub}({ 'filehandle' => $fh, 'format' => $format }, $job);
779       close $fh;
780       unlink $file or warn "Can't delete $file: $!";
781     }
782     
783     if ($error) {
784       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
785       die $error;
786     }else{
787       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
788     }
789
790   }else{
791     die "Unknown format: $format";
792   }
793
794 }
795
796 =back
797
798 =head1 BUGS
799
800   Mixing automatic and manual editing works poorly at present.
801
802 =head1 SEE ALSO
803
804 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
805 documentation.
806
807 =cut
808
809 1;
810