autodownload and update of cch tax data
[freeside.git] / FS / FS / part_pkg_taxrate.pm
1 package FS::part_pkg_taxrate;
2
3 use strict;
4 use vars qw( @ISA );
5 use Date::Parse;
6 use FS::UID qw(dbh);
7 use FS::Record qw( qsearch qsearchs );
8 use FS::part_pkg_taxproduct;
9 use FS::Misc qw(csv_from_fixed);
10
11 @ISA = qw(FS::Record);
12
13 =head1 NAME
14
15 FS::part_pkg_taxrate - Object methods for part_pkg_taxrate records
16
17 =head1 SYNOPSIS
18
19   use FS::part_pkg_taxrate;
20
21   $record = new FS::part_pkg_taxrate \%hash;
22   $record = new FS::part_pkg_taxrate { 'column' => 'value' };
23
24   $error = $record->insert;
25
26   $error = $new_record->replace($old_record);
27
28   $error = $record->delete;
29
30   $error = $record->check;
31
32 =head1 DESCRIPTION
33
34 An FS::part_pkg_taxrate object maps packages onto tax rates.
35 FS::part_pkg_taxrate inherits from FS::Record.  The following fields are
36 currently supported:
37
38 =over 4
39
40 =item pkgtaxratenum
41
42 Primary key
43
44 =item data_vendor
45
46 Tax data vendor
47
48 =item geocode
49
50 Tax vendor location code
51
52 =item taxproductnum
53
54 Class of package for tax purposes, Index into FS::part_pkg_taxproduct
55
56 =item city
57
58 city
59
60 =item county
61
62 county
63
64 =item state
65
66 state
67
68 =item local
69
70 local
71
72 =item country
73
74 country
75
76 =item taxclassnum
77
78 Class of tax index into FS::tax_taxclass and FS::tax_rate
79
80 =item taxclassnumtaxed
81
82 Class of tax taxed by this entry.
83
84 =item taxable
85
86 taxable
87
88 =item effdate
89
90 effdate
91
92 =back
93
94 =head1 METHODS
95
96 =over 4
97
98 =item new HASHREF
99
100 Creates a new customer (location), package, tax rate mapping.  To add the
101 mapping to the database, see L<"insert">.
102
103 Note that this stores the hash reference, not a distinct copy of the hash it
104 points to.  You can ask the object for a copy with the I<hash> method.
105
106 =cut
107
108 sub table { 'part_pkg_taxrate'; }
109
110 =item insert
111
112 Adds this record to the database.  If there is an error, returns the error,
113 otherwise returns false.
114
115 =cut
116
117 =item delete
118
119 Delete this record from the database.
120
121 =cut
122
123 =item replace OLD_RECORD
124
125 Replaces the OLD_RECORD with this one in the database.  If there is an error,
126 returns the error, otherwise returns false.
127
128 =cut
129
130 =item check
131
132 Checks all fields to make sure this is a valid tax rate mapping.  If there is
133 an error, returns the error, otherwise returns false.  Called by the insert
134 and replace methods.
135
136 =cut
137
138 sub check {
139   my $self = shift;
140
141   my $error = 
142     $self->ut_numbern('pkgtaxratenum')
143     || $self->ut_textn('data_vendor')
144     || $self->ut_textn('geocode')
145     || $self->
146          ut_foreign_key('taxproductnum', 'part_pkg_taxproduct', 'taxproductnum')
147     || $self->ut_textn('city')
148     || $self->ut_textn('county')
149     || $self->ut_textn('state')
150     || $self->ut_textn('local')
151     || $self->ut_text('country')
152     || $self->ut_foreign_keyn('taxclassnumtaxed', 'tax_class', 'taxclassnum')
153     || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
154     || $self->ut_snumbern('effdate')
155     || $self->ut_enum('taxable', [ 'Y', '' ])
156   ;
157   return $error if $error;
158
159   $self->SUPER::check;
160 }
161
162 =item batch_import
163
164 Loads part_pkg_taxrate records from an external CSV file.  If there is
165 an error, returns the error, otherwise returns false. 
166
167 =cut 
168
169 sub batch_import {
170   my ($param, $job) = @_;
171
172   my $fh = $param->{filehandle};
173   my $format = $param->{'format'};
174
175   my $imported = 0;
176   my @fields;
177   my $hook;
178
179   my @column_lengths = ();
180   my @column_callbacks = ();
181   if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
182     $format =~ s/-fixed//;
183     my $date_format = sub { my $r='';
184                             /^(\d{4})(\d{2})(\d{2})$/ && ($r="$1/$2/$3");
185                             $r;
186                           };
187     $column_callbacks[16] = $date_format;
188     push @column_lengths, qw( 28 25 2 1 10 4 30 3 100 2 2 2 2 1 2 2 8 1 );
189     push @column_lengths, 1 if $format eq 'cch-update';
190   }
191
192   my $line;
193   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
194   if ( $job || scalar(@column_callbacks) ) {
195     my $error =
196       csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
197     return $error if $error;
198   }
199
200   if ( $format eq 'cch' ||  $format eq 'cch-update' ) {
201     @fields = qw( city county state local geocode group groupdesc item
202                   itemdesc provider customer taxtypetaxed taxcattaxed
203                   taxable taxtype taxcat effdate rectype );
204     push @fields, 'actionflag' if $format eq 'cch-update';
205
206     $imported++ if $format eq 'cch-update';  #empty file ok
207
208     $hook = sub { 
209       my $hash = shift;
210
211       unless ( $hash->{'rectype'} eq 'R' or $hash->{'rectype'} eq 'T' ) {
212         delete($hash->{$_}) for (keys %$hash);
213         return;
214       }
215
216       $hash->{'data_vendor'} = 'cch';
217
218       my %providers = ( '00' => 'Regulated LEC',
219                         '01' => 'Regulated IXC',
220                         '02' => 'Unregulated LEC',
221                         '03' => 'Unregulated IXC',
222                         '04' => 'ISP',
223                         '05' => 'Wireless',
224                       );
225
226       my %customers = ( '00' => 'Residential',
227                         '01' => 'Commercial',
228                         '02' => 'Industrial',
229                         '09' => 'Lifeline',
230                         '10' => 'Senior Citizen',
231                       );
232
233       my $taxproduct =
234         join(':', map{ $hash->{$_} } qw(group item provider customer ) );
235
236       my %part_pkg_taxproduct = ( 'data_vendor' => 'cch', 
237                                   'taxproduct' => $taxproduct,
238                                 );
239
240       my $part_pkg_taxproduct = qsearchs( 'part_pkg_taxproduct', 
241                                           { %part_pkg_taxproduct }
242                                         );
243
244       unless ($part_pkg_taxproduct) {
245         return "Can't find part_pkg_taxproduct for txmatrix deletion: ".
246                join(" ", map { "$_ => ". $hash->{$_} } @fields)
247           if ($hash->{'actionfield'} && $hash->{'actionflag'} eq 'D');
248
249         $part_pkg_taxproduct{'description'} = 
250           join(' : ', (map{ $hash->{$_} } qw(groupdesc itemdesc)),
251                       $providers{$hash->{'provider'}} || '',
252                       $customers{$hash->{'customer'}} || '',
253               );
254         $part_pkg_taxproduct = new FS::part_pkg_taxproduct \%part_pkg_taxproduct;
255         my $error = $part_pkg_taxproduct->insert;
256         return "Error inserting tax product (part_pkg_taxproduct): $error"
257           if $error;
258
259       }
260       $hash->{'taxproductnum'} = $part_pkg_taxproduct->taxproductnum;
261
262       delete($hash->{$_})
263         for qw(group groupdesc item itemdesc provider customer rectype );
264
265       my %map = ( 'taxclassnum'      => [ 'taxtype', 'taxcat' ],
266                   'taxclassnumtaxed' => [ 'taxtypetaxed', 'taxcattaxed' ],
267                 );
268
269       for my $item (keys %map) {
270         my $class = join(':', map($hash->{$_}, @{$map{$item}}));
271         my $tax_class =
272           qsearchs( 'tax_class',
273                     { data_vendor => 'cch',
274                       'taxclass' => $class,
275                     }
276                   );
277         $hash->{$item} = $tax_class->taxclassnum
278           if $tax_class;
279
280         return "Can't find tax class for txmatrix deletion: ".
281                join(" ", map { "$_ => ". $hash->{$_} } @fields)
282           if ( $hash->{'actionflag'} && $hash->{'actionflag'} eq 'D' &&
283                !$tax_class && $class ne ':'
284              );
285
286         delete($hash->{$_}) foreach @{$map{$item}};
287       }
288
289       $hash->{'effdate'} = str2time($hash->{'effdate'});
290       $hash->{'country'} = 'US'; # CA is available
291
292       delete($hash->{'taxable'}) if ($hash->{'taxable'} eq 'N');
293
294       if (exists($hash->{actionflag}) && $hash->{actionflag} eq 'D') {
295         delete($hash->{actionflag});
296
297         my $part_pkg_taxrate = qsearchs('part_pkg_taxrate', $hash);
298         unless ( $part_pkg_taxrate ) {
299           if ( $hash->{taxproductnum} ) {
300             my $taxproduct =
301               qsearchs( 'part_pkg_taxproduct',
302                         { 'taxproductnum' => $hash->{taxproductnum} }
303                       );
304             $hash->{taxproductnum} .= ' ( '. $taxproduct->taxproduct. ' )'
305               if $taxproduct;
306           }
307           return "Can't find part_pkg_taxrate to delete: ".
308                  join(" ", map { "$_ => *". $hash->{$_}. '*' } keys(%$hash) );
309         }
310
311         my $error = $part_pkg_taxrate->delete;
312         return $error if $error;
313
314         delete($hash->{$_}) foreach (keys %$hash);
315       }
316
317       delete($hash->{actionflag});
318
319       '';
320     };
321
322   } elsif ( $format eq 'extended' ) {
323     die "unimplemented\n";
324     @fields = qw( );
325     $hook = sub {};
326   } else {
327     die "unknown format $format";
328   }
329
330   eval "use Text::CSV_XS;";
331   die $@ if $@;
332
333   my $csv = new Text::CSV_XS;
334
335   local $SIG{HUP} = 'IGNORE';
336   local $SIG{INT} = 'IGNORE';
337   local $SIG{QUIT} = 'IGNORE';
338   local $SIG{TERM} = 'IGNORE';
339   local $SIG{TSTP} = 'IGNORE';
340   local $SIG{PIPE} = 'IGNORE';
341
342   my $oldAutoCommit = $FS::UID::AutoCommit;
343   local $FS::UID::AutoCommit = 0;
344   my $dbh = dbh;
345   
346   while ( defined($line=<$fh>) ) {
347     $csv->parse($line) or do {
348       $dbh->rollback if $oldAutoCommit;
349       return "can't parse: ". $csv->error_input();
350     };
351
352
353     if ( $job ) {  # progress bar
354       if ( time - $min_sec > $last ) {
355         my $error = $job->update_statustext(
356           int( 100 * $imported / $count ). ",Importing tax matrix"
357         );
358         die $error if $error;
359         $last = time;
360       }
361     }
362
363     my @columns = $csv->fields();
364
365     my %part_pkg_taxrate = ( 'data_vendor' => $format );
366     foreach my $field ( @fields ) {
367       $part_pkg_taxrate{$field} = shift @columns; 
368     }
369     if ( scalar( @columns ) ) {
370       $dbh->rollback if $oldAutoCommit;
371       return "Unexpected trailing columns in line (wrong format?): $line";
372     }
373
374     my $error = &{$hook}(\%part_pkg_taxrate);
375     if ( $error ) {
376       $dbh->rollback if $oldAutoCommit;
377       return $error;
378     }
379     next unless scalar(keys %part_pkg_taxrate);
380
381
382     my $part_pkg_taxrate = new FS::part_pkg_taxrate( \%part_pkg_taxrate );
383     $error = $part_pkg_taxrate->insert;
384
385     if ( $error ) {
386       $dbh->rollback if $oldAutoCommit;
387       return "can't insert part_pkg_taxrate for $line: $error";
388     }
389
390     $imported++;
391   }
392
393   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
394
395   return "Empty file!" unless ( $imported || $format eq 'cch-update' );
396
397   ''; #no error
398
399 }
400
401 =back
402
403 =head1 BUGS
404
405 =head1 SEE ALSO
406
407 L<FS::Record>, schema.html from the base documentation.
408
409 =cut
410
411 1;
412
413