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