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