0377a7d0407270fd9ced99c43b4aeed546343225
[freeside.git] / FS / FS / tax_rate_location.pm
1 package FS::tax_rate_location;
2
3 use strict;
4 use base qw( FS::Record );
5 use FS::Record qw( qsearch qsearchs dbh );
6 use FS::Misc qw( csv_from_fixed );
7
8 =head1 NAME
9
10 FS::tax_rate_location - Object methods for tax_rate_location records
11
12 =head1 SYNOPSIS
13
14   use FS::tax_rate_location;
15
16   $record = new FS::tax_rate_location \%hash;
17   $record = new FS::tax_rate_location { 'column' => 'value' };
18
19   $error = $record->insert;
20
21   $error = $new_record->replace($old_record);
22
23   $error = $record->delete;
24
25   $error = $record->check;
26
27 =head1 DESCRIPTION
28
29 An FS::tax_rate_location object represents a tax jurisdiction.  The only
30 functional field is "geocode", a foreign key to tax rates (L<FS::tax_rate>) 
31 that apply in the jurisdiction.  The city, county, state, and country fields 
32 are provided for description and reporting.
33
34 FS::tax_rate_location inherits from FS::Record.  The following fields are 
35 currently supported:
36
37 =over 4
38
39 =item taxratelocationnum - Primary key (assigned automatically for new 
40 tax_rate_locations)
41
42 =item data_vendor - The tax data vendor ('cch' or 'billsoft').
43
44 =item geocode - A unique geographic location code provided by the data vendor
45
46 =item city - City
47
48 =item county -  County
49
50 =item state - State (2-letter code)
51
52 =item country - Country (2-letter code, optional)
53
54 =item disabled - If 'Y' this record is no longer active.
55
56 =back
57
58 =head1 METHODS
59
60 =over 4
61
62 =item new HASHREF
63
64 Creates a new tax rate location.  To add the record to the database, see
65  L<"insert">.
66
67 Note that this stores the hash reference, not a distinct copy of the hash it
68 points to.  You can ask the object for a copy with the I<hash> method.
69
70 =cut
71
72 sub table { 'tax_rate_location'; }
73
74 =item insert
75
76 Adds this record to the database.  If there is an error, returns the error,
77 otherwise returns false.
78
79 =cut
80
81 =item delete
82
83 Delete this record from the database.
84
85 =cut
86
87 sub delete {
88   return "Can't delete tax rate locations.  Set disable to 'Y' instead.";
89   # check that it is unused in any cust_bill_pkg_tax_location records instead?
90 }
91
92 =item replace OLD_RECORD
93
94 Replaces the OLD_RECORD with this one in the database.  If there is an error,
95 returns the error, otherwise returns false.
96
97 =cut
98
99 =item check
100
101 Checks all fields to make sure this is a valid tax rate location.  If there is
102 an error, returns the error, otherwise returns false.  Called by the insert
103 and replace methods.
104
105 =cut
106
107 sub check {
108   my $self = shift;
109
110   my $error = 
111     $self->ut_numbern('taxratelocationnum')
112     || $self->ut_textn('data_vendor')
113     || $self->ut_alpha('geocode')
114     || $self->ut_textn('city')
115     || $self->ut_textn('county')
116     || $self->ut_textn('state')
117     || $self->ut_enum('disabled', [ '', 'Y' ])
118   ;
119   return $error if $error;
120
121   my @unique = qw( data_vendor geocode );
122   push @unique, qw( state country )
123     if $self->data_vendor eq 'compliance_solutions';
124
125   my $t;
126   $t = qsearchs( 'tax_rate_location',
127                  { disabled => '',
128                    ( map { $_ => $self->$_ } @unique ),
129                  },
130                )
131     unless $self->disabled;
132
133   $t = $self->by_key( $self->taxratelocationnum )
134     if ( !$t && $self->taxratelocationnum );
135
136   return "geocode ". $self->geocode. " already in use for this vendor"
137     if ( $t && $t->taxratelocationnum != $self->taxratelocationnum );
138
139   return "may only be disabled"
140     if ( $t && scalar( grep { $t->$_ ne $self->$_ } 
141                        grep { $_ ne 'disabled' }
142                        $self->fields
143                      )
144        );
145
146   $self->SUPER::check;
147 }
148
149 =item find_or_insert
150
151 Finds an existing, non-disabled tax jurisdiction matching the data_vendor 
152 and geocode fields. If there is one, updates its city, county, state, and
153 country to match this record.  If there is no existing record, inserts this 
154 record.
155
156 =cut
157
158 sub find_or_insert {
159   my $self = shift;
160   my $existing = qsearchs('tax_rate_location', {
161       disabled    => '',
162       data_vendor => $self->data_vendor,
163       geocode     => $self->geocode
164   });
165   if ($existing) {
166     my $update = 0;
167     foreach (qw(city county state country)) {
168       if ($self->get($_) ne $existing->get($_)) {
169         $update++;
170       }
171     }
172     $self->set(taxratelocationnum => $existing->taxratelocationnum);
173     if ($update) {
174       return $self->replace($existing);
175     } else {
176       return;
177     }
178   } else {
179     return $self->insert;
180   }
181 }
182
183 =back
184
185 =head1 CLASS METHODS
186
187 =item location_sql KEY => VALUE, ...
188
189 Returns an SQL fragment identifying matching tax_rate_location /
190 cust_bill_pkg_tax_rate_location records.
191
192 Parameters are county, state, city and locationtaxid
193
194 =cut
195
196 sub location_sql {
197   my($class, %param) = @_;
198
199   my %pn = (
200    'city'          => 'tax_rate_location.city',
201    'county'        => 'tax_rate_location.county',
202    'state'         => 'tax_rate_location.state',
203    'locationtaxid' => 'cust_bill_pkg_tax_rate_location.locationtaxid',
204   );
205
206   my %ph = map { $pn{$_} => dbh->quote($param{$_}) } keys %pn;
207
208   join( ' AND ',
209     map { "( $_ = $ph{$_} OR $ph{$_} = '' AND $_ IS NULL)" } keys %ph
210   );
211
212 }
213
214 =back
215
216 =head1 SUBROUTINES
217
218 =over 4
219
220 =item batch_import HASHREF, JOB
221
222 Starts importing tax_rate_location records from a file.  HASHREF must contain
223 'filehandle' (an open handle to the input file) and 'format' (one of 'cch',
224 'cch-fixed', 'cch-update', 'cch-fixed-update', or 'billsoft').  JOB is an
225 L<FS::queue> object to receive progress messages.
226
227 =cut
228
229 # XXX move this into TaxEngine modules at some point
230
231 sub batch_import {
232   my ($param, $job) = @_;
233
234   my $fh = $param->{filehandle};
235   my $format = $param->{'format'};
236
237   my %insert = ();
238   my %delete = ();
239
240   my @fields;
241   my $hook;
242
243   my @column_lengths = ();
244   my @column_callbacks = ();
245   if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
246     $format =~ s/-fixed//;
247     my $trim = sub { my $r = shift; $r =~ s/^\s*//; $r =~ s/\s*$//; $r };
248     push @column_lengths, qw( 28 25 2 10 );
249     push @column_lengths, 1 if $format eq 'cch-update';
250     push @column_callbacks, $trim foreach (@column_lengths);
251   }
252
253   my $line;
254   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
255   if ( $job || scalar(@column_callbacks) ) { # this makes zero sense
256     my $error =
257       csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
258     return $error if $error;
259   }
260
261   if ( $format eq 'cch' || $format eq 'cch-update' ) {
262     @fields = qw( city county state geocode );
263     push @fields, 'actionflag' if $format eq 'cch-update';
264
265     $hook = sub {
266       my $hash = shift;
267
268       $hash->{'data_vendor'} ='cch';
269
270       if (exists($hash->{'actionflag'}) && $hash->{'actionflag'} eq 'D') {
271         delete($hash->{actionflag});
272
273         $hash->{disabled} = '';
274         my $tax_rate_location = qsearchs('tax_rate_location', $hash);
275         return "Can't find tax_rate_location to delete: ".
276                join(" ", map { "$_ => ". $hash->{$_} } @fields)
277           unless $tax_rate_location;
278
279         $tax_rate_location->disabled('Y');
280         my $error = $tax_rate_location->replace;
281         return $error if $error;
282
283         delete($hash->{$_}) foreach (keys %$hash);
284       }
285
286       delete($hash->{'actionflag'});
287
288       '';
289
290     };
291
292   } elsif ( $format eq 'billsoft' ) {
293     @fields = ( qw( geocode alt_location country state county city ), '', '' );
294
295     $hook = sub {
296       my $hash = shift;
297       if ($hash->{alt_location}) {
298         # don't import these; the jurisdiction should be named using its 
299         # primary city
300         %$hash = ();
301         return;
302       }
303
304       $hash->{data_vendor} = 'billsoft';
305       # unlike cust_tax_location, keep the whole-country and whole-state 
306       # rows, but strip the whitespace
307       $hash->{county} =~ s/^ //g;
308       $hash->{state} =~ s/^ //g;
309       $hash->{country} =~ s/^ //g;
310       $hash->{city} =~ s/[^\w ]//g; # remove asterisks and other bad things
311       $hash->{country} = substr($hash->{country}, 0, 2);
312       '';
313     }
314
315   } elsif ( $format eq 'extended' ) {
316     die "unimplemented\n";
317     @fields = qw( );
318     $hook = sub {};
319   } else {
320     die "unknown format $format";
321   }
322
323   eval "use Text::CSV_XS;";
324   die $@ if $@;
325
326   my $csv = new Text::CSV_XS;
327
328   my $imported = 0;
329
330   local $SIG{HUP} = 'IGNORE';
331   local $SIG{INT} = 'IGNORE';
332   local $SIG{QUIT} = 'IGNORE';
333   local $SIG{TERM} = 'IGNORE';
334   local $SIG{TSTP} = 'IGNORE';
335   local $SIG{PIPE} = 'IGNORE';
336
337   my $oldAutoCommit = $FS::UID::AutoCommit;
338   local $FS::UID::AutoCommit = 0;
339   my $dbh = dbh;
340
341   while ( defined($line=<$fh>) ) {
342     $csv->parse($line) or do {
343       $dbh->rollback if $oldAutoCommit;
344       return "can't parse: ". $csv->error_input();
345     };
346
347     if ( $job ) {  # progress bar
348       if ( time - $min_sec > $last ) {
349         my $error = $job->update_statustext(
350           int( 100 * $imported / $count ) .
351           ',Creating tax jurisdiction records'
352         );
353         die $error if $error;
354         $last = time;
355       }
356     }
357
358     my @columns = $csv->fields();
359
360     my %tax_rate_location = ();
361     foreach my $field ( @fields ) {
362       $tax_rate_location{$field} = shift @columns;
363     }
364     if ( scalar( @columns ) ) {
365       $dbh->rollback if $oldAutoCommit;
366       return "Unexpected trailing columns in line (wrong format?) importing tax-rate_location: $line";
367     }
368
369     my $error = &{$hook}(\%tax_rate_location);
370     if ( $error ) {
371       $dbh->rollback if $oldAutoCommit;
372       return $error;
373     }
374
375     if (scalar(keys %tax_rate_location)) { #inserts only
376
377       my $tax_rate_location = new FS::tax_rate_location( \%tax_rate_location );
378       $error = $tax_rate_location->insert;
379
380       if ( $error ) {
381         $dbh->rollback if $oldAutoCommit;
382         return "can't insert tax_rate_location for $line: $error";
383       }
384
385     }
386
387     $imported++;
388
389   }
390
391   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
392
393   return "Empty file!" unless ($imported || $format eq 'cch-update');
394
395   ''; #no error
396
397 }
398
399 sub _upgrade_data {
400   my $class = shift;
401
402   my $sql = "UPDATE tax_rate_location SET data_vendor = 'compliance_solutions' WHERE data_vendor = 'compliance solutions'";
403
404   my $sth = dbh->prepare($sql) or die $DBI::errstr;
405   $sth->execute() or die $sth->errstr;
406   
407 }
408
409 =head1 BUGS
410
411 Currently somewhat specific to CCH supplied data.
412
413 =head1 SEE ALSO
414
415 L<FS::Record>, schema.html from the base documentation.
416
417 =cut
418
419 1;
420