new tax rating engine
[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 );
6 use Exporter;
7 use FS::Record qw( qsearch 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
21 #ask FS::UID to run this stuff for us later
22 $FS::UID::callback{'FS::cust_main_county'} = sub { 
23   $conf = new FS::Conf;
24 };
25
26 =head1 NAME
27
28 FS::cust_main_county - Object methods for cust_main_county objects
29
30 =head1 SYNOPSIS
31
32   use FS::cust_main_county;
33
34   $record = new FS::cust_main_county \%hash;
35   $record = new FS::cust_main_county { 'column' => 'value' };
36
37   $error = $record->insert;
38
39   $error = $new_record->replace($old_record);
40
41   $error = $record->delete;
42
43   $error = $record->check;
44
45   ($county_html, $state_html, $country_html) =
46     FS::cust_main_county::regionselector( $county, $state, $country );
47
48 =head1 DESCRIPTION
49
50 An FS::cust_main_county object represents a tax rate, defined by locale.
51 FS::cust_main_county inherits from FS::Record.  The following fields are
52 currently supported:
53
54 =over 4
55
56 =item taxnum - primary key (assigned automatically for new tax rates)
57
58 =item state
59
60 =item county
61
62 =item country
63
64 =item tax - percentage
65
66 =item taxclass
67
68 =item exempt_amount
69
70 =item taxname - if defined, printed on invoices instead of "Tax"
71
72 =item setuptax - if 'Y', this tax does not apply to setup fees
73
74 =item recurtax - if 'Y', this tax does not apply to recurring fees
75
76 =back
77
78 =head1 METHODS
79
80 =over 4
81
82 =item new HASHREF
83
84 Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.
85
86 =cut
87
88 sub table { 'cust_main_county'; }
89
90 =item insert
91
92 Adds this tax rate to the database.  If there is an error, returns the error,
93 otherwise returns false.
94
95 =item delete
96
97 Deletes this tax rate from the database.  If there is an error, returns the
98 error, otherwise returns false.
99
100 =item replace OLD_RECORD
101
102 Replaces the OLD_RECORD with this one in the database.  If there is an error,
103 returns the error, otherwise returns false.
104
105 =item check
106
107 Checks all fields to make sure this is a valid tax rate.  If there is an error,
108 returns the error, otherwise returns false.  Called by the insert and replace
109 methods.
110
111 =cut
112
113 sub check {
114   my $self = shift;
115
116   $self->exempt_amount(0) unless $self->exempt_amount;
117
118   $self->ut_numbern('taxnum')
119     || $self->ut_anything('state')
120     || $self->ut_textn('county')
121     || $self->ut_text('country')
122     || $self->ut_float('tax')
123     || $self->ut_textn('taxclass') # ...
124     || $self->ut_money('exempt_amount')
125     || $self->ut_textn('taxname')
126     || $self->ut_enum('setuptax', [ '', 'Y' ] )
127     || $self->ut_enum('recurtax', [ '', 'Y' ] )
128     || $self->SUPER::check
129     ;
130
131 }
132
133 sub taxname {
134   my $self = shift;
135   if ( $self->dbdef_table->column('taxname') ) {
136     return $self->setfield('taxname', $_[0]) if @_;
137     return $self->getfield('taxname');
138   }  
139   return '';
140 }
141
142 sub setuptax {
143   my $self = shift;
144   if ( $self->dbdef_table->column('setuptax') ) {
145     return $self->setfield('setuptax', $_[0]) if @_;
146     return $self->getfield('setuptax');
147   }  
148   return '';
149 }
150
151 sub recurtax {
152   my $self = shift;
153   if ( $self->dbdef_table->column('recurtax') ) {
154     return $self->setfield('recurtax', $_[0]) if @_;
155     return $self->getfield('recurtax');
156   }  
157   return '';
158 }
159
160 =item taxline CUST_BILL_PKG, ...
161
162 Returns a listref of a name and an amount of tax calculated for the list of
163 packages.  Returns a scalar error message on error.
164
165 =cut
166
167 sub taxline {
168   my $self = shift;
169
170   local $SIG{HUP} = 'IGNORE';
171   local $SIG{INT} = 'IGNORE';
172   local $SIG{QUIT} = 'IGNORE';
173   local $SIG{TERM} = 'IGNORE';
174   local $SIG{TSTP} = 'IGNORE';
175   local $SIG{PIPE} = 'IGNORE';
176
177   my $oldAutoCommit = $FS::UID::AutoCommit;
178   local $FS::UID::AutoCommit = 0;
179   my $dbh = dbh;
180
181   my $name = $self->taxname || 'Tax';
182   my $amount = 0;
183
184   foreach my $cust_bill_pkg (@_) {
185
186     my $cust_bill = $cust_bill_pkg->cust_pkg->cust_bill;
187     my $part_pkg = $cust_bill_pkg->cust_pkg->part_pkg;
188   
189     my $taxable_charged = 0;
190     $taxable_charged += $cust_bill_pkg->setup
191       unless $part_pkg->setuptax =~ /^Y$/i
192           || $self->setuptax =~ /^Y$/i;
193     $taxable_charged += $cust_bill_pkg->recur
194       unless $part_pkg->recurtax =~ /^Y$/i
195           || $self->recurtax =~ /^Y$/i;
196
197     return [ $name, 0 ]
198       unless $taxable_charged;
199   
200     if ( $self->exempt_amount && $self->exempt_amount > 0 ) {
201       #my ($mon,$year) = (localtime($cust_bill_pkg->sdate) )[4,5];
202       my ($mon,$year) =
203         (localtime( $cust_bill_pkg->sdate || $cust_bill->_date ) )[4,5];
204       $mon++;
205       my $freq = $part_pkg->freq || 1;
206       if ( $freq !~ /(\d+)$/ ) {
207         $dbh->rollback if $oldAutoCommit;
208         return "daily/weekly package definitions not (yet?)".
209                " compatible with monthly tax exemptions";
210       }
211       my $taxable_per_month =
212         sprintf("%.2f", $taxable_charged / $freq );
213
214       #call the whole thing off if this customer has any old
215       #exemption records...
216       my @cust_tax_exempt =
217         qsearch( 'cust_tax_exempt' => { custnum=> $cust_bill->custnum } );
218       if ( @cust_tax_exempt ) {
219         $dbh->rollback if $oldAutoCommit;
220         return
221           'this customer still has old-style tax exemption records; '.
222           'run bin/fs-migrate-cust_tax_exempt?';
223       }
224
225       foreach my $which_month ( 1 .. $freq ) {
226   
227         #maintain the new exemption table now
228         my $sql = "
229           SELECT SUM(amount)
230             FROM cust_tax_exempt_pkg
231               LEFT JOIN cust_bill_pkg USING ( billpkgnum )
232               LEFT JOIN cust_bill     USING ( invnum     )
233             WHERE custnum = ?
234               AND taxnum  = ?
235               AND year    = ?
236               AND month   = ?
237         ";
238         my $sth = dbh->prepare($sql) or do {
239           $dbh->rollback if $oldAutoCommit;
240           return "fatal: can't lookup exising exemption: ". dbh->errstr;
241         };
242         $sth->execute(
243           $cust_bill->custnum,
244           $self->taxnum,
245           1900+$year,
246           $mon,
247         ) or do {
248           $dbh->rollback if $oldAutoCommit;
249           return "fatal: can't lookup exising exemption: ". dbh->errstr;
250         };
251         my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0;
252         
253         my $remaining_exemption =
254           $self->exempt_amount - $existing_exemption;
255         if ( $remaining_exemption > 0 ) {
256           my $addl = $remaining_exemption > $taxable_per_month
257             ? $taxable_per_month
258             : $remaining_exemption;
259           $taxable_charged -= $addl;
260
261           my $cust_tax_exempt_pkg = new FS::cust_tax_exempt_pkg ( {
262             'billpkgnum' => $cust_bill_pkg->billpkgnum,
263             'taxnum'     => $self->taxnum,
264             'year'       => 1900+$year,
265             'month'      => $mon,
266             'amount'     => sprintf("%.2f", $addl ),
267           } );
268           my $error = $cust_tax_exempt_pkg->insert;
269           if ( $error ) {
270             $dbh->rollback if $oldAutoCommit;
271             return "fatal: can't insert cust_tax_exempt_pkg: $error";
272           }
273         } # if $remaining_exemption > 0
274
275         #++
276         $mon++;
277         #until ( $mon < 12 ) { $mon -= 12; $year++; }
278         until ( $mon < 13 ) { $mon -= 12; $year++; }
279
280       } #foreach $which_month
281
282     } #if $tax->exempt_amount
283
284     $taxable_charged = sprintf( "%.2f", $taxable_charged);
285
286     $amount += $taxable_charged * $self->tax / 100
287   }
288
289   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
290   return [ $name, $amount ]
291 }
292
293 =back
294
295 =head1 SUBROUTINES
296
297 =over 4
298
299 =item regionselector [ COUNTY STATE COUNTRY [ PREFIX [ ONCHANGE [ DISABLED ] ] ] ]
300
301 =cut
302
303 sub regionselector {
304   my ( $selected_county, $selected_state, $selected_country,
305        $prefix, $onchange, $disabled ) = @_;
306
307   $prefix = '' unless defined $prefix;
308
309   $countyflag = 0;
310
311 #  unless ( @cust_main_county ) { #cache 
312     @cust_main_county = qsearch('cust_main_county', {} );
313     foreach my $c ( @cust_main_county ) {
314       $countyflag=1 if $c->county;
315       #push @{$cust_main_county{$c->country}{$c->state}}, $c->county;
316       $cust_main_county{$c->country}{$c->state}{$c->county} = 1;
317     }
318 #  }
319   $countyflag=1 if $selected_county;
320
321   my $script_html = <<END;
322     <SCRIPT>
323     function opt(what,value,text) {
324       var optionName = new Option(text, value, false, false);
325       var length = what.length;
326       what.options[length] = optionName;
327     }
328     function ${prefix}country_changed(what) {
329       country = what.options[what.selectedIndex].text;
330       for ( var i = what.form.${prefix}state.length; i >= 0; i-- )
331           what.form.${prefix}state.options[i] = null;
332 END
333       #what.form.${prefix}state.options[0] = new Option('', '', false, true);
334
335   foreach my $country ( sort keys %cust_main_county ) {
336     $script_html .= "\nif ( country == \"$country\" ) {\n";
337     foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
338       ( my $dstate = $state ) =~ s/[\n\r]//g;
339       my $text = $dstate || '(n/a)';
340       $script_html .= qq!opt(what.form.${prefix}state, "$dstate", "$text");\n!;
341     }
342     $script_html .= "}\n";
343   }
344
345   $script_html .= <<END;
346     }
347     function ${prefix}state_changed(what) {
348 END
349
350   if ( $countyflag ) {
351     $script_html .= <<END;
352       state = what.options[what.selectedIndex].text;
353       country = what.form.${prefix}country.options[what.form.${prefix}country.selectedIndex].text;
354       for ( var i = what.form.${prefix}county.length; i >= 0; i-- )
355           what.form.${prefix}county.options[i] = null;
356 END
357
358     foreach my $country ( sort keys %cust_main_county ) {
359       $script_html .= "\nif ( country == \"$country\" ) {\n";
360       foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
361         $script_html .= "\nif ( state == \"$state\" ) {\n";
362           #foreach my $county ( sort @{$cust_main_county{$country}{$state}} ) {
363           foreach my $county ( sort keys %{$cust_main_county{$country}{$state}} ) {
364             my $text = $county || '(n/a)';
365             $script_html .=
366               qq!opt(what.form.${prefix}county, "$county", "$text");\n!;
367           }
368         $script_html .= "}\n";
369       }
370       $script_html .= "}\n";
371     }
372   }
373
374   $script_html .= <<END;
375     }
376     </SCRIPT>
377 END
378
379   my $county_html = $script_html;
380   if ( $countyflag ) {
381     $county_html .= qq!<SELECT NAME="${prefix}county" onChange="$onchange" $disabled>!;
382     $county_html .= '</SELECT>';
383   } else {
384     $county_html .=
385       qq!<INPUT TYPE="hidden" NAME="${prefix}county" VALUE="$selected_county">!;
386   }
387
388   my $state_html = qq!<SELECT NAME="${prefix}state" !.
389                    qq!onChange="${prefix}state_changed(this); $onchange" $disabled>!;
390   foreach my $state ( sort keys %{ $cust_main_county{$selected_country} } ) {
391     my $text = $state || '(n/a)';
392     my $selected = $state eq $selected_state ? 'SELECTED' : '';
393     $state_html .= qq(\n<OPTION $selected VALUE="$state">$text</OPTION>);
394   }
395   $state_html .= '</SELECT>';
396
397   $state_html .= '</SELECT>';
398
399   my $country_html = qq!<SELECT NAME="${prefix}country" !.
400                      qq!onChange="${prefix}country_changed(this); $onchange" $disabled>!;
401   my $countrydefault = $conf->config('countrydefault') || 'US';
402   foreach my $country (
403     sort { ($b eq $countrydefault) <=> ($a eq $countrydefault) or $a cmp $b }
404       keys %cust_main_county
405   ) {
406     my $selected = $country eq $selected_country ? ' SELECTED' : '';
407     $country_html .= qq(\n<OPTION$selected VALUE="$country">$country</OPTION>");
408   }
409   $country_html .= '</SELECT>';
410
411   ($county_html, $state_html, $country_html);
412
413 }
414
415 =back
416
417 =head1 BUGS
418
419 regionselector?  putting web ui components in here?  they should probably live
420 somewhere else...
421
422 =head1 SEE ALSO
423
424 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
425 documentation.
426
427 =cut
428
429 1;
430