compliance solutions, RT#77676
[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 $t = '';
122   $t = $self->existing_search
123     unless $self->disabled;
124
125   $t = $self->by_key( $self->taxratelocationnum )
126     if !$t && $self->taxratelocationnum;
127
128   return "geocode ". $self->geocode. " already in use for this vendor"
129     if ( $t && $t->taxratelocationnum != $self->taxratelocationnum );
130
131   return "may only be disabled"
132     if ( $t && scalar( grep { $t->$_ ne $self->$_ } 
133                        grep { $_ ne 'disabled' }
134                        $self->fields
135                      )
136        );
137
138   $self->SUPER::check;
139 }
140
141 =item find_or_insert
142
143 Finds an existing, non-disabled tax jurisdiction matching the data_vendor 
144 and geocode fields. If there is one, updates its city, county, state, and
145 country to match this record.  If there is no existing record, inserts this 
146 record.
147
148 =cut
149
150 sub find_or_insert {
151   my $self = shift;
152   my $existing = $self->existing_search;
153   if ($existing) {
154     my $update = 0;
155     foreach (qw(city county state country)) {
156       if ($self->get($_) ne $existing->get($_)) {
157         $update++;
158       }
159     }
160     $self->set(taxratelocationnum => $existing->taxratelocationnum);
161     if ($update) {
162       return $self->replace($existing);
163     } else {
164       return;
165     }
166   } else {
167     return $self->insert;
168   }
169 }
170
171 sub existing_search {
172   my $self = shift;
173
174   my @unique = qw( data_vendor geocode );
175   push @unique, qw( state country )
176     if $self->data_vendor eq 'compliance_solutions';
177
178   qsearchs( 'tax_rate_location',
179             { disabled => '',
180               map { $_ => $self->$_ } @unique
181             }
182           );
183 }
184
185 =back
186
187 =head1 CLASS METHODS
188
189 =item location_sql KEY => VALUE, ...
190
191 Returns an SQL fragment identifying matching tax_rate_location /
192 cust_bill_pkg_tax_rate_location records.
193
194 Parameters are county, state, city and locationtaxid
195
196 =cut
197
198 sub location_sql {
199   my($class, %param) = @_;
200
201   my %pn = (
202    'city'          => 'tax_rate_location.city',
203    'county'        => 'tax_rate_location.county',
204    'state'         => 'tax_rate_location.state',
205    'locationtaxid' => 'cust_bill_pkg_tax_rate_location.locationtaxid',
206   );
207
208   my %ph = map { $pn{$_} => dbh->quote($param{$_}) } keys %pn;
209
210   join( ' AND ',
211     map { "( $_ = $ph{$_} OR $ph{$_} = '' AND $_ IS NULL)" } keys %ph
212   );
213
214 }
215
216 =back
217
218 =head1 SUBROUTINES
219
220 =over 4
221
222 =item batch_import HASHREF, JOB
223
224 Starts importing tax_rate_location records from a file.  HASHREF must contain
225 'filehandle' (an open handle to the input file) and 'format' (one of 'cch',
226 'cch-fixed', 'cch-update', 'cch-fixed-update', or 'billsoft').  JOB is an
227 L<FS::queue> object to receive progress messages.
228
229 =cut
230
231 # XXX move this into TaxEngine modules at some point
232
233 sub batch_import {
234   my ($param, $job) = @_;
235
236   my $fh = $param->{filehandle};
237   my $format = $param->{'format'};
238
239   my %insert = ();
240   my %delete = ();
241
242   my @fields;
243   my $hook;
244
245   my @column_lengths = ();
246   my @column_callbacks = ();
247   if ( $format eq 'cch-fixed' || $format eq 'cch-fixed-update' ) {
248     $format =~ s/-fixed//;
249     my $trim = sub { my $r = shift; $r =~ s/^\s*//; $r =~ s/\s*$//; $r };
250     push @column_lengths, qw( 28 25 2 10 );
251     push @column_lengths, 1 if $format eq 'cch-update';
252     push @column_callbacks, $trim foreach (@column_lengths);
253   }
254
255   my $line;
256   my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
257   if ( $job || scalar(@column_callbacks) ) { # this makes zero sense
258     my $error =
259       csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
260     return $error if $error;
261   }
262
263   if ( $format eq 'cch' || $format eq 'cch-update' ) {
264     @fields = qw( city county state geocode );
265     push @fields, 'actionflag' if $format eq 'cch-update';
266
267     $hook = sub {
268       my $hash = shift;
269
270       $hash->{'data_vendor'} ='cch';
271
272       if (exists($hash->{'actionflag'}) && $hash->{'actionflag'} eq 'D') {
273         delete($hash->{actionflag});
274
275         $hash->{disabled} = '';
276         my $tax_rate_location = qsearchs('tax_rate_location', $hash);
277         return "Can't find tax_rate_location to delete: ".
278                join(" ", map { "$_ => ". $hash->{$_} } @fields)
279           unless $tax_rate_location;
280
281         $tax_rate_location->disabled('Y');
282         my $error = $tax_rate_location->replace;
283         return $error if $error;
284
285         delete($hash->{$_}) foreach (keys %$hash);
286       }
287
288       delete($hash->{'actionflag'});
289
290       '';
291
292     };
293
294   } elsif ( $format eq 'billsoft' ) {
295     @fields = ( qw( geocode alt_location country state county city ), '', '' );
296
297     $hook = sub {
298       my $hash = shift;
299       if ($hash->{alt_location}) {
300         # don't import these; the jurisdiction should be named using its 
301         # primary city
302         %$hash = ();
303         return;
304       }
305
306       $hash->{data_vendor} = 'billsoft';
307       # unlike cust_tax_location, keep the whole-country and whole-state 
308       # rows, but strip the whitespace
309       $hash->{county} =~ s/^ //g;
310       $hash->{state} =~ s/^ //g;
311       $hash->{country} =~ s/^ //g;
312       $hash->{city} =~ s/[^\w ]//g; # remove asterisks and other bad things
313       $hash->{country} = substr($hash->{country}, 0, 2);
314       '';
315     }
316
317   } elsif ( $format eq 'extended' ) {
318     die "unimplemented\n";
319     @fields = qw( );
320     $hook = sub {};
321   } else {
322     die "unknown format $format";
323   }
324
325   eval "use Text::CSV_XS;";
326   die $@ if $@;
327
328   my $csv = new Text::CSV_XS;
329
330   my $imported = 0;
331
332   local $SIG{HUP} = 'IGNORE';
333   local $SIG{INT} = 'IGNORE';
334   local $SIG{QUIT} = 'IGNORE';
335   local $SIG{TERM} = 'IGNORE';
336   local $SIG{TSTP} = 'IGNORE';
337   local $SIG{PIPE} = 'IGNORE';
338
339   my $oldAutoCommit = $FS::UID::AutoCommit;
340   local $FS::UID::AutoCommit = 0;
341   my $dbh = dbh;
342
343   while ( defined($line=<$fh>) ) {
344     $csv->parse($line) or do {
345       $dbh->rollback if $oldAutoCommit;
346       return "can't parse: ". $csv->error_input();
347     };
348
349     if ( $job ) {  # progress bar
350       if ( time - $min_sec > $last ) {
351         my $error = $job->update_statustext(
352           int( 100 * $imported / $count ) .
353           ',Creating tax jurisdiction records'
354         );
355         die $error if $error;
356         $last = time;
357       }
358     }
359
360     my @columns = $csv->fields();
361
362     my %tax_rate_location = ();
363     foreach my $field ( @fields ) {
364       $tax_rate_location{$field} = shift @columns;
365     }
366     if ( scalar( @columns ) ) {
367       $dbh->rollback if $oldAutoCommit;
368       return "Unexpected trailing columns in line (wrong format?) importing tax-rate_location: $line";
369     }
370
371     my $error = &{$hook}(\%tax_rate_location);
372     if ( $error ) {
373       $dbh->rollback if $oldAutoCommit;
374       return $error;
375     }
376
377     if (scalar(keys %tax_rate_location)) { #inserts only
378
379       my $tax_rate_location = new FS::tax_rate_location( \%tax_rate_location );
380       $error = $tax_rate_location->insert;
381
382       if ( $error ) {
383         $dbh->rollback if $oldAutoCommit;
384         return "can't insert tax_rate_location for $line: $error";
385       }
386
387     }
388
389     $imported++;
390
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 sub _upgrade_data {
402   my $class = shift;
403
404   my $sql = "UPDATE tax_rate_location SET data_vendor = 'compliance_solutions' WHERE data_vendor = 'compliance solutions'";
405
406   my $sth = dbh->prepare($sql) or die $DBI::errstr;
407   $sth->execute() or die $sth->errstr;
408   
409 }
410
411 =head1 BUGS
412
413 Currently somewhat specific to CCH supplied data.
414
415 =head1 SEE ALSO
416
417 L<FS::Record>, schema.html from the base documentation.
418
419 =cut
420
421 1;
422