checkpoint of new tax rating system
[freeside.git] / FS / FS / tax_rate.pm
1 package FS::tax_rate;
2
3 use strict;
4 use vars qw( @ISA @EXPORT_OK $conf $DEBUG $me
5              %tax_unittypes %tax_maxtypes %tax_basetypes %tax_authorities
6              %tax_passtypes
7              @tax_rate %tax_rate $countyflag );
8 use Exporter;
9 use Date::Parse;
10 use Tie::IxHash;
11 use FS::Record qw( qsearchs qsearch dbh );
12 use FS::tax_class;
13
14 @ISA = qw( FS::Record );
15 @EXPORT_OK = qw( regionselector );
16
17 $DEBUG = 1;
18 $me = '[FS::tax_rate]';
19
20 @tax_rate = ();
21 $countyflag = '';
22
23 #ask FS::UID to run this stuff for us later
24 $FS::UID::callback{'FS::tax_rate'} = sub { 
25   $conf = new FS::Conf;
26 };
27
28 =head1 NAME
29
30 FS::tax_rate - Object methods for tax_rate objects
31
32 =head1 SYNOPSIS
33
34   use FS::tax_rate;
35
36   $record = new FS::tax_rate \%hash;
37   $record = new FS::tax_rate { 'column' => 'value' };
38
39   $error = $record->insert;
40
41   $error = $new_record->replace($old_record);
42
43   $error = $record->delete;
44
45   $error = $record->check;
46
47   ($county_html, $state_html, $country_html) =
48     FS::tax_rate::regionselector( $county, $state, $country );
49
50 =head1 DESCRIPTION
51
52 An FS::tax_rate object represents a tax rate, defined by locale.
53 FS::tax_rate inherits from FS::Record.  The following fields are
54 currently supported:
55
56 =over 4
57
58 =item taxnum
59
60 primary key (assigned automatically for new tax rates)
61
62 =item geocode
63
64 a geographic location code provided by a tax data vendor
65
66 =item data_vendor
67
68 the tax data vendor
69
70 =item location
71
72 a location code provided by a tax authority
73
74 =item taxclassnum
75
76 a foreign key into FS::tax_class - the type of tax
77 referenced but FS::part_pkg_taxrate
78
79 =item effective_date
80
81 the time after which the tax applies
82
83 =item tax
84
85 percentage
86
87 =item excessrate
88
89 second bracket percentage 
90
91 =item taxbase
92
93 the amount to which the tax applies (first bracket)
94
95 =item taxmax
96
97 a cap on the amount of tax if a cap exists
98
99 =item usetax
100
101 percentage on out of jurisdiction purchases
102
103 =item useexcessrate
104
105 second bracket percentage on out of jurisdiction purchases
106
107 =item unittype
108
109 one of the values in %tax_unittypes
110
111 =item fee
112
113 amount of tax per unit
114
115 =item excessfee
116
117 second bracket amount of tax per unit
118
119 =item feebase
120
121 the number of units to which the fee applies (first bracket)
122
123 =item feemax
124
125 the most units to which fees apply (first and second brackets)
126
127 =item maxtype
128
129 a value from %tax_maxtypes indicating how brackets accumulate (i.e. monthly, per invoice, etc)
130
131 =item taxname
132
133 if defined, printed on invoices instead of "Tax"
134
135 =item taxauth
136
137 a value from %tax_authorities
138
139 =item basetype
140
141 a value from %tax_basetypes indicating the tax basis
142
143 =item passtype
144
145 a value from %tax_passtypes indicating how the tax should displayed to the customer
146
147 =item passflag
148
149 'Y', 'N', or blank indicating the tax can be passed to the customer
150
151 =item setuptax
152
153 if 'Y', this tax does not apply to setup fees
154
155 =item recurtax
156
157 if 'Y', this tax does not apply to recurring fees
158
159 =item manual
160
161 if 'Y', has been manually edited
162
163 =back
164
165 =head1 METHODS
166
167 =over 4
168
169 =item new HASHREF
170
171 Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.
172
173 =cut
174
175 sub table { 'tax_rate'; }
176
177 =item insert
178
179 Adds this tax rate to the database.  If there is an error, returns the error,
180 otherwise returns false.
181
182 =item delete
183
184 Deletes this tax rate from the database.  If there is an error, returns the
185 error, otherwise returns false.
186
187 =item replace OLD_RECORD
188
189 Replaces the OLD_RECORD with this one in the database.  If there is an error,
190 returns the error, otherwise returns false.
191
192 =item check
193
194 Checks all fields to make sure this is a valid tax rate.  If there is an error,
195 returns the error, otherwise returns false.  Called by the insert and replace
196 methods.
197
198 =cut
199
200 sub check {
201   my $self = shift;
202
203   foreach (qw( taxbase taxmax )) {
204     $self->$_(0) unless $self->$_;
205   }
206
207   $self->ut_numbern('taxnum')
208     || $self->ut_text('geocode')
209     || $self->ut_textn('data_vendor')
210     || $self->ut_textn('location')
211     || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
212     || $self->ut_numbern('effective_date')
213     || $self->ut_float('tax')
214     || $self->ut_floatn('excessrate')
215     || $self->ut_money('taxbase')
216     || $self->ut_money('taxmax')
217     || $self->ut_floatn('usetax')
218     || $self->ut_floatn('useexcessrate')
219     || $self->ut_numbern('unittype')
220     || $self->ut_floatn('fee')
221     || $self->ut_floatn('excessfee')
222     || $self->ut_floatn('feemax')
223     || $self->ut_numbern('maxtype')
224     || $self->ut_textn('taxname')
225     || $self->ut_numbern('taxauth')
226     || $self->ut_numbern('basetype')
227     || $self->ut_numbern('passtype')
228     || $self->ut_enum('passflag', [ '', 'Y', 'N' ])
229     || $self->ut_enum('setuptax', [ '', 'Y' ] )
230     || $self->ut_enum('recurtax', [ '', 'Y' ] )
231     || $self->ut_enum('manual', [ '', 'Y' ] )
232     || $self->SUPER::check
233     ;
234
235 }
236
237 =item taxclass_description
238
239 Returns the human understandable value associated with the related
240 FS::tax_class.
241
242 =cut
243
244 sub taxclass_description {
245   my $self = shift;
246   my $tax_class = qsearchs('tax_class', {'taxclassnum' => $self->taxclassnum });
247   $tax_class ? $tax_class->description : '';
248 }
249
250 =item unittype_name
251
252 Returns the human understandable value associated with the unittype column
253
254 =cut
255
256 %tax_unittypes = ( '0' => 'access line',
257                    '1' => 'minute',
258                    '2' => 'account',
259 );
260
261 sub unittype_name {
262   my $self = shift;
263   $tax_unittypes{$self->unittype};
264 }
265
266 =item maxtype_name
267
268 Returns the human understandable value associated with the maxtype column
269
270 =cut
271
272 %tax_maxtypes = ( '0' => 'receipts per invoice',
273                   '1' => 'receipts per item',
274                   '2' => 'total utility charges per utility tax year',
275                   '3' => 'total charges per utility tax year',
276                   '4' => 'receipts per access line',
277                   '9' => 'monthly receipts per location',
278 );
279
280 sub maxtype_name {
281   my $self = shift;
282   $tax_maxtypes{$self->maxtype};
283 }
284
285 =item basetype_name
286
287 Returns the human understandable value associated with the basetype column
288
289 =cut
290
291 %tax_basetypes = ( '0'  => 'sale price',
292                    '1'  => 'gross receipts',
293                    '2'  => 'sales taxable telecom revenue',
294                    '3'  => 'minutes carried',
295                    '4'  => 'minutes billed',
296                    '5'  => 'gross operating revenue',
297                    '6'  => 'access line',
298                    '7'  => 'account',
299                    '8'  => 'gross revenue',
300                    '9'  => 'portion gross receipts attributable to interstate service',
301                    '10' => 'access line',
302                    '11' => 'gross profits',
303                    '12' => 'tariff rate',
304                    '14' => 'account',
305 );
306
307 sub basetype_name {
308   my $self = shift;
309   $tax_basetypes{$self->basetype};
310 }
311
312 =item taxauth_name
313
314 Returns the human understandable value associated with the taxauth column
315
316 =cut
317
318 %tax_authorities = ( '0' => 'federal',
319                      '1' => 'state',
320                      '2' => 'county',
321                      '3' => 'city',
322                      '4' => 'local',
323                      '5' => 'county administered by state',
324                      '6' => 'city administered by state',
325                      '7' => 'city administered by county',
326                      '8' => 'local administered by state',
327                      '9' => 'local administered by county',
328 );
329
330 sub taxauth_name {
331   my $self = shift;
332   $tax_authorities{$self->taxauth};
333 }
334
335 =item passtype_name
336
337 Returns the human understandable value associated with the passtype column
338
339 =cut
340
341 %tax_passtypes = ( '0' => 'separate tax line',
342                    '1' => 'separate surcharge line',
343                    '2' => 'surcharge not separated',
344                    '3' => 'included in base rate',
345 );
346
347 sub passtype_name {
348   my $self = shift;
349   $tax_passtypes{$self->passtype};
350 }
351
352 =back
353
354 =head1 SUBROUTINES
355
356 =over 4
357
358 =item regionselector [ COUNTY STATE COUNTRY [ PREFIX [ ONCHANGE [ DISABLED ] ] ] ]
359
360 =cut
361
362 sub regionselector {
363   my ( $selected_county, $selected_state, $selected_country,
364        $prefix, $onchange, $disabled ) = @_;
365
366   $prefix = '' unless defined $prefix;
367
368   $countyflag = 0;
369
370 #  unless ( @tax_rate ) { #cache 
371     @tax_rate = qsearch('tax_rate', {} );
372     foreach my $c ( @tax_rate ) {
373       $countyflag=1 if $c->county;
374       #push @{$tax_rate{$c->country}{$c->state}}, $c->county;
375       $tax_rate{$c->country}{$c->state}{$c->county} = 1;
376     }
377 #  }
378   $countyflag=1 if $selected_county;
379
380   my $script_html = <<END;
381     <SCRIPT>
382     function opt(what,value,text) {
383       var optionName = new Option(text, value, false, false);
384       var length = what.length;
385       what.options[length] = optionName;
386     }
387     function ${prefix}country_changed(what) {
388       country = what.options[what.selectedIndex].text;
389       for ( var i = what.form.${prefix}state.length; i >= 0; i-- )
390           what.form.${prefix}state.options[i] = null;
391 END
392       #what.form.${prefix}state.options[0] = new Option('', '', false, true);
393
394   foreach my $country ( sort keys %tax_rate ) {
395     $script_html .= "\nif ( country == \"$country\" ) {\n";
396     foreach my $state ( sort keys %{$tax_rate{$country}} ) {
397       ( my $dstate = $state ) =~ s/[\n\r]//g;
398       my $text = $dstate || '(n/a)';
399       $script_html .= qq!opt(what.form.${prefix}state, "$dstate", "$text");\n!;
400     }
401     $script_html .= "}\n";
402   }
403
404   $script_html .= <<END;
405     }
406     function ${prefix}state_changed(what) {
407 END
408
409   if ( $countyflag ) {
410     $script_html .= <<END;
411       state = what.options[what.selectedIndex].text;
412       country = what.form.${prefix}country.options[what.form.${prefix}country.selectedIndex].text;
413       for ( var i = what.form.${prefix}county.length; i >= 0; i-- )
414           what.form.${prefix}county.options[i] = null;
415 END
416
417     foreach my $country ( sort keys %tax_rate ) {
418       $script_html .= "\nif ( country == \"$country\" ) {\n";
419       foreach my $state ( sort keys %{$tax_rate{$country}} ) {
420         $script_html .= "\nif ( state == \"$state\" ) {\n";
421           #foreach my $county ( sort @{$tax_rate{$country}{$state}} ) {
422           foreach my $county ( sort keys %{$tax_rate{$country}{$state}} ) {
423             my $text = $county || '(n/a)';
424             $script_html .=
425               qq!opt(what.form.${prefix}county, "$county", "$text");\n!;
426           }
427         $script_html .= "}\n";
428       }
429       $script_html .= "}\n";
430     }
431   }
432
433   $script_html .= <<END;
434     }
435     </SCRIPT>
436 END
437
438   my $county_html = $script_html;
439   if ( $countyflag ) {
440     $county_html .= qq!<SELECT NAME="${prefix}county" onChange="$onchange" $disabled>!;
441     $county_html .= '</SELECT>';
442   } else {
443     $county_html .=
444       qq!<INPUT TYPE="hidden" NAME="${prefix}county" VALUE="$selected_county">!;
445   }
446
447   my $state_html = qq!<SELECT NAME="${prefix}state" !.
448                    qq!onChange="${prefix}state_changed(this); $onchange" $disabled>!;
449   foreach my $state ( sort keys %{ $tax_rate{$selected_country} } ) {
450     my $text = $state || '(n/a)';
451     my $selected = $state eq $selected_state ? 'SELECTED' : '';
452     $state_html .= qq(\n<OPTION $selected VALUE="$state">$text</OPTION>);
453   }
454   $state_html .= '</SELECT>';
455
456   $state_html .= '</SELECT>';
457
458   my $country_html = qq!<SELECT NAME="${prefix}country" !.
459                      qq!onChange="${prefix}country_changed(this); $onchange" $disabled>!;
460   my $countrydefault = $conf->config('countrydefault') || 'US';
461   foreach my $country (
462     sort { ($b eq $countrydefault) <=> ($a eq $countrydefault) or $a cmp $b }
463       keys %tax_rate
464   ) {
465     my $selected = $country eq $selected_country ? ' SELECTED' : '';
466     $country_html .= qq(\n<OPTION$selected VALUE="$country">$country</OPTION>");
467   }
468   $country_html .= '</SELECT>';
469
470   ($county_html, $state_html, $country_html);
471
472 }
473
474 sub batch_import {
475   my $param = shift;
476
477   my $fh = $param->{filehandle};
478   my $format = $param->{'format'};
479
480   my @fields;
481   my $hook;
482   if ( $format eq 'cch' ) {
483     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
484                   excessrate effective_date taxauth taxtype taxcat taxname
485                   usetax useexcessrate fee unittype feemax maxtype passflag
486                   passtype basetype );
487     $hook = sub {
488       my $hash = shift;
489
490       $hash->{'effective_date'} = str2time($hash->{'effective_date'});
491
492       my $taxclassid =
493         join(':', map{ $hash->{$_} } qw(taxtype taxcat) );
494
495       my %tax_class = ( 'data_vendor'  => 'cch', 
496                         'taxclass' => $taxclassid,
497                       );
498
499       my $tax_class = qsearchs( 'tax_class', \%tax_class );
500       return "Error inserting tax rate: no tax class $taxclassid"
501         unless $tax_class;
502
503       $hash->{'taxclassnum'} = $tax_class->taxclassnum;
504
505       foreach (qw( inoutcity inoutlocal taxtype taxcat )) {
506         delete($hash->{$_});
507       }
508
509       my %passflagmap = ( '0' => '',
510                           '1' => 'Y',
511                           '2' => 'N',
512                         );
513       $hash->{'passflag'} = $passflagmap{$hash->{'passflag'}}
514         if exists $passflagmap{$hash->{'passflag'}};
515
516       foreach (keys %$hash) {
517         $hash->{$_} = substr($hash->{$_}, 0, 80)
518           if length($hash->{$_}) > 80;
519       }
520
521     };
522
523   } elsif ( $format eq 'extended' ) {
524     die "unimplemented\n";
525     @fields = qw( );
526     $hook = sub {};
527   } else {
528     die "unknown format $format";
529   }
530
531   eval "use Text::CSV_XS;";
532   die $@ if $@;
533
534   my $csv = new Text::CSV_XS;
535
536   my $imported = 0;
537
538   local $SIG{HUP} = 'IGNORE';
539   local $SIG{INT} = 'IGNORE';
540   local $SIG{QUIT} = 'IGNORE';
541   local $SIG{TERM} = 'IGNORE';
542   local $SIG{TSTP} = 'IGNORE';
543   local $SIG{PIPE} = 'IGNORE';
544
545   my $oldAutoCommit = $FS::UID::AutoCommit;
546   local $FS::UID::AutoCommit = 0;
547   my $dbh = dbh;
548   
549   my $line;
550   while ( defined($line=<$fh>) ) {
551     $csv->parse($line) or do {
552       $dbh->rollback if $oldAutoCommit;
553       return "can't parse: ". $csv->error_input();
554     };
555
556     warn "$me batch_import: $imported\n" 
557       if (!($imported % 100) && $DEBUG);
558
559     my @columns = $csv->fields();
560
561     my %tax_rate = ( 'data_vendor' => $format );
562     foreach my $field ( @fields ) {
563       $tax_rate{$field} = shift @columns; 
564     }
565     my $error = &{$hook}(\%tax_rate);
566     if ( $error ) {
567       $dbh->rollback if $oldAutoCommit;
568       return $error;
569     }
570
571     my $tax_rate = new FS::tax_rate( \%tax_rate );
572     $error = $tax_rate->insert;
573
574     if ( $error ) {
575       $dbh->rollback if $oldAutoCommit;
576       return "can't insert tax_rate for $line: $error";
577     }
578
579     $imported++;
580   }
581
582   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
583
584   return "Empty file!" unless $imported;
585
586   ''; #no error
587
588 }
589
590 =back
591
592 =head1 BUGS
593
594 regionselector?  putting web ui components in here?  they should probably live
595 somewhere else...
596
597 =head1 SEE ALSO
598
599 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
600 documentation.
601
602 =cut
603
604 1;
605