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