pod
[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 =item label OPTIONS
141
142 Returns a label looking like "Anytown, Alameda County, CA, US".
143
144 If the taxname field is set, it will look like
145 "CA Sales Tax (Anytown, Alameda County, CA, US)".
146
147 If the taxclass is set, then it will be
148 "Anytown, Alameda County, CA, US (International)".
149
150 OPTIONS may contain "with_taxclass", "with_city", and "with_district" to show
151 those fields.  It may also contain "out", in which case, if this region 
152 (district+city+county+state+country) contains no non-zero taxes, the label 
153 will read "Out of taxable region(s)".
154
155 =cut
156
157 sub label {
158   my ($self, %opt) = @_;
159   if ( $opt{'out'} 
160        and $self->tax == 0
161        and !defined(qsearchs('cust_main_county', {
162            'district' => $self->district,
163            'city'     => $self->city,
164            'county'   => $self->county,
165            'state'    => $self->state,
166            'country'  => $self->country,
167            'tax'  => { op => '>', value => 0 },
168         })) )
169   {
170     return 'Out of taxable region(s)';
171   }
172   my $label = $self->country;
173   $label = $self->state.", $label" if $self->state;
174   $label = $self->county." County, $label" if $self->county;
175   if ($opt{with_city}) {
176     $label = $self->city.", $label" if $self->city;
177     if ($opt{with_district} and $self->district) {
178       $label = $self->district . ", $label";
179     }
180   }
181   # ugly labels when taxclass and taxname are both non-null...
182   # but this is how the tax report does it
183   if ($opt{with_taxclass}) {
184     $label = "$label (".$self->taxclass.')' if $self->taxclass;
185   }
186   $label = $self->taxname." ($label)" if $self->taxname;
187
188   $label;
189 }
190
191 =item sql_taxclass_sameregion
192
193 Returns an SQL WHERE fragment or the empty string to search for entries
194 with different tax classes.
195
196 =cut
197
198 #hmm, description above could be better...
199
200 sub sql_taxclass_sameregion {
201   my $self = shift;
202
203   my $same_query = 'SELECT DISTINCT taxclass FROM cust_main_county '.
204                    ' WHERE taxnum != ? AND country = ?';
205   my @same_param = ( 'taxnum', 'country' );
206   foreach my $opt_field (qw( state county )) {
207     if ( $self->$opt_field() ) {
208       $same_query .= " AND $opt_field = ?";
209       push @same_param, $opt_field;
210     } else {
211       $same_query .= " AND $opt_field IS NULL";
212     }
213   }
214
215   my @taxclasses = $self->_list_sql( \@same_param, $same_query );
216
217   return '' unless scalar(@taxclasses);
218
219   '( taxclass IS NULL OR ( '.  #only if !$self->taxclass ??
220      join(' AND ', map { 'taxclass != '.dbh->quote($_) } @taxclasses ). 
221   ' ) ) ';
222 }
223
224 sub _list_sql {
225   my( $self, $param, $sql ) = @_;
226   my $sth = dbh->prepare($sql) or die dbh->errstr;
227   $sth->execute( map $self->$_(), @$param )
228     or die "Unexpected error executing statement $sql: ". $sth->errstr;
229   map $_->[0], @{ $sth->fetchall_arrayref };
230 }
231
232 =item taxline TAXABLES_ARRAYREF, [ OPTION => VALUE ... ]
233
234 Takes an arrayref of L<FS::cust_bill_pkg> objects representing taxable
235 line items, and returns a new L<FS::cust_bill_pkg> object representing
236 the tax on them under this tax rate.
237
238 This will have a pseudo-field, "cust_bill_pkg_tax_location", containing 
239 an arrayref of L<FS::cust_bill_pkg_tax_location> objects.  Each of these 
240 will in turn have a "taxable_cust_bill_pkg" pseudo-field linking it to one
241 of the taxable items.  All of these links must be resolved as the objects
242 are inserted.
243
244 In addition to calculating the tax for the line items, this will calculate
245 any appropriate tax exemptions and attach them to the line items.
246
247 Options may include 'custnum' and 'invoice_time' in case the cust_bill_pkg
248 objects belong to an invoice that hasn't been inserted yet.
249
250 Options may include 'exemptions', an arrayref of L<FS::cust_tax_exempt_pkg>
251 objects belonging to the same customer, to be counted against the monthly 
252 tax exemption limit if there is one.
253
254 =cut
255
256 # XXX change tax_rate.pm to work like this
257
258 sub taxline {
259   my( $self, $taxables, %opt ) = @_;
260   return 'taxline called with no line items' unless @$taxables;
261
262   local $SIG{HUP} = 'IGNORE';
263   local $SIG{INT} = 'IGNORE';
264   local $SIG{QUIT} = 'IGNORE';
265   local $SIG{TERM} = 'IGNORE';
266   local $SIG{TSTP} = 'IGNORE';
267   local $SIG{PIPE} = 'IGNORE';
268
269   my $oldAutoCommit = $FS::UID::AutoCommit;
270   local $FS::UID::AutoCommit = 0;
271   my $dbh = dbh;
272
273   my $name = $self->taxname || 'Tax';
274   my $taxable_cents = 0;
275   my $tax_cents = 0;
276
277   my $cust_bill = $taxables->[0]->cust_bill;
278   my $custnum   = $cust_bill ? $cust_bill->custnum : $opt{'custnum'};
279   my $invoice_time = $cust_bill ? $cust_bill->_date : $opt{'invoice_time'};
280   my $cust_main = FS::cust_main->by_key($custnum) if $custnum > 0;
281   if (!$cust_main) {
282     # better way to handle this?  should we just assume that it's taxable?
283     die "unable to calculate taxes for an unknown customer\n";
284   }
285
286   # set a flag if the customer is tax-exempt
287   my $exempt_cust;
288   my $conf = FS::Conf->new;
289   if ( $conf->exists('cust_class-tax_exempt') ) {
290     my $cust_class = $cust_main->cust_class;
291     $exempt_cust = $cust_class->tax if $cust_class;
292   } else {
293     $exempt_cust = $cust_main->tax;
294   }
295
296   # set a flag if the customer is exempt from this tax here
297   my $exempt_cust_taxname = $cust_main->tax_exemption($self->taxname)
298     if $self->taxname;
299
300   # Gather any exemptions that are already attached to these cust_bill_pkgs
301   # so that we can deduct them from the customer's monthly limit.
302   my @existing_exemptions = @{ $opt{'exemptions'} };
303   push @existing_exemptions, @{ $_->cust_tax_exempt_pkg }
304     for @$taxables;
305
306   my $tax_item = FS::cust_bill_pkg->new({
307       'pkgnum'    => 0,
308       'recur'     => 0,
309       'sdate'     => '',
310       'edate'     => '',
311       'itemdesc'  => $name,
312   });
313   my @tax_location;
314
315   foreach my $cust_bill_pkg (@$taxables) {
316
317     my $cust_pkg  = $cust_bill_pkg->cust_pkg;
318     my $part_pkg  = $cust_bill_pkg->part_pkg;
319     my $part_fee  = $cust_bill_pkg->part_fee;
320
321     my $locationnum = $cust_pkg
322                       ? $cust_pkg->locationnum
323                       : $cust_main->bill_locationnum;
324
325     my @new_exemptions;
326     my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur
327       or next; # don't create zero-amount exemptions
328
329     # XXX the following procedure should probably be in cust_bill_pkg
330
331     if ( $exempt_cust ) {
332
333       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
334           amount => $taxable_charged,
335           exempt_cust => 'Y',
336         });
337       $taxable_charged = 0;
338
339     } elsif ( $exempt_cust_taxname ) {
340
341       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
342           amount => $taxable_charged,
343           exempt_cust_taxname => 'Y',
344         });
345       $taxable_charged = 0;
346
347     }
348
349     my $setup_exempt = ( ($part_fee and not $part_fee->taxable)
350                       or ($part_pkg and $part_pkg->setuptax)
351                       or $self->setuptax );
352
353     if ( $setup_exempt
354         and $cust_bill_pkg->setup > 0
355         and $taxable_charged > 0 ) {
356
357       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
358           amount => $cust_bill_pkg->setup,
359           exempt_setup => 'Y'
360       });
361       $taxable_charged -= $cust_bill_pkg->setup;
362
363     }
364
365     my $recur_exempt = ( ($part_fee and not $part_fee->taxable)
366                       or ($part_pkg and $part_pkg->recurtax)
367                       or $self->recurtax );
368
369     if ( $recur_exempt
370         and $cust_bill_pkg->recur > 0
371         and $taxable_charged > 0 ) {
372
373       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
374           amount => $cust_bill_pkg->recur,
375           exempt_recur => 'Y'
376       });
377       $taxable_charged -= $cust_bill_pkg->recur;
378     
379     }
380   
381     if ( $self->exempt_amount && $self->exempt_amount > 0 
382       and $taxable_charged > 0 ) {
383       # If the billing period extends across multiple calendar months, 
384       # there may be several months of exemption available.
385       my $sdate = $cust_bill_pkg->sdate || $invoice_time;
386       my $start_month = (localtime($sdate))[4] + 1;
387       my $start_year  = (localtime($sdate))[5] + 1900;
388       my $edate = $cust_bill_pkg->edate || $invoice_time;
389       my $end_month   = (localtime($edate))[4] + 1;
390       my $end_year    = (localtime($edate))[5] + 1900;
391
392       # If the partial last month + partial first month <= one month,
393       # don't use the exemption in the last month
394       # (unless the last month is also the first month, e.g. one-time
395       # charges)
396       if ( (localtime($sdate))[3] >= (localtime($edate))[3]
397            and ($start_month != $end_month or $start_year != $end_year)
398       ) { 
399         $end_month--;
400         if ( $end_month == 0 ) {
401           $end_year--;
402           $end_month = 12;
403         }
404       }
405
406       # number of months of exemption available
407       my $freq = ($end_month - $start_month) +
408                  ($end_year  - $start_year) * 12 +
409                  1;
410
411       # divide equally among all of them
412       my $permonth = sprintf('%.2f', $taxable_charged / $freq);
413
414       #call the whole thing off if this customer has any old
415       #exemption records...
416       my @cust_tax_exempt =
417         qsearch( 'cust_tax_exempt' => { custnum=> $custnum } );
418       if ( @cust_tax_exempt ) {
419         $dbh->rollback if $oldAutoCommit;
420         return
421           'this customer still has old-style tax exemption records; '.
422           'run bin/fs-migrate-cust_tax_exempt?';
423       }
424
425       my ($mon, $year) = ($start_month, $start_year);
426       while ($taxable_charged > 0.005 and 
427              ($year < $end_year or
428                ($year == $end_year and $mon <= $end_month)
429              )
430       ) {
431  
432         # find the sum of the exemption used by this customer, for this tax,
433         # in this month
434         my $sql = "
435           SELECT SUM(amount)
436             FROM cust_tax_exempt_pkg
437               LEFT JOIN cust_bill_pkg USING ( billpkgnum )
438               LEFT JOIN cust_bill     USING ( invnum     )
439             WHERE custnum = ?
440               AND taxnum  = ?
441               AND year    = ?
442               AND month   = ?
443               AND exempt_monthly = 'Y'
444         ";
445         my $sth = dbh->prepare($sql) or do {
446           $dbh->rollback if $oldAutoCommit;
447           return "fatal: can't lookup existing exemption: ". dbh->errstr;
448         };
449         $sth->execute(
450           $custnum,
451           $self->taxnum,
452           $year,
453           $mon,
454         ) or do {
455           $dbh->rollback if $oldAutoCommit;
456           return "fatal: can't lookup existing exemption: ". dbh->errstr;
457         };
458         my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0;
459
460         # add any exemption we're already using for another line item
461         foreach ( grep { $_->taxnum == $self->taxnum &&
462                          $_->exempt_monthly eq 'Y'   &&
463                          $_->month  == $mon          &&
464                          $_->year   == $year 
465                        } @existing_exemptions
466                 )
467         {
468           $existing_exemption += $_->amount;
469         }
470
471         my $remaining_exemption =
472           $self->exempt_amount - $existing_exemption;
473         if ( $remaining_exemption > 0 ) {
474           my $addl = $remaining_exemption > $permonth
475             ? $permonth
476             : $remaining_exemption;
477           $addl = $taxable_charged if $addl > $taxable_charged;
478
479           push @new_exemptions, FS::cust_tax_exempt_pkg->new({
480               amount          => sprintf('%.2f', $addl),
481               exempt_monthly  => 'Y',
482               year            => $year,
483               month           => $mon,
484             });
485           $taxable_charged -= $addl;
486         }
487         # if they're using multiple months of exemption for a multi-month
488         # package, then record the exemptions in separate months
489         $mon++;
490         if ( $mon > 12 ) {
491           $mon -= 12;
492           $year++;
493         }
494
495       }
496     } # if exempt_amount
497
498     $_->taxnum($self->taxnum) foreach @new_exemptions;
499
500     # attach them to the line item
501     push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, @new_exemptions;
502     push @existing_exemptions, @new_exemptions;
503
504     $taxable_charged = sprintf( "%.2f", $taxable_charged);
505     next if $taxable_charged == 0;
506
507     my $this_tax_cents = int($taxable_charged * $self->tax);
508     my $location = FS::cust_bill_pkg_tax_location->new({
509         'taxnum'      => $self->taxnum,
510         'taxtype'     => ref($self),
511         'cents'       => $this_tax_cents,
512         'pkgnum'      => $cust_bill_pkg->pkgnum,
513         'locationnum' => $locationnum,
514         'taxable_cust_bill_pkg' => $cust_bill_pkg,
515         'tax_cust_bill_pkg'     => $tax_item,
516     });
517     push @tax_location, $location;
518
519     $taxable_cents += $taxable_charged;
520     $tax_cents += $this_tax_cents;
521   } #foreach $cust_bill_pkg
522   
523   # now round and distribute
524   my $extra_cents = sprintf('%.2f', $taxable_cents * $self->tax / 100) * 100
525                     - $tax_cents;
526   # make sure we have an integer
527   $extra_cents = sprintf('%.0f', $extra_cents);
528   if ( $extra_cents < 0 ) {
529     die "nonsense extra_cents value $extra_cents";
530   }
531   $tax_cents += $extra_cents;
532   my $i = 0;
533   foreach (@tax_location) { # can never require more than a single pass, yes?
534     my $cents = $_->get('cents');
535     if ( $extra_cents > 0 ) {
536       $cents++;
537       $extra_cents--;
538     }
539     $_->set('amount', sprintf('%.2f', $cents/100));
540   }
541   $tax_item->set('setup' => sprintf('%.2f', $tax_cents / 100));
542   $tax_item->set('cust_bill_pkg_tax_location', \@tax_location);
543   
544   return $tax_item;
545 }
546
547 =back
548
549 =head1 SUBROUTINES
550
551 =over 4
552
553 =item regionselector [ COUNTY STATE COUNTRY [ PREFIX [ ONCHANGE [ DISABLED ] ] ] ]
554
555 =cut
556
557 sub regionselector {
558   my ( $selected_county, $selected_state, $selected_country,
559        $prefix, $onchange, $disabled ) = @_;
560
561   $prefix = '' unless defined $prefix;
562
563   $countyflag = 0;
564
565 #  unless ( @cust_main_county ) { #cache 
566     @cust_main_county = qsearch('cust_main_county', {} );
567     foreach my $c ( @cust_main_county ) {
568       $countyflag=1 if $c->county;
569       #push @{$cust_main_county{$c->country}{$c->state}}, $c->county;
570       $cust_main_county{$c->country}{$c->state}{$c->county} = 1;
571     }
572 #  }
573   $countyflag=1 if $selected_county;
574
575   my $script_html = <<END;
576     <SCRIPT>
577     function opt(what,value,text) {
578       var optionName = new Option(text, value, false, false);
579       var length = what.length;
580       what.options[length] = optionName;
581     }
582     function ${prefix}country_changed(what) {
583       country = what.options[what.selectedIndex].text;
584       for ( var i = what.form.${prefix}state.length; i >= 0; i-- )
585           what.form.${prefix}state.options[i] = null;
586 END
587       #what.form.${prefix}state.options[0] = new Option('', '', false, true);
588
589   foreach my $country ( sort keys %cust_main_county ) {
590     $script_html .= "\nif ( country == \"$country\" ) {\n";
591     foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
592       ( my $dstate = $state ) =~ s/[\n\r]//g;
593       my $text = $dstate || '(n/a)';
594       $script_html .= qq!opt(what.form.${prefix}state, "$dstate", "$text");\n!;
595     }
596     $script_html .= "}\n";
597   }
598
599   $script_html .= <<END;
600     }
601     function ${prefix}state_changed(what) {
602 END
603
604   if ( $countyflag ) {
605     $script_html .= <<END;
606       state = what.options[what.selectedIndex].text;
607       country = what.form.${prefix}country.options[what.form.${prefix}country.selectedIndex].text;
608       for ( var i = what.form.${prefix}county.length; i >= 0; i-- )
609           what.form.${prefix}county.options[i] = null;
610 END
611
612     foreach my $country ( sort keys %cust_main_county ) {
613       $script_html .= "\nif ( country == \"$country\" ) {\n";
614       foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
615         $script_html .= "\nif ( state == \"$state\" ) {\n";
616           #foreach my $county ( sort @{$cust_main_county{$country}{$state}} ) {
617           foreach my $county ( sort keys %{$cust_main_county{$country}{$state}} ) {
618             my $text = $county || '(n/a)';
619             $script_html .=
620               qq!opt(what.form.${prefix}county, "$county", "$text");\n!;
621           }
622         $script_html .= "}\n";
623       }
624       $script_html .= "}\n";
625     }
626   }
627
628   $script_html .= <<END;
629     }
630     </SCRIPT>
631 END
632
633   my $county_html = $script_html;
634   if ( $countyflag ) {
635     $county_html .= qq!<SELECT NAME="${prefix}county" onChange="$onchange" $disabled>!;
636     $county_html .= '</SELECT>';
637   } else {
638     $county_html .=
639       qq!<INPUT TYPE="hidden" NAME="${prefix}county" VALUE="$selected_county">!;
640   }
641
642   my $state_html = qq!<SELECT NAME="${prefix}state" !.
643                    qq!onChange="${prefix}state_changed(this); $onchange" $disabled>!;
644   foreach my $state ( sort keys %{ $cust_main_county{$selected_country} } ) {
645     my $text = $state || '(n/a)';
646     my $selected = $state eq $selected_state ? 'SELECTED' : '';
647     $state_html .= qq(\n<OPTION $selected VALUE="$state">$text</OPTION>);
648   }
649   $state_html .= '</SELECT>';
650
651   $state_html .= '</SELECT>';
652
653   my $country_html = qq!<SELECT NAME="${prefix}country" !.
654                      qq!onChange="${prefix}country_changed(this); $onchange" $disabled>!;
655   my $countrydefault = $conf->config('countrydefault') || 'US';
656   foreach my $country (
657     sort { ($b eq $countrydefault) <=> ($a eq $countrydefault) or $a cmp $b }
658       keys %cust_main_county
659   ) {
660     my $selected = $country eq $selected_country ? ' SELECTED' : '';
661     $country_html .= qq(\n<OPTION$selected VALUE="$country">$country</OPTION>");
662   }
663   $country_html .= '</SELECT>';
664
665   ($county_html, $state_html, $country_html);
666
667 }
668
669 =back
670
671 =head1 BUGS
672
673 regionselector?  putting web ui components in here?  they should probably live
674 somewhere else...
675
676 =head1 SEE ALSO
677
678 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
679 documentation.
680
681 =cut
682
683 1;
684