143f62ed3e10b0d5d4056fdb93a5cd8de52570f7
[freeside.git] / FS / FS / cust_main_county.pm
1 package FS::cust_main_county;
2
3 use strict;
4 use vars qw( @ISA @EXPORT_OK $conf
5              @cust_main_county %cust_main_county $countyflag ); # $cityflag );
6 use Exporter;
7 use FS::Record qw( qsearch qsearchs dbh );
8 use FS::cust_bill_pkg;
9 use FS::cust_bill;
10 use FS::cust_pkg;
11 use FS::part_pkg;
12 use FS::cust_tax_exempt;
13 use FS::cust_tax_exempt_pkg;
14
15 @ISA = qw( FS::Record );
16 @EXPORT_OK = qw( regionselector );
17
18 @cust_main_county = ();
19 $countyflag = '';
20 #$cityflag = '';
21
22 #ask FS::UID to run this stuff for us later
23 $FS::UID::callback{'FS::cust_main_county'} = sub { 
24   $conf = new FS::Conf;
25 };
26
27 =head1 NAME
28
29 FS::cust_main_county - Object methods for cust_main_county objects
30
31 =head1 SYNOPSIS
32
33   use FS::cust_main_county;
34
35   $record = new FS::cust_main_county \%hash;
36   $record = new FS::cust_main_county { 'column' => 'value' };
37
38   $error = $record->insert;
39
40   $error = $new_record->replace($old_record);
41
42   $error = $record->delete;
43
44   $error = $record->check;
45
46   ($county_html, $state_html, $country_html) =
47     FS::cust_main_county::regionselector( $county, $state, $country );
48
49 =head1 DESCRIPTION
50
51 An FS::cust_main_county object represents a tax rate, defined by locale.
52 FS::cust_main_county inherits from FS::Record.  The following fields are
53 currently supported:
54
55 =over 4
56
57 =item taxnum - primary key (assigned automatically for new tax rates)
58
59 =item district - tax district (optional)
60
61 =item city
62
63 =item county
64
65 =item state
66
67 =item country
68
69 =item tax - percentage
70
71 =item taxclass
72
73 =item exempt_amount
74
75 =item taxname - if defined, printed on invoices instead of "Tax"
76
77 =item setuptax - if 'Y', this tax does not apply to setup fees
78
79 =item recurtax - if 'Y', this tax does not apply to recurring fees
80
81 =back
82
83 =head1 METHODS
84
85 =over 4
86
87 =item new HASHREF
88
89 Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.
90
91 =cut
92
93 sub table { 'cust_main_county'; }
94
95 =item insert
96
97 Adds this tax rate to the database.  If there is an error, returns the error,
98 otherwise returns false.
99
100 =item delete
101
102 Deletes this tax rate from the database.  If there is an error, returns the
103 error, otherwise returns false.
104
105 =item replace OLD_RECORD
106
107 Replaces the OLD_RECORD with this one in the database.  If there is an error,
108 returns the error, otherwise returns false.
109
110 =item check
111
112 Checks all fields to make sure this is a valid tax rate.  If there is an error,
113 returns the error, otherwise returns false.  Called by the insert and replace
114 methods.
115
116 =cut
117
118 sub check {
119   my $self = shift;
120
121   $self->exempt_amount(0) unless $self->exempt_amount;
122
123   $self->ut_numbern('taxnum')
124     || $self->ut_alphan('district')
125     || $self->ut_textn('city')
126     || $self->ut_textn('county')
127     || $self->ut_anything('state')
128     || $self->ut_text('country')
129     || $self->ut_float('tax')
130     || $self->ut_textn('taxclass') # ...
131     || $self->ut_money('exempt_amount')
132     || $self->ut_textn('taxname')
133     || $self->ut_enum('setuptax', [ '', 'Y' ] )
134     || $self->ut_enum('recurtax', [ '', 'Y' ] )
135     || $self->SUPER::check
136     ;
137
138 }
139
140 sub taxname {
141   my $self = shift;
142   if ( $self->dbdef_table->column('taxname') ) {
143     return $self->setfield('taxname', $_[0]) if @_;
144     return $self->getfield('taxname');
145   }  
146   return '';
147 }
148
149 sub setuptax {
150   my $self = shift;
151   if ( $self->dbdef_table->column('setuptax') ) {
152     return $self->setfield('setuptax', $_[0]) if @_;
153     return $self->getfield('setuptax');
154   }  
155   return '';
156 }
157
158 sub recurtax {
159   my $self = shift;
160   if ( $self->dbdef_table->column('recurtax') ) {
161     return $self->setfield('recurtax', $_[0]) if @_;
162     return $self->getfield('recurtax');
163   }  
164   return '';
165 }
166
167 =item label OPTIONS
168
169 Returns a label looking like "Anytown, Alameda County, CA, US".
170
171 If the taxname field is set, it will look like
172 "CA Sales Tax (Anytown, Alameda County, CA, US)".
173
174 If the taxclass is set, then it will be
175 "Anytown, Alameda County, CA, US (International)".
176
177 Currently it will not contain the district, even if the city+county+state
178 is not unique.
179
180 OPTIONS may contain "no_taxclass" (hides taxclass) and/or "no_city"
181 (hides city).  It may also contain "out", in which case, if this 
182 region (district+city+county+state+country) contains no non-zero 
183 taxes, the label will read "Out of taxable region(s)".
184
185 =cut
186
187 sub label {
188   my ($self, %opt) = @_;
189   if ( $opt{'out'} 
190        and $self->tax == 0
191        and !defined(qsearchs('cust_main_county', {
192            'district' => $self->district,
193            'city'     => $self->city,
194            'county'   => $self->county,
195            'state'    => $self->state,
196            'country'  => $self->country,
197            'tax'  => { op => '>', value => 0 },
198         })) )
199   {
200     return 'Out of taxable region(s)';
201   }
202   my $label = $self->country;
203   $label = $self->state.", $label" if $self->state;
204   $label = $self->county." County, $label" if $self->county;
205   if (!$opt{no_city}) {
206     $label = $self->city.", $label" if $self->city;
207   }
208   # ugly labels when taxclass and taxname are both non-null...
209   # but this is how the tax report does it
210   if (!$opt{no_taxclass}) {
211     $label = "$label (".$self->taxclass.')' if $self->taxclass;
212   }
213   $label = $self->taxname." ($label)" if $self->taxname;
214
215   $label;
216 }
217
218 =item sql_taxclass_sameregion
219
220 Returns an SQL WHERE fragment or the empty string to search for entries
221 with different tax classes.
222
223 =cut
224
225 #hmm, description above could be better...
226
227 sub sql_taxclass_sameregion {
228   my $self = shift;
229
230   my $same_query = 'SELECT DISTINCT taxclass FROM cust_main_county '.
231                    ' WHERE taxnum != ? AND country = ?';
232   my @same_param = ( 'taxnum', 'country' );
233   foreach my $opt_field (qw( state county )) {
234     if ( $self->$opt_field() ) {
235       $same_query .= " AND $opt_field = ?";
236       push @same_param, $opt_field;
237     } else {
238       $same_query .= " AND $opt_field IS NULL";
239     }
240   }
241
242   my @taxclasses = $self->_list_sql( \@same_param, $same_query );
243
244   return '' unless scalar(@taxclasses);
245
246   '( taxclass IS NULL OR ( '.  #only if !$self->taxclass ??
247      join(' AND ', map { 'taxclass != '.dbh->quote($_) } @taxclasses ). 
248   ' ) ) ';
249 }
250
251 sub _list_sql {
252   my( $self, $param, $sql ) = @_;
253   my $sth = dbh->prepare($sql) or die dbh->errstr;
254   $sth->execute( map $self->$_(), @$param )
255     or die "Unexpected error executing statement $sql: ". $sth->errstr;
256   map $_->[0], @{ $sth->fetchall_arrayref };
257 }
258
259 =item taxline TAXABLES_ARRAYREF, [ OPTION => VALUE ... ]
260
261 Returns an hashref of a name and an amount of tax calculated for the 
262 line items (L<FS::cust_bill_pkg> objects) in TAXABLES_ARRAYREF.  The line 
263 items must come from the same invoice.  Returns a scalar error message 
264 on error.
265
266 In addition to calculating the tax for the line items, this will calculate
267 any appropriate tax exemptions and attach them to the line items.
268
269 Options may include 'custnum' and 'invoice_date' in case the cust_bill_pkg
270 objects belong to an invoice that hasn't been inserted yet.
271
272 Options may include 'exemptions', an arrayref of L<FS::cust_tax_exempt_pkg>
273 objects belonging to the same customer, to be counted against the monthly 
274 tax exemption limit if there is one.
275
276 =cut
277
278 # XXX this should just return a cust_bill_pkg object for the tax,
279 # but that requires changing stuff in tax_rate.pm also.
280
281 sub taxline {
282   my( $self, $taxables, %opt ) = @_;
283   return 'taxline called with no line items' unless @$taxables;
284
285   local $SIG{HUP} = 'IGNORE';
286   local $SIG{INT} = 'IGNORE';
287   local $SIG{QUIT} = 'IGNORE';
288   local $SIG{TERM} = 'IGNORE';
289   local $SIG{TSTP} = 'IGNORE';
290   local $SIG{PIPE} = 'IGNORE';
291
292   my $oldAutoCommit = $FS::UID::AutoCommit;
293   local $FS::UID::AutoCommit = 0;
294   my $dbh = dbh;
295
296   my $name = $self->taxname || 'Tax';
297   my $amount = 0;
298
299   my $cust_bill = $taxables->[0]->cust_bill;
300   my $custnum   = $cust_bill ? $cust_bill->custnum : $opt{'custnum'};
301   my $invoice_date = $cust_bill ? $cust_bill->_date : $opt{'invoice_date'};
302   my $cust_main = FS::cust_main->by_key($custnum) if $custnum > 0;
303   if (!$cust_main) {
304     # better way to handle this?  should we just assume that it's taxable?
305     die "unable to calculate taxes for an unknown customer\n";
306   }
307
308   # set a flag if the customer is tax-exempt
309   my $exempt_cust;
310   my $conf = FS::Conf->new;
311   if ( $conf->exists('cust_class-tax_exempt') ) {
312     my $cust_class = $cust_main->cust_class;
313     $exempt_cust = $cust_class->tax if $cust_class;
314   } else {
315     $exempt_cust = $cust_main->tax;
316   }
317
318   # set a flag if the customer is exempt from this tax here
319   my $exempt_cust_taxname = $cust_main->tax_exemption($self->taxname)
320     if $self->taxname;
321
322   # Gather any exemptions that are already attached to these cust_bill_pkgs
323   # so that we can deduct them from the customer's monthly limit.
324   my @existing_exemptions = @{ $opt{'exemptions'} };
325   push @existing_exemptions, @{ $_->cust_tax_exempt_pkg }
326     for @$taxables;
327
328   foreach my $cust_bill_pkg (@$taxables) {
329
330     my $cust_pkg  = $cust_bill_pkg->cust_pkg;
331     my $part_pkg  = $cust_bill_pkg->part_pkg;
332
333     my @new_exemptions;
334     my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur
335       or next; # don't create zero-amount exemptions
336
337     # XXX the following procedure should probably be in cust_bill_pkg
338
339     if ( $exempt_cust ) {
340
341       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
342           amount => $taxable_charged,
343           exempt_cust => 'Y',
344         });
345       $taxable_charged = 0;
346
347     } elsif ( $exempt_cust_taxname ) {
348
349       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
350           amount => $taxable_charged,
351           exempt_cust_taxname => 'Y',
352         });
353       $taxable_charged = 0;
354
355     }
356
357     if ( ($part_pkg->setuptax eq 'Y' or $self->setuptax eq 'Y')
358         and $cust_bill_pkg->setup > 0 and $taxable_charged > 0 ) {
359
360       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
361           amount => $cust_bill_pkg->setup,
362           exempt_setup => 'Y'
363       });
364       $taxable_charged -= $cust_bill_pkg->setup;
365
366     }
367     if ( ($part_pkg->recurtax eq 'Y' or $self->recurtax eq 'Y')
368         and $cust_bill_pkg->recur > 0 and $taxable_charged > 0 ) {
369
370       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
371           amount => $cust_bill_pkg->recur,
372           exempt_recur => 'Y'
373       });
374       $taxable_charged -= $cust_bill_pkg->recur;
375     
376     }
377   
378     if ( $self->exempt_amount && $self->exempt_amount > 0 
379       and $taxable_charged > 0 ) {
380       #my ($mon,$year) = (localtime($cust_bill_pkg->sdate) )[4,5];
381       my ($mon,$year) =
382         (localtime( $cust_bill_pkg->sdate || $invoice_date ) )[4,5];
383       $mon++;
384       $year += 1900;
385       my $freq = $cust_bill_pkg->freq;
386       unless ($freq) {
387         $freq = $part_pkg->freq || 1;  # less trustworthy fallback
388       }
389       if ( $freq !~ /(\d+)$/ ) {
390         $dbh->rollback if $oldAutoCommit;
391         return "daily/weekly package definitions not (yet?)".
392                " compatible with monthly tax exemptions";
393       }
394       my $taxable_per_month =
395         sprintf("%.2f", $taxable_charged / $freq );
396
397       #call the whole thing off if this customer has any old
398       #exemption records...
399       my @cust_tax_exempt =
400         qsearch( 'cust_tax_exempt' => { custnum=> $custnum } );
401       if ( @cust_tax_exempt ) {
402         $dbh->rollback if $oldAutoCommit;
403         return
404           'this customer still has old-style tax exemption records; '.
405           'run bin/fs-migrate-cust_tax_exempt?';
406       }
407
408       foreach my $which_month ( 1 .. $freq ) {
409   
410         #maintain the new exemption table now
411         my $sql = "
412           SELECT SUM(amount)
413             FROM cust_tax_exempt_pkg
414               LEFT JOIN cust_bill_pkg USING ( billpkgnum )
415               LEFT JOIN cust_bill     USING ( invnum     )
416             WHERE custnum = ?
417               AND taxnum  = ?
418               AND year    = ?
419               AND month   = ?
420               AND exempt_monthly = 'Y'
421         ";
422         my $sth = dbh->prepare($sql) or do {
423           $dbh->rollback if $oldAutoCommit;
424           return "fatal: can't lookup exising exemption: ". dbh->errstr;
425         };
426         $sth->execute(
427           $custnum,
428           $self->taxnum,
429           $year,
430           $mon,
431         ) or do {
432           $dbh->rollback if $oldAutoCommit;
433           return "fatal: can't lookup exising exemption: ". dbh->errstr;
434         };
435         my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0;
436
437         foreach ( grep { $_->taxnum == $self->taxnum &&
438                          $_->exempt_monthly eq 'Y'   &&
439                          $_->month  == $mon          &&
440                          $_->year   == $year 
441                        } @existing_exemptions
442                 )
443         {
444           $existing_exemption += $_->amount;
445         }
446         
447         my $remaining_exemption =
448           $self->exempt_amount - $existing_exemption;
449         if ( $remaining_exemption > 0 ) {
450           my $addl = $remaining_exemption > $taxable_per_month
451             ? $taxable_per_month
452             : $remaining_exemption;
453           push @new_exemptions, FS::cust_tax_exempt_pkg->new({
454               amount          => sprintf('%.2f', $addl),
455               exempt_monthly  => 'Y',
456               year            => $year,
457               month           => $mon,
458             });
459           $taxable_charged -= $addl;
460         }
461         last if $taxable_charged < 0.005;
462         # if they're using multiple months of exemption for a multi-month
463         # package, then record the exemptions in separate months
464         $mon++;
465         if ( $mon > 12 ) {
466           $mon -= 12;
467           $year++;
468         }
469
470       } #foreach $which_month
471     } # if exempt_amount
472
473     $_->taxnum($self->taxnum) foreach @new_exemptions;
474
475     if ( $cust_bill_pkg->billpkgnum ) {
476       die "tried to calculate tax exemptions on a previously billed line item\n";
477       # this is unnecessary
478 #      foreach my $cust_tax_exempt_pkg (@new_exemptions) {
479 #        my $error = $cust_tax_exempt_pkg->insert;
480 #        if ( $error ) {
481 #          $dbh->rollback if $oldAutoCommit;
482 #          return "can't insert cust_tax_exempt_pkg: $error";
483 #        }
484 #      }
485     }
486
487     # attach them to the line item
488     push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, @new_exemptions;
489     push @existing_exemptions, @new_exemptions;
490
491     # If we were smart, we'd also generate a cust_bill_pkg_tax_location 
492     # record at this point, but that would require redesigning more stuff.
493     $taxable_charged = sprintf( "%.2f", $taxable_charged);
494
495     $amount += $taxable_charged * $self->tax / 100;
496   } #foreach $cust_bill_pkg
497
498   return {
499     'name'   => $name,
500     'amount' => $amount,
501   };
502
503 }
504
505 =back
506
507 =head1 SUBROUTINES
508
509 =over 4
510
511 =item regionselector [ COUNTY STATE COUNTRY [ PREFIX [ ONCHANGE [ DISABLED ] ] ] ]
512
513 =cut
514
515 sub regionselector {
516   my ( $selected_county, $selected_state, $selected_country,
517        $prefix, $onchange, $disabled ) = @_;
518
519   $prefix = '' unless defined $prefix;
520
521   $countyflag = 0;
522
523 #  unless ( @cust_main_county ) { #cache 
524     @cust_main_county = qsearch('cust_main_county', {} );
525     foreach my $c ( @cust_main_county ) {
526       $countyflag=1 if $c->county;
527       #push @{$cust_main_county{$c->country}{$c->state}}, $c->county;
528       $cust_main_county{$c->country}{$c->state}{$c->county} = 1;
529     }
530 #  }
531   $countyflag=1 if $selected_county;
532
533   my $script_html = <<END;
534     <SCRIPT>
535     function opt(what,value,text) {
536       var optionName = new Option(text, value, false, false);
537       var length = what.length;
538       what.options[length] = optionName;
539     }
540     function ${prefix}country_changed(what) {
541       country = what.options[what.selectedIndex].text;
542       for ( var i = what.form.${prefix}state.length; i >= 0; i-- )
543           what.form.${prefix}state.options[i] = null;
544 END
545       #what.form.${prefix}state.options[0] = new Option('', '', false, true);
546
547   foreach my $country ( sort keys %cust_main_county ) {
548     $script_html .= "\nif ( country == \"$country\" ) {\n";
549     foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
550       ( my $dstate = $state ) =~ s/[\n\r]//g;
551       my $text = $dstate || '(n/a)';
552       $script_html .= qq!opt(what.form.${prefix}state, "$dstate", "$text");\n!;
553     }
554     $script_html .= "}\n";
555   }
556
557   $script_html .= <<END;
558     }
559     function ${prefix}state_changed(what) {
560 END
561
562   if ( $countyflag ) {
563     $script_html .= <<END;
564       state = what.options[what.selectedIndex].text;
565       country = what.form.${prefix}country.options[what.form.${prefix}country.selectedIndex].text;
566       for ( var i = what.form.${prefix}county.length; i >= 0; i-- )
567           what.form.${prefix}county.options[i] = null;
568 END
569
570     foreach my $country ( sort keys %cust_main_county ) {
571       $script_html .= "\nif ( country == \"$country\" ) {\n";
572       foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
573         $script_html .= "\nif ( state == \"$state\" ) {\n";
574           #foreach my $county ( sort @{$cust_main_county{$country}{$state}} ) {
575           foreach my $county ( sort keys %{$cust_main_county{$country}{$state}} ) {
576             my $text = $county || '(n/a)';
577             $script_html .=
578               qq!opt(what.form.${prefix}county, "$county", "$text");\n!;
579           }
580         $script_html .= "}\n";
581       }
582       $script_html .= "}\n";
583     }
584   }
585
586   $script_html .= <<END;
587     }
588     </SCRIPT>
589 END
590
591   my $county_html = $script_html;
592   if ( $countyflag ) {
593     $county_html .= qq!<SELECT NAME="${prefix}county" onChange="$onchange" $disabled>!;
594     $county_html .= '</SELECT>';
595   } else {
596     $county_html .=
597       qq!<INPUT TYPE="hidden" NAME="${prefix}county" VALUE="$selected_county">!;
598   }
599
600   my $state_html = qq!<SELECT NAME="${prefix}state" !.
601                    qq!onChange="${prefix}state_changed(this); $onchange" $disabled>!;
602   foreach my $state ( sort keys %{ $cust_main_county{$selected_country} } ) {
603     my $text = $state || '(n/a)';
604     my $selected = $state eq $selected_state ? 'SELECTED' : '';
605     $state_html .= qq(\n<OPTION $selected VALUE="$state">$text</OPTION>);
606   }
607   $state_html .= '</SELECT>';
608
609   $state_html .= '</SELECT>';
610
611   my $country_html = qq!<SELECT NAME="${prefix}country" !.
612                      qq!onChange="${prefix}country_changed(this); $onchange" $disabled>!;
613   my $countrydefault = $conf->config('countrydefault') || 'US';
614   foreach my $country (
615     sort { ($b eq $countrydefault) <=> ($a eq $countrydefault) or $a cmp $b }
616       keys %cust_main_county
617   ) {
618     my $selected = $country eq $selected_country ? ' SELECTED' : '';
619     $country_html .= qq(\n<OPTION$selected VALUE="$country">$country</OPTION>");
620   }
621   $country_html .= '</SELECT>';
622
623   ($county_html, $state_html, $country_html);
624
625 }
626
627 =back
628
629 =head1 BUGS
630
631 regionselector?  putting web ui components in here?  they should probably live
632 somewhere else...
633
634 =head1 SEE ALSO
635
636 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
637 documentation.
638
639 =cut
640
641 1;
642