d773ff534f27066446262ce1ad9ae82b8f3fa4e6
[freeside.git] / FS / FS / tax_rate.pm
1 package FS::tax_rate;
2 use base qw( FS::Record );
3
4 use strict;
5 use vars qw( $DEBUG $me
6              %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
7              %tax_passtypes %GetInfoType $keep_cch_files );
8 use Date::Parse;
9 use DateTime;
10 use DateTime::Format::Strptime;
11 use Storable qw( thaw nfreeze );
12 use IO::File;
13 use File::Temp;
14 use Text::CSV_XS;
15 use URI::Escape;
16 use LWP::UserAgent;
17 use HTTP::Request;
18 use HTTP::Response;
19 use MIME::Base64;
20 use DBIx::DBSchema;
21 use DBIx::DBSchema::Table;
22 use DBIx::DBSchema::Column;
23 use FS::Record qw( qsearch qsearchs dbh dbdef );
24 use FS::Conf;
25 use FS::tax_class;
26 use FS::cust_bill_pkg;
27 use FS::cust_tax_location;
28 use FS::tax_rate_location;
29 use FS::part_pkg_taxrate;
30 use FS::part_pkg_taxproduct;
31 use FS::cust_main;
32 use FS::Misc qw( csv_from_fixed );
33
34 $DEBUG = 0;
35 $me = '[FS::tax_rate]';
36 $keep_cch_files = 0;
37
38 =head1 NAME
39
40 FS::tax_rate - Object methods for tax_rate objects
41
42 =head1 SYNOPSIS
43
44   use FS::tax_rate;
45
46   $record = new FS::tax_rate \%hash;
47   $record = new FS::tax_rate { 'column' => 'value' };
48
49   $error = $record->insert;
50
51   $error = $new_record->replace($old_record);
52
53   $error = $record->delete;
54
55   $error = $record->check;
56
57 =head1 DESCRIPTION
58
59 An FS::tax_rate object represents a tax rate, defined by locale.
60 FS::tax_rate inherits from FS::Record.  The following fields are
61 currently supported:
62
63 =over 4
64
65 =item taxnum
66
67 primary key (assigned automatically for new tax rates)
68
69 =item geocode
70
71 a geographic location code provided by a tax data vendor
72
73 =item data_vendor
74
75 the tax data vendor
76
77 =item location
78
79 a location code provided by a tax authority
80
81 =item taxclassnum
82
83 a foreign key into FS::tax_class - the type of tax
84 referenced but FS::part_pkg_taxrate
85 eitem effective_date
86
87 the time after which the tax applies
88
89 =item tax
90
91 percentage
92
93 =item excessrate
94
95 second bracket percentage 
96
97 =item taxbase
98
99 the amount to which the tax applies (first bracket)
100
101 =item taxmax
102
103 a cap on the amount of tax if a cap exists
104
105 =item usetax
106
107 percentage on out of jurisdiction purchases
108
109 =item useexcessrate
110
111 second bracket percentage on out of jurisdiction purchases
112
113 =item unittype
114
115 one of the values in %tax_unittypes
116
117 =item fee
118
119 amount of tax per unit
120
121 =item excessfee
122
123 second bracket amount of tax per unit
124
125 =item feebase
126
127 the number of units to which the fee applies (first bracket)
128
129 =item feemax
130
131 the most units to which fees apply (first and second brackets)
132
133 =item maxtype
134
135 a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)
136
137 =item taxname
138
139 if defined, printed on invoices instead of "Tax"
140
141 =item taxauth
142
143 a value from %tax_authorities
144
145 =item basetype
146
147 a value from %tax_basetypes indicating the tax basis
148
149 =item passtype
150
151 a value from %tax_passtypes indicating how the tax should displayed to the customer
152
153 =item passflag
154
155 'Y', 'N', or blank indicating the tax can be passed to the customer
156
157 =item setuptax
158
159 if 'Y', this tax does not apply to setup fees
160
161 =item recurtax
162
163 if 'Y', this tax does not apply to recurring fees
164
165 =item manual
166
167 if 'Y', has been manually edited
168
169 =back
170
171 =head1 METHODS
172
173 =over 4
174
175 =item new HASHREF
176
177 Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.
178
179 =cut
180
181 sub table { 'tax_rate'; }
182
183 =item insert
184
185 Adds this tax rate to the database.  If there is an error, returns the error,
186 otherwise returns false.
187
188 =item delete
189
190 Deletes this tax rate from the database.  If there is an error, returns the
191 error, otherwise returns false.
192
193 =item replace OLD_RECORD
194
195 Replaces the OLD_RECORD with this one in the database.  If there is an error,
196 returns the error, otherwise returns false.
197
198 =item check
199
200 Checks all fields to make sure this is a valid tax rate.  If there is an error,
201 returns the error, otherwise returns false.  Called by the insert and replace
202 methods.
203
204 =cut
205
206 sub check {
207   my $self = shift;
208
209   foreach (qw( taxbase taxmax )) {
210     $self->$_(0) unless $self->$_;
211   }
212
213   $self->ut_numbern('taxnum')
214     || $self->ut_text('geocode')
215     || $self->ut_textn('data_vendor')
216     || $self->ut_cch_textn('location')
217     || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
218     || $self->ut_snumbern('effective_date')
219     || $self->ut_float('tax')
220     || $self->ut_floatn('excessrate')
221     || $self->ut_money('taxbase')
222     || $self->ut_money('taxmax')
223     || $self->ut_floatn('usetax')
224     || $self->ut_floatn('useexcessrate')
225     || $self->ut_numbern('unittype')
226     || $self->ut_floatn('fee')
227     || $self->ut_floatn('excessfee')
228     || $self->ut_floatn('feemax')
229     || $self->ut_numbern('maxtype')
230     || $self->ut_textn('taxname')
231     || $self->ut_numbern('taxauth')
232     || $self->ut_numbern('basetype')
233     || $self->ut_numbern('passtype')
234     || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
235     || $self->ut_enum('setuptax', [ '', 'Y' ] )
236     || $self->ut_enum('recurtax', [ '', 'Y' ] )
237     || $self->ut_enum('inoutcity', [ '', 'I', 'O' ] )
238     || $self->ut_enum('inoutlocal', [ '', 'I', 'O' ] )
239     || $self->ut_enum('manual', [ '', 'Y' ] )
240     || $self->ut_enum('disabled', [ '', 'Y' ] )
241     || $self->SUPER::check
242     ;
243
244 }
245
246 #ut_text / ut_textn w/ ` added cause now that's in the data
247 sub ut_cch_textn {
248   my($self,$field)=@_;
249   $self->getfield($field)
250     =~ /^([\wô \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=\[\]\<\>\`]*)$/
251       or return gettext('illegal_or_empty_text'). " $field: ".
252                  $self->getfield($field);
253   $self->setfield($field,$1);
254   '';
255
256 }
257
258 =item taxclass_description
259
260 Returns the human understandable value associated with the related
261 FS::tax_class.
262
263 =cut
264
265 sub taxclass_description {
266   my $self = shift;
267   my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
268   $tax_class ? $tax_class->description : '';
269 }
270
271 =item unittype_name
272
273 Returns the human understandable value associated with the unittype column
274
275 =cut
276
277 %tax_unittypes = ( '0' => 'access line',
278                    '1' => 'minute',
279                    '2' => 'account',
280 );
281
282 sub unittype_name {
283   my $self = shift;
284   $tax_unittypes{$self->unittype};
285 }
286
287 =item maxtype_name
288
289 Returns the human understandable value associated with the maxtype column.
290
291 =cut
292
293 # XXX these are non-functional, and most of them are horrible to implement
294 # in our current model
295
296 %tax_maxtypes = ( '0' => 'receipts per invoice',
297                   '1' => 'receipts per item',
298                   '2' => 'total utility charges per utility tax year',
299                   '3' => 'total charges per utility tax year',
300                   '4' => 'receipts per access line',
301                   '7' => 'total utility charges per calendar year',
302                   '9' => 'monthly receipts per location',
303                   '10' => 'monthly receipts exceeds taxbase and total tax per month does not exceed maxtax', # wtf?
304                   '11' => 'receipts/units per access line',
305                   '14' => 'units per invoice',
306                   '15' => 'units per month',
307                   '18' => 'units per account',
308 );
309
310 sub maxtype_name {
311   my $self = shift;
312   $tax_maxtypes{$self->maxtype};
313 }
314
315 =item basetype_name
316
317 Returns the human understandable value associated with the basetype column
318
319 =cut
320
321 %tax_basetypes = ( '0'  => 'sale price',
322                    '1'  => 'gross receipts',
323                    '2'  => 'sales taxable telecom revenue',
324                    '3'  => 'minutes carried',
325                    '4'  => 'minutes billed',
326                    '5'  => 'gross operating revenue',
327                    '6'  => 'access line',
328                    '7'  => 'account',
329                    '8'  => 'gross revenue',
330                    '9'  => 'portion gross receipts attributable to interstate service',
331                    '10' => 'access line',
332                    '11' => 'gross profits',
333                    '12' => 'tariff rate',
334                    '14' => 'account',
335                    '15' => 'prior year gross receipts',
336 );
337
338 sub basetype_name {
339   my $self = shift;
340   $tax_basetypes{$self->basetype};
341 }
342
343 =item taxauth_name
344
345 Returns the human understandable value associated with the taxauth column
346
347 =cut
348
349 %tax_authorities = ( '0' => 'federal',
350                      '1' => 'state',
351                      '2' => 'county',
352                      '3' => 'city',
353                      '4' => 'local',
354                      '5' => 'county administered by state',
355                      '6' => 'city administered by state',
356                      '7' => 'city administered by county',
357                      '8' => 'local administered by state',
358                      '9' => 'local administered by county',
359 );
360
361 sub taxauth_name {
362   my $self = shift;
363   $tax_authorities{$self->taxauth};
364 }
365
366 =item passtype_name
367
368 Returns the human understandable value associated with the passtype column
369
370 =cut
371
372 %tax_passtypes = ( '0' => 'separate tax line',
373                    '1' => 'separate surcharge line',
374                    '2' => 'surcharge not separated',
375                    '3' => 'included in base rate',
376 );
377
378 sub passtype_name {
379   my $self = shift;
380   $tax_passtypes{$self->passtype};
381 }
382
383 =item taxline TAXABLES
384
385 Returns a listref of a name and an amount of tax calculated for the list
386 of packages/amounts referenced by TAXABLES.  If an error occurs, a message
387 is returned as a scalar.
388
389 =cut
390
391 sub taxline {
392   my $self = shift;
393   # this used to accept a hash of options but none of them did anything
394   # so it's been removed.
395
396   my $taxables;
397
398   if (ref($_[0]) eq 'ARRAY') {
399     $taxables = shift;
400   }else{
401     $taxables = [ @_ ];
402     #exemptions would be broken in this case
403   }
404
405   my $name = $self->taxname;
406   $name = 'Other surcharges'
407     if ($self->passtype == 2);
408   my $amount = 0;
409   
410   if ( $self->disabled ) { # we always know how to handle disabled taxes
411     return {
412       'name'   => $name,
413       'amount' => $amount,
414     };
415   }
416
417   my $taxable_charged = 0;
418   my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; }
419                       @$taxables;
420
421   warn "calculating taxes for ". $self->taxnum. " on ".
422     join (",", map { $_->pkgnum } @cust_bill_pkg)
423     if $DEBUG;
424
425   if ($self->passflag eq 'N') {
426     # return "fatal: can't (yet) handle taxes not passed to the customer";
427     # until someone needs to track these in freeside
428     return {
429       'name'   => $name,
430       'amount' => 0,
431     };
432   }
433
434   my $maxtype = $self->maxtype || 0;
435   if ($maxtype != 0 && $maxtype != 1 
436       && $maxtype != 14 && $maxtype != 15
437       && $maxtype != 18 # sigh
438     ) {
439     return $self->_fatal_or_null( 'tax with "'.
440                                     $self->maxtype_name. '" threshold'
441                                 );
442   } # I don't know why, it's not like there are maxtypes that we DO support
443
444   # we treat gross revenue as gross receipts and expect the tax data
445   # to DTRT (i.e. tax on tax rules)
446   if ($self->basetype != 0 && $self->basetype != 1 &&
447       $self->basetype != 5 && $self->basetype != 6 &&
448       $self->basetype != 7 && $self->basetype != 8 &&
449       $self->basetype != 14
450   ) {
451     return
452       $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' );
453   }
454
455   unless ($self->setuptax =~ /^Y$/i) {
456     $taxable_charged += $_->setup foreach @cust_bill_pkg;
457   }
458   unless ($self->recurtax =~ /^Y$/i) {
459     $taxable_charged += $_->recur foreach @cust_bill_pkg;
460   }
461
462   my $taxable_units = 0;
463   unless ($self->recurtax =~ /^Y$/i) {
464
465     if (( $self->unittype || 0 ) == 0) { #access line
466       my %seen = ();
467       foreach (@cust_bill_pkg) {
468         $taxable_units += $_->units
469           unless $seen{$_->pkgnum}++;
470       }
471
472     } elsif ($self->unittype == 1) { #minute
473       return $self->_fatal_or_null( 'fee with minute unit type' );
474
475     } elsif ($self->unittype == 2) { #account
476
477       my $conf = new FS::Conf;
478       if ( $conf->exists('tax-pkg_address') ) {
479         #number of distinct locations
480         my %seen = ();
481         foreach (@cust_bill_pkg) {
482           $taxable_units++
483             unless $seen{$_->cust_pkg->locationnum}++;
484         }
485       } else {
486         $taxable_units = 1;
487       }
488
489     } else {
490       return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum );
491     }
492
493   }
494
495   # XXX handle excessrate (use_excessrate) / excessfee /
496   #            taxbase/feebase / taxmax/feemax
497   #            and eventually exemptions
498   #
499   # the tax or fee is applied to taxbase or feebase and then
500   # the excessrate or excess fee is applied to taxmax or feemax
501
502   if ( ($self->taxmax > 0 and $taxable_charged > $self->taxmax) or
503        ($self->feemax > 0 and $taxable_units > $self->feemax) ) {
504     # throw an error
505     # (why not just cap taxable_charged/units at the taxmax/feemax? because
506     # it's way more complicated than that. this won't even catch every case
507     # where a bracket maximum should apply.)
508     return $self->_fatal_or_null( 'tax base > taxmax/feemax for tax'.$self->taxnum );
509   }
510
511   $amount += $taxable_charged * $self->tax;
512   $amount += $taxable_units * $self->fee;
513   
514   warn "calculated taxes as [ $name, $amount ]\n"
515     if $DEBUG;
516
517   return {
518     'name'   => $name,
519     'amount' => $amount,
520   };
521
522 }
523
524 sub _fatal_or_null {
525   my ($self, $error) = @_;
526
527   $DB::single = 1; # not a mistake
528
529   my $conf = new FS::Conf;
530
531   $error = "can't yet handle ". $error;
532   my $name = $self->taxname;
533   $name = 'Other surcharges'
534     if ($self->passtype == 2);
535
536   if ($conf->exists('ignore_incalculable_taxes')) {
537     warn "WARNING: $error; billing anyway per ignore_incalculable_taxes conf\n";
538     return { name => $name, amount => 0 };
539   } else {
540     return "fatal: $error";
541   }
542 }
543
544 =item tax_on_tax CUST_LOCATION
545
546 Returns a list of taxes which are candidates for taxing taxes for the
547 given service location (see L<FS::cust_location>)
548
549 =cut
550
551     #hot
552 sub tax_on_tax {
553        #akshun
554   my $self = shift;
555   my $cust_location = shift;
556
557   warn "looking up taxes on tax ". $self->taxnum. " for customer ".
558     $cust_location->custnum
559     if $DEBUG;
560
561   my $geocode = $cust_location->geocode($self->data_vendor);
562
563   # CCH oddness in m2m
564   my $dbh = dbh;
565   my $extra_sql = ' AND ('.
566     join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
567                  qw(10 5 2)
568         ).
569     ')';
570
571   my $order_by = 'ORDER BY taxclassnum, length(geocode) desc';
572   my $select   = 'DISTINCT ON(taxclassnum) *';
573
574   # should qsearch preface columns with the table to facilitate joins?
575   my @taxclassnums = map { $_->taxclassnum }
576     qsearch( { 'table'     => 'part_pkg_taxrate',
577                'select'    => $select,
578                'hashref'   => { 'data_vendor'      => $self->data_vendor,
579                                 'taxclassnumtaxed' => $self->taxclassnum,
580                               },
581                'extra_sql' => $extra_sql,
582                'order_by'  => $order_by,
583            } );
584
585   return () unless @taxclassnums;
586
587   $extra_sql =
588     "AND (".  join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
589
590   qsearch({ 'table'     => 'tax_rate',
591             'hashref'   => { 'geocode' => $geocode, },
592             'extra_sql' => $extra_sql,
593          })
594
595 }
596
597 =item tax_rate_location
598
599 Returns an object representing the location associated with this tax
600 (see L<FS::tax_rate_location>)
601
602 =cut
603
604 sub tax_rate_location {
605   my $self = shift;
606
607   qsearchs({ 'table'     => 'tax_rate_location',
608              'hashref'   => { 'data_vendor' => $self->data_vendor, 
609                               'geocode'     => $self->geocode,
610                               'disabled'    => '',
611                             },
612           }) ||
613   new FS::tax_rate_location;
614
615 }
616
617 =back
618
619 =head1 SUBROUTINES
620
621 =over 4
622
623 =item batch_import
624
625 =cut
626
627 sub _progressbar_foo {
628   return (0, time, 5);
629 }
630
631 sub batch_import {
632   my ($param, $job) = @_;
633
634   my $fh = $param->{filehandle};
635   my $format = $param->{'format'};
636
637   my %insert = ();
638   my %delete = ();
639
640   my @fields;
641   my $hook;
642
643   my @column_lengths = ();
644   my @column_callbacks = ();
645   if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
646     $format =~ s/-fixed//;
647     my $date_format = sub { my $r='';
648                             /^(\d{4})(\d{2})(\d{2})$/ && ($r="$3/$2/$1");
649                             $r;
650                           };
651     my $trim = sub { my $r = shift; $r =~ s/^\s*//; $r =~ s/\s*$//; $r };
652     push @column_lengths, qw( 10 1 1 8 8 5 8 8 8 1 2 2 30 8 8 10 2 8 2 1 2 2 );
653     push @column_lengths, 1 if $format eq 'cch-update';
654     push @column_callbacks, $trim foreach (@column_lengths); # 5, 6, 15, 17 esp
655     $column_callbacks[8] = $date_format;
656   }
657   
658   my $line;
659   my ( $count, $last, $min_sec ) = _progressbar_foo();
660   if ( $job || scalar(@column_callbacks) ) {
661     my $error =
662       csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
663     return $error if $error;
664   }
665   $count *=2;
666
667   if ( $format eq 'cch' || $format eq 'cch-update' ) {
668     #false laziness w/below (sub _perform_cch_diff)
669     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
670                   excessrate effective_date taxauth taxtype taxcat taxname
671                   usetax useexcessrate fee unittype feemax maxtype passflag
672                   passtype basetype );
673     push @fields, 'actionflag' if $format eq 'cch-update';
674
675     $hook = sub {
676       my $hash = shift;
677
678       $hash->{'actionflag'} ='I' if ($hash->{'data_vendor'} eq 'cch');
679       $hash->{'data_vendor'} ='cch';
680       my $parser = new DateTime::Format::Strptime( pattern => "%m/%d/%Y",
681                                                    time_zone => 'floating',
682                                                  );
683       my $dt = $parser->parse_datetime( $hash->{'effective_date'} );
684       $hash->{'effective_date'} = $dt ? $dt->epoch : '';
685
686       $hash->{$_} =~ s/\s//g foreach qw( inoutcity inoutlocal ) ; 
687       $hash->{$_} = sprintf("%.2f", $hash->{$_}) foreach qw( taxbase taxmax );
688
689       my $taxclassid =
690         join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
691
692       my %tax_class = ( 'data_vendor'  => 'cch', 
693                         'taxclass' => $taxclassid,
694                       );
695
696       my $tax_class = qsearchs( 'tax_class', \%tax_class );
697       return "Error updating tax rate: no tax class $taxclassid"
698         unless $tax_class;
699
700       $hash->{'taxclassnum'} = $tax_class->taxclassnum;
701
702       foreach (qw( taxtype taxcat )) {
703         delete($hash->{$_});
704       }
705
706       my %passflagmap = ( '0' => '',
707                           '1' => 'Y',
708                           '2' => 'N',
709                         );
710       $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
711         if exists $passflagmap{$hash->{'passflag'}};
712
713       foreach (keys %$hash) {
714         $hash->{$_} = substr($hash->{$_}, 0, 80)
715           if length($hash->{$_}) > 80;
716       }
717
718       my $actionflag = delete($hash->{'actionflag'});
719
720       $hash->{'taxname'} =~ s/`/'/g; 
721       $hash->{'taxname'} =~ s|\\|/|g;
722
723       return '' if $format eq 'cch';  # but not cch-update
724
725       if ($actionflag eq 'I') {
726         $insert{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
727       }elsif ($actionflag eq 'D') {
728         $delete{ $hash->{'geocode'}. ':'. $hash->{'taxclassnum'} } = { %$hash };
729       }else{
730         return "Unexpected action flag: ". $hash->{'actionflag'};
731       }
732
733       delete($hash->{$_}) for keys %$hash;
734
735       '';
736
737     };
738
739   } elsif ( $format eq 'extended' ) {
740     die "unimplemented\n";
741     @fields = qw( );
742     $hook = sub {};
743   } else {
744     die "unknown format $format";
745   }
746
747   my $csv = new Text::CSV_XS;
748
749   my $imported = 0;
750
751   local $SIG{HUP} = 'IGNORE';
752   local $SIG{INT} = 'IGNORE';
753   local $SIG{QUIT} = 'IGNORE';
754   local $SIG{TERM} = 'IGNORE';
755   local $SIG{TSTP} = 'IGNORE';
756   local $SIG{PIPE} = 'IGNORE';
757
758   my $oldAutoCommit = $FS::UID::AutoCommit;
759   local $FS::UID::AutoCommit = 0;
760   my $dbh = dbh;
761   
762   while ( defined($line=<$fh>) ) {
763     $csv->parse($line) or do {
764       $dbh->rollback if $oldAutoCommit;
765       return "can't parse: ". $csv->error_input();
766     };
767
768     if ( $job ) {  # progress bar
769       if ( time - $min_sec > $last ) {
770         my $error = $job->update_statustext(
771           int( 100 * $imported / $count ). ",Importing tax rates"
772         );
773         if ($error) {
774           $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
775           die $error;
776         }
777         $last = time;
778       }
779     }
780
781     my @columns = $csv->fields();
782
783     my %tax_rate = ( 'data_vendor' => $format );
784     foreach my $field ( @fields ) {
785       $tax_rate{$field} = shift @columns; 
786     }
787
788     if ( scalar( @columns ) ) {
789       $dbh->rollback if $oldAutoCommit;
790       return "Unexpected trailing columns in line (wrong format?) importing tax_rate: $line";
791     }
792
793     my $error = &{$hook}(\%tax_rate);
794     if ( $error ) {
795       $dbh->rollback if $oldAutoCommit;
796       return $error;
797     }
798
799     if (scalar(keys %tax_rate)) { #inserts only, not updates for cch
800
801       my $tax_rate = new FS::tax_rate( \%tax_rate );
802       $error = $tax_rate->insert;
803
804       if ( $error ) {
805         $dbh->rollback if $oldAutoCommit;
806         return "can't insert tax_rate for $line: $error";
807       }
808
809     }
810
811     $imported++;
812
813   }
814
815   my @replace = grep { exists($delete{$_}) } keys %insert;
816   for (@replace) {
817     if ( $job ) {  # progress bar
818       if ( time - $min_sec > $last ) {
819         my $error = $job->update_statustext(
820           int( 100 * $imported / $count ). ",Importing tax rates"
821         );
822         if ($error) {
823           $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
824           die $error;
825         }
826         $last = time;
827       }
828     }
829
830     my $old = qsearchs( 'tax_rate', $delete{$_} );
831
832     if ( $old ) {
833
834       my $new = new FS::tax_rate({ $old->hash, %{$insert{$_}}, 'manual' => ''  });
835       $new->taxnum($old->taxnum);
836       my $error = $new->replace($old);
837
838       if ( $error ) {
839         $dbh->rollback if $oldAutoCommit;
840         my $hashref = $insert{$_};
841         $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
842         return "can't replace tax_rate for $line: $error";
843       }
844
845       $imported++;
846
847     } else {
848
849       $old = delete $delete{$_};
850       warn "WARNING: can't find tax_rate to replace (inserting instead and continuing) for: ".
851         #join(" ", map { "$_ => ". $old->{$_} } @fields);
852         join(" ", map { "$_ => ". $old->{$_} } keys(%$old) );
853     }
854
855     $imported++;
856   }
857
858   for (grep { !exists($delete{$_}) } keys %insert) {
859     if ( $job ) {  # progress bar
860       if ( time - $min_sec > $last ) {
861         my $error = $job->update_statustext(
862           int( 100 * $imported / $count ). ",Importing tax rates"
863         );
864         if ($error) {
865           $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
866           die $error;
867         }
868         $last = time;
869       }
870     }
871
872     my $tax_rate = new FS::tax_rate( $insert{$_} );
873     my $error = $tax_rate->insert;
874
875     if ( $error ) {
876       $dbh->rollback if $oldAutoCommit;
877       my $hashref = $insert{$_};
878       $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
879       return "can't insert tax_rate for $line: $error";
880     }
881
882     $imported++;
883   }
884
885   for (grep { !exists($insert{$_}) } keys %delete) {
886     if ( $job ) {  # progress bar
887       if ( time - $min_sec > $last ) {
888         my $error = $job->update_statustext(
889           int( 100 * $imported / $count ). ",Importing tax rates"
890         );
891         if ($error) {
892           $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
893           die $error;
894         }
895         $last = time;
896       }
897     }
898
899     my $tax_rate = qsearchs( 'tax_rate', $delete{$_} );
900     if (!$tax_rate) {
901       $dbh->rollback if $oldAutoCommit;
902       $tax_rate = $delete{$_};
903       warn "WARNING: can't find tax_rate to delete for: ".
904         join(" ", map { "$_ => ". $tax_rate->{$_} } keys(%$tax_rate) ).
905         " (ignoring)\n";
906     } else {
907       my $error = $tax_rate->delete; #  XXX we really should not do this
908                                      # (it orphans CBPTRL records)
909
910       if ( $error ) {
911         $dbh->rollback if $oldAutoCommit;
912         my $hashref = $delete{$_};
913         $line = join(", ", map { "$_ => ". $hashref->{$_} } keys(%$hashref) );
914         return "can't delete tax_rate for $line: $error";
915       }
916     }
917
918     $imported++;
919   }
920
921   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
922
923   return "Empty file!" unless ($imported || $format eq 'cch-update');
924
925   ''; #no error
926
927 }
928
929 =item process_batch_import
930
931 Load a batch import as a queued JSRPC job
932
933 =cut
934
935 sub process_batch_import {
936   my $job = shift;
937
938   my $oldAutoCommit = $FS::UID::AutoCommit;
939   local $FS::UID::AutoCommit = 0;
940   my $dbh = dbh;
941
942   my $param = thaw(decode_base64(shift));
943   my $args = '$job, encode_base64( nfreeze( $param ) )';
944
945   my $method = '_perform_batch_import';
946   if ( $param->{reload} ) {
947     $method = 'process_batch_reload';
948   }
949
950   eval "$method($args);";
951   if ($@) {
952     $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
953     die $@;
954   }
955
956   #success!
957   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
958 }
959
960 sub _perform_batch_import {
961   my $job = shift;
962
963   my $param = thaw(decode_base64(shift));
964   my $format = $param->{'format'};        #well... this is all cch specific
965
966   my $files = $param->{'uploaded_files'}
967     or die "No files provided.";
968
969   my (%files) = map { /^(\w+):((taxdata\/\w+\.\w+\/)?[\.\w]+)$/ ? ($1,$2):() }
970                 split /,/, $files;
971
972   if ( $format eq 'cch' || $format eq 'cch-fixed'
973     || $format eq 'cch-update' || $format eq 'cch-fixed-update' )
974   {
975
976     my $oldAutoCommit = $FS::UID::AutoCommit;
977     local $FS::UID::AutoCommit = 0;
978     my $dbh = dbh;
979     my $error = '';
980     my @insert_list = ();
981     my @delete_list = ();
982     my @predelete_list = ();
983     my $insertname = '';
984     my $deletename = '';
985     my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
986
987     my @list = ( 'GEOCODE',  \&FS::tax_rate_location::batch_import,
988                  'CODE',     \&FS::tax_class::batch_import,
989                  'PLUS4',    \&FS::cust_tax_location::batch_import,
990                  'ZIP',      \&FS::cust_tax_location::batch_import,
991                  'TXMATRIX', \&FS::part_pkg_taxrate::batch_import,
992                  'DETAIL',   \&FS::tax_rate::batch_import,
993                );
994     while( scalar(@list) ) {
995       my ( $name, $import_sub ) = splice( @list, 0, 2 );
996       my $file = lc($name). 'file';
997
998       unless ($files{$file}) {
999         #$error = "No $name supplied";
1000         next;
1001       }
1002       next if $name eq 'DETAIL' && $format =~ /update/;
1003
1004       my $filename = "$dir/".  $files{$file};
1005
1006       if ( $format =~ /update/ ) {
1007
1008         ( $error, $insertname, $deletename ) =
1009           _perform_cch_insert_delete_split( $name, $filename, $dir, $format )
1010           unless $error;
1011         last if $error;
1012
1013         unlink $filename or warn "Can't delete $filename: $!"
1014           unless $keep_cch_files;
1015         push @insert_list, $name, $insertname, $import_sub, $format;
1016         if ( $name eq 'GEOCODE' || $name eq 'CODE' ) { #handle this whole ordering issue better
1017           unshift @predelete_list, $name, $deletename, $import_sub, $format;
1018         } else {
1019           unshift @delete_list, $name, $deletename, $import_sub, $format;
1020         }
1021
1022       } else {
1023
1024         push @insert_list, $name, $filename, $import_sub, $format;
1025
1026       }
1027
1028     }
1029
1030     push @insert_list,
1031       'DETAIL', "$dir/".$files{detailfile}, \&FS::tax_rate::batch_import, $format
1032       if $format =~ /update/;
1033
1034     my %addl_param = ();
1035     if ( $param->{'delete_only'} ) {
1036       $addl_param{'delete_only'} = $param->{'delete_only'};
1037       @insert_list = () 
1038     }
1039
1040     $error ||= _perform_cch_tax_import( $job,
1041                                         [ @predelete_list ],
1042                                         [ @insert_list ],
1043                                         [ @delete_list ],
1044                                         \%addl_param,
1045     );
1046     
1047     
1048     @list = ( @predelete_list, @insert_list, @delete_list );
1049     while( !$keep_cch_files && scalar(@list) ) {
1050       my ( undef, $file, undef, undef ) = splice( @list, 0, 4 );
1051       unlink $file or warn "Can't delete $file: $!";
1052     }
1053
1054     if ($error) {
1055       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1056       die $error;
1057     }else{
1058       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1059     }
1060
1061   }else{
1062     die "Unknown format: $format";
1063   }
1064
1065 }
1066
1067
1068 sub _perform_cch_tax_import {
1069   my ( $job, $predelete_list, $insert_list, $delete_list, $addl_param ) = @_;
1070   $addl_param ||= {};
1071
1072   my $error = '';
1073   foreach my $list ($predelete_list, $insert_list, $delete_list) {
1074     while( scalar(@$list) ) {
1075       my ( $name, $file, $method, $format ) = splice( @$list, 0, 4 );
1076       my $fmt = "$format-update";
1077       $fmt = $format. ( lc($name) eq 'zip' ? '-zip' : '' );
1078       open my $fh, "< $file" or $error ||= "Can't open $name file $file: $!";
1079       my $param = { 'filehandle' => $fh,
1080                     'format'     => $fmt,
1081                     %$addl_param,
1082                   };
1083       $error ||= &{$method}($param, $job);
1084       close $fh;
1085     }
1086   }
1087
1088   return $error;
1089 }
1090
1091 sub _perform_cch_insert_delete_split {
1092   my ($name, $filename, $dir, $format) = @_;
1093
1094   my $error = '';
1095
1096   open my $fh, "< $filename"
1097     or $error ||= "Can't open $name file $filename: $!";
1098
1099   my $ifh = new File::Temp( TEMPLATE => "$name.insert.XXXXXXXX",
1100                             DIR      => $dir,
1101                             UNLINK   => 0,     #meh
1102                           ) or die "can't open temp file: $!\n";
1103   my $insertname = $ifh->filename;
1104
1105   my $dfh = new File::Temp( TEMPLATE => "$name.delete.XXXXXXXX",
1106                             DIR      => $dir,
1107                             UNLINK   => 0,     #meh
1108                           ) or die "can't open temp file: $!\n";
1109   my $deletename = $dfh->filename;
1110
1111   my $insert_pattern = ($format eq 'cch-update') ? qr/"I"\s*$/ : qr/I\s*$/;
1112   my $delete_pattern = ($format eq 'cch-update') ? qr/"D"\s*$/ : qr/D\s*$/;
1113   while(<$fh>) {
1114     my $handle = '';
1115     $handle = $ifh if $_ =~ /$insert_pattern/;
1116     $handle = $dfh if $_ =~ /$delete_pattern/;
1117     unless ($handle) {
1118       $error = "bad input line: $_" unless $handle;
1119       last;
1120     }
1121     print $handle $_;
1122   }
1123   close $fh;
1124   close $ifh;
1125   close $dfh;
1126
1127   return ($error, $insertname, $deletename);
1128 }
1129
1130 sub _perform_cch_diff {
1131   my ($name, $newdir, $olddir) = @_;
1132
1133   my %oldlines = ();
1134
1135   if ($olddir) {
1136     open my $oldcsvfh, "$olddir/$name.txt"
1137       or die "failed to open $olddir/$name.txt: $!\n";
1138
1139     while(<$oldcsvfh>) {
1140       chomp;
1141       $oldlines{$_} = 1;
1142     }
1143     close $oldcsvfh;
1144   }
1145
1146   open my $newcsvfh, "$newdir/$name.txt"
1147     or die "failed to open $newdir/$name.txt: $!\n";
1148     
1149   my $dfh = new File::Temp( TEMPLATE => "$name.diff.XXXXXXXX",
1150                             DIR      => "$newdir",
1151                             UNLINK   => 0,     #meh
1152                           ) or die "can't open temp file: $!\n";
1153   my $diffname = $dfh->filename;
1154
1155   while(<$newcsvfh>) {
1156     chomp;
1157     if (exists($oldlines{$_})) {
1158       $oldlines{$_} = 0;
1159     } else {
1160       print $dfh $_, ',"I"', "\n";
1161     }
1162   }
1163   close $newcsvfh;
1164
1165   #false laziness w/above (sub batch_import)
1166   my @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
1167                    excessrate effective_date taxauth taxtype taxcat taxname
1168                    usetax useexcessrate fee unittype feemax maxtype passflag
1169                    passtype basetype );
1170   my $numfields = scalar(@fields);
1171
1172   my $csv = new Text::CSV_XS { 'always_quote' => 1 };
1173
1174   for my $line (grep $oldlines{$_}, keys %oldlines) {
1175
1176     $csv->parse($line) or do {
1177       #$dbh->rollback if $oldAutoCommit;
1178       die "can't parse: ". $csv->error_input();
1179     };
1180     my @columns = $csv->fields();
1181     
1182     $csv->combine( splice(@columns, 0, $numfields) );
1183
1184     print $dfh $csv->string, ',"D"', "\n";
1185   }
1186
1187   close $dfh;
1188
1189   return $diffname;
1190 }
1191
1192 sub _cch_fetch_and_unzip {
1193   my ( $job, $urls, $secret, $dir ) = @_;
1194
1195   my $ua = new LWP::UserAgent;
1196   foreach my $url (split ',', $urls) {
1197     my @name = split '/', $url;  #somewhat restrictive
1198     my $name = pop @name;
1199     $name =~ /([\w.]+)/; # untaint that which we don't trust so much any more
1200     $name = $1;
1201       
1202     open my $taxfh, ">$dir/$name" or die "Can't open $dir/$name: $!\n";
1203      
1204     my ( $imported, $last, $min_sec ) = _progressbar_foo();
1205     my $res = $ua->request(
1206       new HTTP::Request( GET => $url ),
1207       sub {
1208             print $taxfh $_[0] or die "Can't write to $dir/$name: $!\n";
1209             my $content_length = $_[1]->content_length;
1210             $imported += length($_[0]);
1211             if ( time - $min_sec > $last ) {
1212               my $error = $job->update_statustext(
1213                 ($content_length ? int(100 * $imported/$content_length) : 0 ).
1214                 ",Downloading data from CCH"
1215               );
1216               die $error if $error;
1217               $last = time;
1218             }
1219       },
1220     );
1221     die "download of $url failed: ". $res->status_line
1222       unless $res->is_success;
1223       
1224     close $taxfh;
1225     my $error = $job->update_statustext( "0,Unpacking data" );
1226     die $error if $error;
1227     $secret =~ /([\w.]+)/; # untaint that which we don't trust so much any more
1228     $secret = $1;
1229     system('unzip', "-P", $secret, "-d", "$dir",  "$dir/$name") == 0
1230       or die "unzip -P $secret -d $dir $dir/$name failed";
1231     #unlink "$dir/$name";
1232   }
1233 }
1234  
1235 sub _cch_extract_csv_from_dbf {
1236   my ( $job, $dir, $name ) = @_;
1237
1238   eval "use XBase;";
1239   die $@ if $@;
1240
1241   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1242   my $error = $job->update_statustext( "0,Unpacking $name" );
1243   die $error if $error;
1244   warn "opening $dir.new/$name.dbf\n" if $DEBUG;
1245   my $table = new XBase 'name' => "$dir.new/$name.dbf";
1246   die "failed to access $dir.new/$name.dbf: ". XBase->errstr
1247     unless defined($table);
1248   my $count = $table->last_record; # approximately;
1249   open my $csvfh, ">$dir.new/$name.txt"
1250     or die "failed to open $dir.new/$name.txt: $!\n";
1251
1252   my $csv = new Text::CSV_XS { 'always_quote' => 1 };
1253   my @fields = $table->field_names;
1254   my $cursor = $table->prepare_select;
1255   my $format_date =
1256     sub { my $date = shift;
1257           $date =~ /^(\d{4})(\d{2})(\d{2})$/ && ($date = "$2/$3/$1");
1258           $date;
1259         };
1260   while (my $row = $cursor->fetch_hashref) {
1261     $csv->combine( map { my $type = $table->field_type($_);
1262                          if ($type eq 'D') {
1263                            &{$format_date}($row->{$_}) ;
1264                          } elsif ($type eq 'N' && $row->{$_} =~ /e-/i ) {
1265                            sprintf('%.8f', $row->{$_}); #db row is numeric(14,8)
1266                          } else {
1267                            $row->{$_};
1268                          }
1269                        }
1270                    @fields
1271     );
1272     print $csvfh $csv->string, "\n";
1273     $imported++;
1274     if ( time - $min_sec > $last ) {
1275       my $error = $job->update_statustext(
1276         int(100 * $imported/$count).  ",Unpacking $name"
1277       );
1278       die $error if $error;
1279       $last = time;
1280     }
1281   }
1282   $table->close;
1283   close $csvfh;
1284 }
1285
1286 sub _remember_disabled_taxes {
1287   my ( $job, $format, $disabled_tax_rate ) = @_;
1288
1289   # cch specific hash
1290
1291   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1292
1293   my @items = qsearch( { table   => 'tax_rate',
1294                          hashref => { disabled => 'Y',
1295                                       data_vendor => $format,
1296                                     },
1297                          select  => 'geocode, taxclassnum',
1298                        }
1299                      );
1300   my $count = scalar(@items);
1301   foreach my $tax_rate ( @items ) {
1302     if ( time - $min_sec > $last ) {
1303       $job->update_statustext(
1304         int( 100 * $imported / $count ). ",Remembering disabled taxes"
1305       );
1306       $last = time;
1307     }
1308     $imported++;
1309     my $tax_class =
1310       qsearchs( 'tax_class', { taxclassnum => $tax_rate->taxclassnum } );
1311     unless ( $tax_class ) {
1312       warn "failed to find tax_class ". $tax_rate->taxclassnum;
1313       next;
1314     }
1315     $disabled_tax_rate->{$tax_rate->geocode. ':'. $tax_class->taxclass} = 1;
1316   }
1317 }
1318
1319 sub _remember_tax_products {
1320   my ( $job, $format, $taxproduct ) = @_;
1321
1322   # XXX FIXME  this loop only works when cch is the only data provider
1323
1324   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1325
1326   my $extra_sql = "WHERE taxproductnum IS NOT NULL OR ".
1327                   "0 < ( SELECT count(*) from part_pkg_option WHERE ".
1328                   "       part_pkg_option.pkgpart = part_pkg.pkgpart AND ".
1329                   "       optionname LIKE 'usage_taxproductnum_%' AND ".
1330                   "       optionvalue != '' )";
1331   my @items = qsearch( { table => 'part_pkg',
1332                          select  => 'DISTINCT pkgpart,taxproductnum',
1333                          hashref => {},
1334                          extra_sql => $extra_sql,
1335                        }
1336                      );
1337   my $count = scalar(@items);
1338   foreach my $part_pkg ( @items ) {
1339     if ( time - $min_sec > $last ) {
1340       $job->update_statustext(
1341         int( 100 * $imported / $count ). ",Remembering tax products"
1342       );
1343       $last = time;
1344     }
1345     $imported++;
1346     warn "working with package part ". $part_pkg->pkgpart.
1347       "which has a taxproductnum of ". $part_pkg->taxproductnum. "\n" if $DEBUG;
1348     my $part_pkg_taxproduct = $part_pkg->taxproduct('');
1349     $taxproduct->{$part_pkg->pkgpart}->{''} = $part_pkg_taxproduct->taxproduct
1350       if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
1351
1352     foreach my $option ( $part_pkg->part_pkg_option ) {
1353       next unless $option->optionname =~ /^usage_taxproductnum_(\w+)$/;
1354       my $class = $1;
1355
1356       $part_pkg_taxproduct = $part_pkg->taxproduct($class);
1357       $taxproduct->{$part_pkg->pkgpart}->{$class} =
1358           $part_pkg_taxproduct->taxproduct
1359         if $part_pkg_taxproduct && $part_pkg_taxproduct->data_vendor eq $format;
1360     }
1361   }
1362 }
1363
1364 sub _restore_remembered_tax_products {
1365   my ( $job, $format, $taxproduct ) = @_;
1366
1367   # cch specific
1368
1369   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1370   my $count = scalar(keys %$taxproduct);
1371   foreach my $pkgpart ( keys %$taxproduct ) {
1372     warn "restoring taxproductnums on pkgpart $pkgpart\n" if $DEBUG;
1373     if ( time - $min_sec > $last ) {
1374       $job->update_statustext(
1375         int( 100 * $imported / $count ). ",Restoring tax products"
1376       );
1377       $last = time;
1378     }
1379     $imported++;
1380
1381     my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkgpart } );
1382     unless ( $part_pkg ) {
1383       return "somehow failed to find part_pkg with pkgpart $pkgpart!\n";
1384     }
1385
1386     my %options = $part_pkg->options;
1387     my %pkg_svc = map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc;
1388     my $primary_svc = $part_pkg->svcpart;
1389     my $new = new FS::part_pkg { $part_pkg->hash };
1390
1391     foreach my $class ( keys %{ $taxproduct->{$pkgpart} } ) {
1392       warn "working with class '$class'\n" if $DEBUG;
1393       my $part_pkg_taxproduct =
1394         qsearchs( 'part_pkg_taxproduct',
1395                   { taxproduct  => $taxproduct->{$pkgpart}->{$class},
1396                     data_vendor => $format,
1397                   }
1398                 );
1399
1400       unless ( $part_pkg_taxproduct ) {
1401         return "failed to find part_pkg_taxproduct (".
1402           $taxproduct->{$pkgpart}->{$class}. ") for pkgpart $pkgpart\n";
1403       }
1404
1405       if ( $class eq '' ) {
1406         $new->taxproductnum($part_pkg_taxproduct->taxproductnum);
1407         next;
1408       }
1409
1410       $options{"usage_taxproductnum_$class"} =
1411         $part_pkg_taxproduct->taxproductnum;
1412
1413     }
1414
1415     my $error = $new->replace( $part_pkg,
1416                                'pkg_svc' => \%pkg_svc,
1417                                'primary_svc' => $primary_svc,
1418                                'options' => \%options,
1419     );
1420       
1421     return $error if $error;
1422
1423   }
1424
1425   '';
1426 }
1427
1428 sub _restore_remembered_disabled_taxes {
1429   my ( $job, $format, $disabled_tax_rate ) = @_;
1430
1431   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1432   my $count = scalar(keys %$disabled_tax_rate);
1433   foreach my $key (keys %$disabled_tax_rate) {
1434     if ( time - $min_sec > $last ) {
1435       $job->update_statustext(
1436         int( 100 * $imported / $count ). ",Disabling tax rates"
1437       );
1438       $last = time;
1439     }
1440     $imported++;
1441     my ($geocode,$taxclass) = split /:/, $key, 2;
1442     my @tax_class = qsearch( 'tax_class', { data_vendor => $format,
1443                                             taxclass    => $taxclass,
1444                                           } );
1445     return "found multiple tax_class records for format $format class $taxclass"
1446       if scalar(@tax_class) > 1;
1447       
1448     unless (scalar(@tax_class)) {
1449       warn "no tax_class for format $format class $taxclass\n";
1450       next;
1451     }
1452
1453     my @tax_rate =
1454       qsearch('tax_rate', { data_vendor  => $format,
1455                             geocode      => $geocode,
1456                             taxclassnum  => $tax_class[0]->taxclassnum,
1457                           }
1458     );
1459
1460     if (scalar(@tax_rate) > 1) {
1461       return "found multiple tax_rate records for format $format geocode ".
1462              "$geocode and taxclass $taxclass ( taxclassnum ".
1463              $tax_class[0]->taxclassnum.  " )";
1464     }
1465       
1466     if (scalar(@tax_rate)) {
1467       $tax_rate[0]->disabled('Y');
1468       my $error = $tax_rate[0]->replace;
1469       return $error if $error;
1470     }
1471   }
1472 }
1473
1474 sub _remove_old_tax_data {
1475   my ( $job, $format ) = @_;
1476
1477   my $dbh = dbh;
1478   my $error = $job->update_statustext( "0,Removing old tax data" );
1479   die $error if $error;
1480
1481   my $sql = "UPDATE public.tax_rate_location SET disabled='Y' ".
1482     "WHERE data_vendor = ".  $dbh->quote($format);
1483   $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1484
1485   my @table = qw(
1486     tax_rate part_pkg_taxrate part_pkg_taxproduct tax_class cust_tax_location
1487   );
1488   foreach my $table ( @table ) {
1489     $sql = "DELETE FROM public.$table WHERE data_vendor = ".
1490       $dbh->quote($format);
1491     $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1492   }
1493
1494   if ( $format eq 'cch' ) {
1495     $sql = "DELETE FROM public.cust_tax_location WHERE data_vendor = ".
1496       $dbh->quote("$format-zip");
1497     $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1498   }
1499
1500   '';
1501 }
1502
1503 sub _create_temporary_tables {
1504   my ( $job, $format ) = @_;
1505
1506   my $dbh = dbh;
1507   my $error = $job->update_statustext( "0,Creating temporary tables" );
1508   die $error if $error;
1509
1510   my @table = qw( tax_rate
1511                   tax_rate_location
1512                   part_pkg_taxrate
1513                   part_pkg_taxproduct
1514                   tax_class
1515                   cust_tax_location
1516   );
1517   foreach my $table ( @table ) {
1518     my $sql =
1519       "CREATE TEMPORARY TABLE $table ( LIKE $table INCLUDING DEFAULTS )";
1520     $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1521   }
1522
1523   '';
1524 }
1525
1526 sub _copy_from_temp {
1527   my ( $job, $format ) = @_;
1528
1529   my $dbh = dbh;
1530   my $error = $job->update_statustext( "0,Making permanent" );
1531   die $error if $error;
1532
1533   my @table = qw( tax_rate
1534                   tax_rate_location
1535                   part_pkg_taxrate
1536                   part_pkg_taxproduct
1537                   tax_class
1538                   cust_tax_location
1539   );
1540   foreach my $table ( @table ) {
1541     my $sql =
1542       "INSERT INTO public.$table SELECT * from $table";
1543     $dbh->do($sql) or return "Failed to execute $sql: ". $dbh->errstr;
1544   }
1545
1546   '';
1547 }
1548
1549 =item process_download_and_reload
1550
1551 Download and process a tax update as a queued JSRPC job after wiping the
1552 existing wipable tax data.
1553
1554 =cut
1555
1556 sub process_download_and_reload {
1557   _process_reload('process_download_and_update', @_);
1558 }
1559
1560   
1561 =item process_batch_reload
1562
1563 Load and process a tax update from the provided files as a queued JSRPC job
1564 after wiping the existing wipable tax data.
1565
1566 =cut
1567
1568 sub process_batch_reload {
1569   _process_reload('_perform_batch_import', @_);
1570 }
1571
1572   
1573 sub _process_reload {
1574   my ( $method, $job ) = ( shift, shift );
1575
1576   my $param = thaw(decode_base64($_[0]));
1577   my $format = $param->{'format'};        #well... this is all cch specific
1578
1579   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1580
1581   if ( $job ) {  # progress bar
1582     my $error = $job->update_statustext( 0 );
1583     die $error if $error;
1584   }
1585
1586   my $oldAutoCommit = $FS::UID::AutoCommit;
1587   local $FS::UID::AutoCommit = 0;
1588   my $dbh = dbh;
1589   my $error = '';
1590
1591   my $sql =
1592     "SELECT count(*) FROM part_pkg_taxoverride JOIN tax_class ".
1593     "USING (taxclassnum) WHERE data_vendor = '$format'";
1594   my $sth = $dbh->prepare($sql) or die $dbh->errstr;
1595   $sth->execute
1596     or die "Unexpected error executing statement $sql: ". $sth->errstr;
1597   die "Don't (yet) know how to handle part_pkg_taxoverride records."
1598     if $sth->fetchrow_arrayref->[0];
1599
1600   # really should get a table EXCLUSIVE lock here
1601
1602   #remember disabled taxes
1603   my %disabled_tax_rate = ();
1604   $error ||= _remember_disabled_taxes( $job, $format, \%disabled_tax_rate );
1605
1606   #remember tax products
1607   my %taxproduct = ();
1608   $error ||= _remember_tax_products( $job, $format, \%taxproduct );
1609
1610   #create temp tables
1611   $error ||= _create_temporary_tables( $job, $format );
1612
1613   #import new data
1614   unless ($error) {
1615     my $args = '$job, @_';
1616     eval "$method($args);";
1617     $error = $@ if $@;
1618   }
1619
1620   #restore taxproducts
1621   $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
1622
1623   #disable tax_rates
1624   $error ||=
1625    _restore_remembered_disabled_taxes( $job, $format, \%disabled_tax_rate );
1626
1627   #wipe out the old data
1628   $error ||= _remove_old_tax_data( $job, $format ); 
1629
1630   #untemporize
1631   $error ||= _copy_from_temp( $job, $format );
1632
1633   if ($error) {
1634     $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1635     die $error;
1636   }
1637
1638   #success!
1639   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1640 }
1641
1642
1643 =item process_download_and_update
1644
1645 Download and process a tax update as a queued JSRPC job
1646
1647 =cut
1648
1649 sub process_download_and_update {
1650   my $job = shift;
1651
1652   my $param = thaw(decode_base64(shift));
1653   my $format = $param->{'format'};        #well... this is all cch specific
1654
1655   my ( $imported, $last, $min_sec ) = _progressbar_foo();
1656
1657   if ( $job ) {  # progress bar
1658     my $error = $job->update_statustext( 0);
1659     die $error if $error;
1660   }
1661
1662   my $cache_dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
1663   my $dir = $cache_dir. 'taxdata';
1664   unless (-d $dir) {
1665     mkdir $dir or die "can't create $dir: $!\n";
1666   }
1667
1668   if ($format eq 'cch') {
1669
1670     my @namelist = qw( code detail geocode plus4 txmatrix zip );
1671
1672     my $conf = new FS::Conf;
1673     die "direct download of tax data not enabled\n" 
1674       unless $conf->exists('taxdatadirectdownload');
1675     my ( $urls, $username, $secret, $states ) =
1676       $conf->config('taxdatadirectdownload');
1677     die "No tax download URL provided.  ".
1678         "Did you set the taxdatadirectdownload configuration value?\n"
1679       unless $urls;
1680
1681     $dir .= '/cch';
1682
1683     my $dbh = dbh;
1684     my $error = '';
1685
1686     # really should get a table EXCLUSIVE lock here
1687     # check if initial import or update
1688     #
1689     # relying on mkdir "$dir.new" as a mutex
1690     
1691     my $sql = "SELECT count(*) from tax_rate WHERE data_vendor='$format'";
1692     my $sth = $dbh->prepare($sql) or die $dbh->errstr;
1693     $sth->execute() or die $sth->errstr;
1694     my $update = $sth->fetchrow_arrayref->[0];
1695
1696     # create cache and/or rotate old tax data
1697
1698     if (-d $dir) {
1699
1700       if (-d "$dir.9") {
1701         opendir(my $dirh, "$dir.9") or die "failed to open $dir.9: $!\n";
1702         foreach my $file (readdir($dirh)) {
1703           unlink "$dir.9/$file" if (-f "$dir.9/$file");
1704         }
1705         closedir($dirh);
1706         rmdir "$dir.9";
1707       }
1708
1709       for (8, 7, 6, 5, 4, 3, 2, 1) {
1710         if ( -e "$dir.$_" ) {
1711           rename "$dir.$_", "$dir.". ($_+1) or die "can't rename $dir.$_: $!\n";
1712         }
1713       }
1714       rename "$dir", "$dir.1" or die "can't rename $dir: $!\n";
1715
1716     } else {
1717
1718       die "can't find previous tax data\n" if $update;
1719
1720     }
1721
1722     mkdir "$dir.new" or die "can't create $dir.new: $!\n";
1723     
1724     # fetch and unpack the zip files
1725
1726     _cch_fetch_and_unzip( $job, $urls, $secret, "$dir.new" );
1727  
1728     # extract csv files from the dbf files
1729
1730     foreach my $name ( @namelist ) {
1731       _cch_extract_csv_from_dbf( $job, $dir, $name ); 
1732     }
1733
1734     # generate the diff files
1735
1736     my @list = ();
1737     foreach my $name ( @namelist ) {
1738       my $difffile = "$dir.new/$name.txt";
1739       if ($update) {
1740         my $error = $job->update_statustext( "0,Comparing to previous $name" );
1741         die $error if $error;
1742         warn "processing $dir.new/$name.txt\n" if $DEBUG;
1743         my $olddir = $update ? "$dir.1" : "";
1744         $difffile = _perform_cch_diff( $name, "$dir.new", $olddir );
1745       }
1746       $difffile =~ s/^$cache_dir//;
1747       push @list, "${name}file:$difffile";
1748     }
1749
1750     # perform the import
1751     local $keep_cch_files = 1;
1752     $param->{uploaded_files} = join( ',', @list );
1753     $param->{format} .= '-update' if $update;
1754     $error ||=
1755       _perform_batch_import( $job, encode_base64( nfreeze( $param ) ) );
1756     
1757     rename "$dir.new", "$dir"
1758       or die "cch tax update processed, but can't rename $dir.new: $!\n";
1759
1760   }else{
1761     die "Unknown format: $format";
1762   }
1763 }
1764
1765 =item browse_queries PARAMS
1766
1767 Returns a list consisting of a hashref suited for use as the argument
1768 to qsearch, and sql query string.  Each is based on the PARAMS hashref
1769 of keys and values which frequently would be passed as C<scalar($cgi->Vars)>
1770 from a form.  This conveniently creates the query hashref and count_query
1771 string required by the browse and search elements.  As a side effect, 
1772 the PARAMS hashref is untainted and keys with unexpected values are removed.
1773
1774 =cut
1775
1776 sub browse_queries {
1777   my $params = shift;
1778
1779   my $query = {
1780                 'table'     => 'tax_rate',
1781                 'hashref'   => {},
1782                 'order_by'  => 'ORDER BY geocode, taxclassnum',
1783               },
1784
1785   my $extra_sql = '';
1786
1787   if ( $params->{data_vendor} =~ /^(\w+)$/ ) {
1788     $extra_sql .= ' WHERE data_vendor = '. dbh->quote($1);
1789   } else {
1790     delete $params->{data_vendor};
1791   }
1792    
1793   if ( $params->{geocode} =~ /^(\w+)$/ ) {
1794     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1795                     'geocode LIKE '. dbh->quote($1.'%');
1796   } else {
1797     delete $params->{geocode};
1798   }
1799
1800   if ( $params->{taxclassnum} =~ /^(\d+)$/ &&
1801        qsearchs( 'tax_class', {'taxclassnum' => $1} )
1802      )
1803   {
1804     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1805                   ' taxclassnum  = '. dbh->quote($1)
1806   } else {
1807     delete $params->{taxclassnun};
1808   }
1809
1810   my $tax_type = $1
1811     if ( $params->{tax_type} =~ /^(\d+)$/ );
1812   delete $params->{tax_type}
1813     unless $tax_type;
1814
1815   my $tax_cat = $1
1816     if ( $params->{tax_cat} =~ /^(\d+)$/ );
1817   delete $params->{tax_cat}
1818     unless $tax_cat;
1819
1820   my @taxclassnum = ();
1821   if ($tax_type || $tax_cat ) {
1822     my $compare = "LIKE '". ( $tax_type || "%" ). ":". ( $tax_cat || "%" ). "'";
1823     $compare = "= '$tax_type:$tax_cat'" if ($tax_type && $tax_cat);
1824     @taxclassnum = map { $_->taxclassnum } 
1825                    qsearch({ 'table'     => 'tax_class',
1826                              'hashref'   => {},
1827                              'extra_sql' => "WHERE taxclass $compare",
1828                           });
1829   }
1830
1831   $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ). '( '.
1832                 join(' OR ', map { " taxclassnum  = $_ " } @taxclassnum ). ' )'
1833     if ( @taxclassnum );
1834
1835   unless ($params->{'showdisabled'}) {
1836     $extra_sql .= ( $extra_sql =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
1837                   "( disabled = '' OR disabled IS NULL )";
1838   }
1839
1840   $query->{extra_sql} = $extra_sql;
1841
1842   return ($query, "SELECT COUNT(*) FROM tax_rate $extra_sql");
1843 }
1844
1845 =item queue_liability_report PARAMS
1846
1847 Launches a tax liability report.
1848
1849 PARAMS needs to be a base64-encoded Storable hash containing:
1850 - beginning: the start date, as a I<user-readable string> (not a timestamp).
1851 - end: the end date of the report, likewise.
1852 - agentnum: the agent to limit the report to, if any.
1853
1854 =cut
1855
1856 sub queue_liability_report {
1857   my $job = shift;
1858   my $param = thaw(decode_base64(shift));
1859
1860   my $cgi = new CGI;
1861   $cgi->param('beginning', $param->{beginning});
1862   $cgi->param('ending', $param->{ending});
1863   my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
1864   my $agentnum = $param->{agentnum};
1865   if ($agentnum =~ /^(\d+)$/) { $agentnum = $1; } else { $agentnum = ''; };
1866   generate_liability_report(
1867     'beginning' => $beginning,
1868     'ending'    => $ending,
1869     'agentnum'  => $agentnum,
1870     'p'         => $param->{RootURL},
1871     'job'       => $job,
1872   );
1873 }
1874
1875 =item generate_liability_report PARAMS
1876
1877 Generates a tax liability report.  PARAMS must include:
1878
1879 - beginning, as a timestamp
1880 - ending, as a timestamp
1881 - p: the Freeside root URL, for generating links
1882 - agentnum (optional)
1883
1884 =cut
1885
1886 #shit, all sorts of false laxiness w/report_newtax.cgi
1887 sub generate_liability_report {
1888   my %args = @_;
1889
1890   my ( $count, $last, $min_sec ) = _progressbar_foo();
1891
1892   #let us open the temp file early
1893   my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
1894   my $report = new File::Temp( TEMPLATE => 'report.tax.liability.XXXXXXXX',
1895                                DIR      => $dir,
1896                                UNLINK   => 0, # not so temp
1897                              ) or die "can't open report file: $!\n";
1898
1899   my $conf = new FS::Conf;
1900   my $money_char = $conf->config('money_char') || '$';
1901
1902   my $join_cust = "
1903       JOIN cust_bill USING ( invnum ) 
1904       LEFT JOIN cust_main USING ( custnum )
1905   ";
1906
1907   my $join_loc =
1908     "LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum )";
1909   my $join_tax_loc = "LEFT JOIN tax_rate_location USING ( taxratelocationnum )";
1910
1911   my $addl_from = " $join_cust $join_loc $join_tax_loc "; 
1912
1913   my $where = "WHERE _date >= $args{beginning} AND _date <= $args{ending} ";
1914
1915   my $agentname = '';
1916   if ( $args{agentnum} =~ /^(\d+)$/ ) {
1917     my $agent = qsearchs('agent', { 'agentnum' => $1 } );
1918     die "agent not found" unless $agent;
1919     $agentname = $agent->agent;
1920     $where .= ' AND cust_main.agentnum = '. $agent->agentnum;
1921   }
1922
1923   #my @taxparam = ( 'itemdesc', 'tax_rate_location.state', 'tax_rate_location.county', 'tax_rate_location.city', 'cust_bill_pkg_tax_rate_location.locationtaxid' );
1924   my @taxparams = qw( city county state locationtaxid );
1925   my @params = ('itemdesc', @taxparams);
1926
1927   my $select = 'DISTINCT itemdesc,locationtaxid,tax_rate_location.state,tax_rate_location.county,tax_rate_location.city';
1928
1929   #false laziness w/FS::Report::Table::Monthly (sub should probably be moved up
1930   #to FS::Report or FS::Record or who the fuck knows where)
1931   my $scalar_sql = sub {
1932     my( $r, $param, $sql ) = @_;
1933     my $sth = dbh->prepare($sql) or die dbh->errstr;
1934     $sth->execute( map $r->$_(), @$param )
1935       or die "Unexpected error executing statement $sql: ". $sth->errstr;
1936     $sth->fetchrow_arrayref->[0] || 0;
1937   };
1938
1939   my $tax = 0;
1940   my $credit = 0;
1941   my %taxes = ();
1942   my %basetaxes = ();
1943   my $calculated = 0;
1944
1945   # get all distinct tuples of (tax name, state, county, city, locationtaxid)
1946   # for taxes that have been charged
1947   # (state, county, city are from tax_rate_location, not from customer data)
1948   my @tax_and_location = qsearch({ table     => 'cust_bill_pkg',
1949                                    select    => $select,
1950                                    hashref   => { pkgpart => 0 },
1951                                    addl_from => $addl_from,
1952                                    extra_sql => $where,
1953                                    debug     => 1,
1954                                 });
1955   $count = scalar(@tax_and_location);
1956   foreach my $t ( @tax_and_location ) {
1957
1958     if ( $args{job} ) {
1959       if ( time - $min_sec > $last ) {
1960         $args{job}->update_statustext( int( 100 * $calculated / $count ).
1961                                        ",Calculating"
1962                                      );
1963         $last = time;
1964       }
1965     }
1966
1967     #my @params = map { my $f = $_; $f =~ s/.*\.//; $f } @taxparam;
1968     my $label = join('~', map { $t->$_ } @params);
1969     $label = 'Tax'. $label if $label =~ /^~/;
1970     unless ( exists( $taxes{$label} ) ) {
1971       my ($baselabel, @trash) = split /~/, $label;
1972
1973       $taxes{$label}->{'label'} = join(', ', split(/~/, $label) );
1974       $taxes{$label}->{'url_param'} =
1975         join(';', map { "$_=". uri_escape($t->$_) } @params);
1976
1977       my $itemdesc_loc = 
1978       # "    payby != 'COMP' ". # breaks the entire report under 4.x
1979       #                         # and unnecessary since COMP accounts don't
1980       #                         # get taxes calculated in the first place
1981         "    ( itemdesc = ? OR ? = '' AND itemdesc IS NULL ) ".
1982         "AND ". FS::tax_rate_location->location_sql( map { $_ => $t->$_ }
1983                                                          @taxparams
1984                                                    );
1985
1986       my $taxwhere =
1987         "FROM cust_bill_pkg $addl_from $where AND $itemdesc_loc";
1988
1989       my $sql = "SELECT SUM(amount) $taxwhere AND cust_bill_pkg.pkgnum = 0";
1990
1991       my $x = &{$scalar_sql}($t, [ 'itemdesc', 'itemdesc' ], $sql );
1992       $tax += $x;
1993       $taxes{$label}->{'tax'} += $x;
1994
1995       my $creditfrom =
1996        "JOIN cust_credit_bill_pkg USING (billpkgnum,billpkgtaxratelocationnum)";
1997       my $creditwhere =
1998         "FROM cust_bill_pkg $addl_from $creditfrom $where AND $itemdesc_loc";
1999
2000       $sql = "SELECT SUM(cust_credit_bill_pkg.amount) ".
2001              " $creditwhere AND cust_bill_pkg.pkgnum = 0";
2002
2003       my $y = &{$scalar_sql}($t, [ 'itemdesc', 'itemdesc' ], $sql );
2004       $credit += $y;
2005       $taxes{$label}->{'credit'} += $y;
2006
2007       unless ( exists( $taxes{$baselabel} ) ) {
2008
2009         $basetaxes{$baselabel}->{'label'} = $baselabel;
2010         $basetaxes{$baselabel}->{'url_param'} = "itemdesc=$baselabel";
2011         $basetaxes{$baselabel}->{'base'} = 1;
2012
2013       }
2014
2015       $basetaxes{$baselabel}->{'tax'} += $x;
2016       $basetaxes{$baselabel}->{'credit'} += $y;
2017       
2018     }
2019
2020     # calculate customer-exemption for this tax
2021     # calculate package-exemption for this tax
2022     # calculate monthly exemption (texas tax) for this tax
2023     # count up all the cust_tax_exempt_pkg records associated with
2024     # the actual line items.
2025   }
2026
2027
2028   #ordering
2029
2030   if ( $args{job} ) {
2031     $args{job}->update_statustext( "0,Sorted" );
2032     $last = time;
2033   }
2034
2035   my @taxes = ();
2036
2037   foreach my $tax ( sort { $a cmp $b } keys %taxes ) {
2038     my ($base, @trash) = split '~', $tax;
2039     my $basetax = delete( $basetaxes{$base} );
2040     if ($basetax) {
2041       if ( $basetax->{tax} == $taxes{$tax}->{tax} ) {
2042         $taxes{$tax}->{base} = 1;
2043       } else {
2044         push @taxes, $basetax;
2045       }
2046     }
2047     push @taxes, $taxes{$tax};
2048   }
2049
2050   push @taxes, {
2051     'label'          => 'Total',
2052     'url_param'      => '',
2053     'tax'            => $tax,
2054     'credit'         => $credit,
2055     'base'           => 1,
2056   };
2057
2058
2059   my $dateagentlink = "begin=$args{beginning};end=$args{ending}";
2060   $dateagentlink .= ';agentnum='. $args{agentnum}
2061     if length($agentname);
2062   my $baselink   = $args{p}. "search/cust_bill_pkg.cgi?vendortax=1;" .
2063                              $dateagentlink;
2064   my $creditlink = $args{p}. "search/cust_credit_bill_pkg.html?$dateagentlink";
2065
2066   print $report <<EOF;
2067   
2068     <% include("/elements/header.html", "$agentname Tax Report - ".
2069                   ( $args{beginning}
2070                       ? time2str('%h %o %Y ', $args{beginning} )
2071                       : ''
2072                   ).
2073                   'through '.
2074                   ( $args{ending} == 4294967295
2075                       ? 'now'
2076                       : time2str('%h %o %Y', $args{ending} )
2077                   )
2078               )
2079     %>
2080
2081     <% include('/elements/table-grid.html') %>
2082
2083     <TR>
2084       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2085       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2086       <TH CLASS="grid" BGCOLOR="#cccccc">Tax invoiced</TH>
2087       <TH CLASS="grid" BGCOLOR="#cccccc">&nbsp;&nbsp;&nbsp;&nbsp;</TH>
2088       <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
2089       <TH CLASS="grid" BGCOLOR="#cccccc">Tax credited</TH>
2090     </TR>
2091 EOF
2092
2093   my $bgcolor1 = '#eeeeee';
2094   my $bgcolor2 = '#ffffff';
2095   my $bgcolor = '';
2096  
2097   $count = scalar(@taxes);
2098   $calculated = 0;
2099   foreach my $tax ( @taxes ) {
2100  
2101     if ( $args{job} ) {
2102       if ( time - $min_sec > $last ) {
2103         $args{job}->update_statustext( int( 100 * $calculated / $count ).
2104                                        ",Generated"
2105                                      );
2106         $last = time;
2107       }
2108     }
2109
2110     if ( $bgcolor eq $bgcolor1 ) {
2111       $bgcolor = $bgcolor2;
2112     } else {
2113       $bgcolor = $bgcolor1;
2114     }
2115  
2116     my $link = '';
2117     if ( $tax->{'label'} ne 'Total' ) {
2118       $link = ';'. $tax->{'url_param'};
2119     }
2120  
2121     print $report <<EOF;
2122       <TR>
2123         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"><% '$tax->{label}' %></TD>
2124         <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2125         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
2126           <A HREF="<% '$baselink$link' %>;istax=1"><% '$money_char' %><% sprintf('%.2f', $tax->{'tax'} ) %></A>
2127         </TD>
2128         <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2129         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>"></TD>
2130         <% ($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2131         <TD CLASS="grid" BGCOLOR="<% '$bgcolor' %>" ALIGN="right">
2132           <A HREF="<% '$creditlink$link' %>;istax=1;iscredit=rate"><% '$money_char' %><% sprintf('%.2f', $tax->{'credit'} ) %></A>
2133         </TD>
2134         <% !($tax->{base}) ? qq!<TD CLASS="grid" BGCOLOR="$bgcolor"></TD>! : '' %>
2135       </TR>
2136 EOF
2137   } 
2138
2139   print $report <<EOF;
2140     </TABLE>
2141
2142     </BODY>
2143     </HTML>
2144 EOF
2145
2146   my $reportname = $report->filename;
2147   close $report;
2148
2149   my $dropstring = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/report.';
2150   $reportname =~ s/^$dropstring//;
2151
2152   my $reporturl = "%%%ROOTURL%%%/misc/queued_report?report=$reportname";
2153   die "<a href=$reporturl>view</a>\n";
2154
2155 }
2156
2157
2158
2159 =back
2160
2161 =head1 BUGS
2162
2163   Mixing automatic and manual editing works poorly at present.
2164
2165   Tax liability calculations take too long and arguably don't belong here.
2166   Tax liability report generation not entirely safe (escaped).
2167
2168 =head1 SEE ALSO
2169
2170 L<FS::Record>, L<FS::cust_location>, L<FS::cust_bill>
2171
2172 =cut
2173
2174 1;
2175