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