fix TeleAPI import (what kind of crack was Christopher smoking that he couldn't fix...
[freeside.git] / FS / FS / cust_bill_pkg_tax_location.pm
1 package FS::cust_bill_pkg_tax_location;
2 use base qw( FS::Record );
3
4 use strict;
5 use List::Util qw(sum min);
6 use FS::Record qw( dbh qsearch qsearchs );
7 use FS::cust_bill_pkg;
8 use FS::cust_pkg;
9 use FS::cust_bill_pay_pkg;
10 use FS::cust_credit_bill_pkg;
11 use FS::cust_main_county;
12 use FS::Log;
13
14 =head1 NAME
15
16 FS::cust_bill_pkg_tax_location - Object methods for cust_bill_pkg_tax_location records
17
18 =head1 SYNOPSIS
19
20   use FS::cust_bill_pkg_tax_location;
21
22   $record = new FS::cust_bill_pkg_tax_location \%hash;
23   $record = new FS::cust_bill_pkg_tax_location { 'column' => 'value' };
24
25   $error = $record->insert;
26
27   $error = $new_record->replace($old_record);
28
29   $error = $record->delete;
30
31   $error = $record->check;
32
33 =head1 DESCRIPTION
34
35 An FS::cust_bill_pkg_tax_location object represents an record of taxation
36 based on package location.  FS::cust_bill_pkg_tax_location inherits from
37 FS::Record.  The following fields are currently supported:
38
39 =over 4
40
41 =item billpkgtaxlocationnum
42
43 billpkgtaxlocationnum
44
45 =item billpkgnum
46
47 billpkgnum
48
49 =item taxnum
50
51 taxnum
52
53 =item taxtype
54
55 taxtype
56
57 =item pkgnum
58
59 pkgnum
60
61 =item locationnum
62
63 locationnum
64
65 =item amount
66
67 amount
68
69 =item taxable_billpkgnum
70
71 The billpkgnum of the L<FS::cust_bill_pkg> that this tax was charged on.
72 It may specifically be on any portion of that line item (setup, recurring,
73 or a usage class).
74
75 =back
76
77 =head1 METHODS
78
79 =over 4
80
81 =item new HASHREF
82
83 Creates a new record.  To add the record to the database, see L<"insert">.
84
85 Note that this stores the hash reference, not a distinct copy of the hash it
86 points to.  You can ask the object for a copy with the I<hash> method.
87
88 =cut
89
90 sub table { 'cust_bill_pkg_tax_location'; }
91
92 =item insert
93
94 Adds this record to the database.  If there is an error, returns the error,
95 otherwise returns false.
96
97 =item delete
98
99 Delete this record from the database.
100
101 =item replace OLD_RECORD
102
103 Replaces the OLD_RECORD with this one in the database.  If there is an error,
104 returns the error, otherwise returns false.
105
106 =item check
107
108 Checks all fields to make sure this is a valid record.  If there is
109 an error, returns the error, otherwise returns false.  Called by the insert
110 and replace methods.
111
112 =cut
113
114 # the check method should currently be supplied - FS::Record contains some
115 # data checking routines
116
117 sub check {
118   my $self = shift;
119
120   my $error = 
121     $self->ut_numbern('billpkgtaxlocationnum')
122     || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg', 'billpkgnum' )
123     || $self->ut_number('taxnum') #cust_bill_pkg/tax_rate key, based on taxtype
124     || $self->ut_enum('taxtype', [ qw( FS::cust_main_county FS::tax_rate ) ] )
125     || $self->ut_number('pkgnum', 'cust_pkg', 'pkgnum' )
126     || $self->ut_foreign_key('locationnum', 'cust_location', 'locationnum' )
127     || $self->ut_money('amount')
128     || $self->ut_foreign_keyn('taxable_billpkgnum', 'cust_bill_pkg', 'billpkgnum')
129   ;
130   return $error if $error;
131
132   $self->SUPER::check;
133 }
134
135 =item cust_bill_pkg
136
137 Returns the associated cust_bill_pkg object (i.e. the tax charge).
138
139 =item taxable_cust_bill_pkg
140
141 Returns the cust_bill_pkg object for the I<taxable> charge.
142
143 =item cust_location
144
145 Returns the associated cust_location object
146
147 =item taxname
148
149 Returns the tax name (for populating the itemdesc field).
150
151 =cut
152
153 sub taxname {
154   my $self = shift;
155   my $cust_main_county = FS::cust_main_county->by_key($self->taxnum)
156     or return '';
157   $cust_main_county->taxname || 'Tax';
158 }
159
160 =item desc
161
162 Returns a description for this tax line item constituent.  Currently this
163 is the desc of the associated line item followed by the state/county/city
164 for the location in parentheses.
165
166 =cut
167
168 sub desc {
169   my $self = shift;
170   my $cust_location = $self->cust_location;
171   my $location = join('/', grep { $_ }                 # leave in?
172                            map { $cust_location->$_ }
173                            qw( state county city )     # country?
174   );
175   my $cust_bill_pkg_desc = $self->billpkgnum
176                          ? $self->cust_bill_pkg->desc
177                          : $self->cust_bill_pkg_desc;
178   "$cust_bill_pkg_desc ($location)";
179 }
180
181 =item owed
182
183 Returns the amount owed (still outstanding) on this tax line item which is
184 the amount of this record minus all payment applications and credit
185 applications.
186
187 =cut
188
189 sub owed {
190   my $self = shift;
191   my $balance = $self->amount;
192   $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg('setup') );
193   $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg('setup') );
194   $balance = sprintf( '%.2f', $balance );
195   $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
196   $balance;
197 }
198
199 sub cust_bill_pay_pkg {
200   my $self = shift;
201   qsearch( 'cust_bill_pay_pkg',
202            { map { $_ => $self->$_ } qw( billpkgtaxlocationnum billpkgnum ) }
203          );
204 }
205
206 sub cust_credit_bill_pkg {
207   my $self = shift;
208   qsearch( 'cust_credit_bill_pkg',
209            { map { $_ => $self->$_ } qw( billpkgtaxlocationnum billpkgnum ) }
210          );
211 }
212
213 sub cust_main_county {
214   my $self = shift;
215   return '' unless $self->taxtype eq 'FS::cust_main_county';
216   qsearchs( 'cust_main_county', { 'taxnum' => $self->taxnum } );
217 }
218
219 sub _upgrade_data {
220   eval {
221     use FS::queue;
222     use Date::Parse 'str2time';
223   };
224   my $class = shift;
225   my $upgrade = 'tax_location_taxable_billpkgnum';
226   return if FS::upgrade_journal->is_done($upgrade);
227   my $job = FS::queue->new({ job => 
228       'FS::cust_bill_pkg_tax_location::upgrade_taxable_billpkgnum'
229   });
230   $job->insert($class, 's' => str2time('2012-01-01'));
231   FS::upgrade_journal->set_done($upgrade);
232 }
233
234 sub upgrade_taxable_billpkgnum {
235   # Associate these records to the correct taxable line items.
236   # The cust_bill_pkg upgrade now does this also for pre-3.0 records that 
237   # aren't broken out by pkgnum, so we only need to deal with the case of 
238   # multiple line items for the same pkgnum.
239   # Despite appearances, this has almost no relation to the upgrade in
240   # FS::cust_bill_pkg.
241
242   my ($class, %opt) = @_;
243   my $dbh = dbh;
244   my $oldAutoCommit = $FS::UID::AutoCommit;
245   local $FS::UID::AutoCommit = 0;
246   my $log = FS::Log->new('upgrade_taxable_billpkgnum');
247
248   my $date_where = '';
249   if ( $opt{s} ) {
250     $date_where .= " AND cust_bill._date >= $opt{s}";
251   }
252   if ( $opt{e} ) {
253     $date_where .= " AND cust_bill._date < $opt{e}";
254   }
255
256   my @need_to_upgrade = qsearch({
257       select => 'cust_bill_pkg_tax_location.*',
258       table => 'cust_bill_pkg_tax_location',
259       hashref => { taxable_billpkgnum => '' },
260       addl_from => 'JOIN cust_bill_pkg USING (billpkgnum)'.
261                    'JOIN cust_bill     USING (invnum)',
262       extra_sql => $date_where,
263   });
264   $log->info('Starting upgrade of '.scalar(@need_to_upgrade).
265       ' cust_bill_pkg_tax_location records.');
266
267   # keys are billpkgnums
268   my %cust_bill_pkg;
269   my %tax_location;
270   foreach (@need_to_upgrade) {
271     my $tax_billpkgnum = $_->billpkgnum;
272     $cust_bill_pkg{ $tax_billpkgnum } ||= FS::cust_bill_pkg->by_key($tax_billpkgnum);
273     $tax_location{ $tax_billpkgnum } ||= [];
274     push @{ $tax_location{ $tax_billpkgnum } }, $_;
275   }
276
277   TAX_ITEM: foreach my $tax_item (values %cust_bill_pkg) {
278     my $tax_locations = $tax_location{ $tax_item->billpkgnum };
279     my $invnum = $tax_item->invnum;
280     my $cust_bill = FS::cust_bill->by_key($tax_item->invnum);
281     my %tax_on_pkg; # keys are tax identifiers
282     TAX_LOCATION: foreach my $tax_location (@$tax_locations) {
283     # recapitulate the "cust_main_county $taxnum $pkgnum" tax identifier,
284     # in a way
285       my $taxid = join(' ',
286         $tax_location->taxtype,
287         $tax_location->taxnum,
288         $tax_location->pkgnum,
289         $tax_location->locationnum
290       );
291       $tax_on_pkg{$taxid} ||= [];
292       push @{ $tax_on_pkg{$taxid} }, $tax_location;
293     }
294     PKGNUM: foreach my $taxid (keys %tax_on_pkg) {
295       my ($taxtype, $taxnum, $pkgnum, $locationnum) = split(' ', $taxid);
296       $log->info("tax#$taxnum, pkg#$pkgnum", object => $cust_bill);
297       my @pkg_items = $cust_bill->cust_bill_pkg_pkgnum($pkgnum);
298       if (!@pkg_items) {
299         # then how is there tax on it? should never happen
300         $log->error("no line items with pkg#$pkgnum", object => $cust_bill);
301         next PKGNUM;
302       }
303       my $pkg_amount = 0;
304       foreach my $pkg_item (@pkg_items) {
305         # find the taxable amount of each one
306         my $amount = $pkg_item->setup + $pkg_item->recur;
307         # subtract any exemptions that apply to this taxdef
308         foreach (qsearch('cust_tax_exempt_pkg', {
309                   taxnum      => $taxnum,
310                   billpkgnum  => $pkg_item->billpkgnum
311                  }) )
312         {
313           $amount -= $_->amount;
314         }
315         $pkg_item->set('amount' => $pkg_item->setup + $pkg_item->recur);
316         $pkg_amount += $amount;
317       } #$pkg_item
318       next PKGNUM if $pkg_amount == 0; # probably because it's fully exempted
319       # now sort them descending by taxable amount
320       @pkg_items = sort { $b->amount <=> $a->amount }
321                    @pkg_items;
322       # and do the same with the tax links
323       # (there should be one per taxed item)
324       my @tax_links = sort { $b->amount <=> $a->amount }
325                       @{ $tax_on_pkg{$taxid} };
326
327       if (scalar(@tax_links) == scalar(@pkg_items)) {
328         # the relatively simple case: they match 1:1
329         for my $i (0 .. scalar(@tax_links) - 1) {
330           $tax_links[$i]->set('taxable_billpkgnum', 
331                               $pkg_items[$i]->billpkgnum);
332           my $error = $tax_links[$i]->replace;
333           if ( $error ) {
334             $log->error("failed to set taxable_billpkgnum in tax on pkg#$pkgnum",
335               object => $cust_bill);
336             next PKGNUM;
337           }
338         } #for $i
339       } else {
340         # the more complicated case
341         $log->warning("mismatched charges and tax links in pkg#$pkgnum",
342           object => $cust_bill);
343         my $tax_amount = sum(map {$_->amount} @tax_links);
344         # remove all tax link records and recreate them to be 1:1 with 
345         # taxable items
346         my (%billpaynum, %creditbillnum);
347         my $link_type;
348         foreach my $tax_link (@tax_links) {
349           $link_type ||= ref($tax_link);
350           my $error = $tax_link->delete;
351           if ( $error ) {
352             $log->error("error unlinking tax#$taxnum pkg#$pkgnum",
353               object => $cust_bill);
354             next PKGNUM;
355           }
356           my $pkey = $tax_link->primary_key;
357           # also remove all applications that reference this tax link
358           # (they will be applications to the tax item)
359           my %hash = ($pkey => $tax_link->get($pkey));
360           foreach (qsearch('cust_bill_pay_pkg', \%hash)) {
361             $billpaynum{$_->billpaynum} += $_->amount;
362             my $error = $_->delete;
363             die "error unapplying payment: $error" if ( $error );
364           }
365           foreach (qsearch('cust_credit_bill_pkg', \%hash)) {
366             $creditbillnum{$_->creditbillnum} += $_->amount;
367             my $error = $_->delete;
368             die "error unapplying credit: $error" if ( $error );
369           }
370         }
371         @tax_links = ();
372         my $cents_remaining = int(100 * $tax_amount);
373         foreach my $pkg_item (@pkg_items) {
374           my $cents = int(100 * $pkg_item->amount * $tax_amount / $pkg_amount);
375           my $tax_link = $link_type->new({
376               taxable_billpkgnum => $pkg_item->billpkgnum,
377               billpkgnum  => $tax_item->billpkgnum,
378               taxnum      => $taxnum,
379               taxtype     => $taxtype,
380               pkgnum      => $pkgnum,
381               locationnum => $locationnum,
382               cents       => $cents,
383           });
384           push @tax_links, $tax_link;
385           $cents_remaining -= $cents;
386         }
387         my $nlinks = scalar @tax_links;
388         my $i = 0;
389         while ($cents_remaining) {
390           $tax_links[$i % $nlinks]->set('cents' =>
391             $tax_links[$i % $nlinks]->cents + 1
392           );
393           $cents_remaining--;
394           $i++;
395         }
396         foreach my $tax_link (@tax_links) {
397           $tax_link->set('amount' => sprintf('%.2f', $tax_link->cents / 100));
398           my $error = $tax_link->insert;
399           if ( $error ) {
400             $log->error("error relinking tax#$taxnum pkg#$pkgnum",
401               object => $cust_bill);
402             next PKGNUM;
403           }
404         }
405
406         $i = 0;
407         my $error;
408         my $left = 0; # the amount "left" on the last tax link after 
409                       # applying payments, but before credits, so that 
410                       # it can receive both a payment and a credit if 
411                       # necessary
412         # reapply payments/credits...this sucks
413         foreach my $billpaynum (keys %billpaynum) {
414           my $pay_amount = $billpaynum{$billpaynum};
415           while ($i < $nlinks and $pay_amount > 0) {
416             my $this_amount = min($pay_amount, $tax_links[$i]->amount);
417             $left = $tax_links[$i]->amount - $this_amount;
418             my $app = FS::cust_bill_pay_pkg->new({
419                 billpaynum            => $billpaynum,
420                 billpkgnum            => $tax_links[$i]->billpkgnum,
421                 billpkgtaxlocationnum => $tax_links[$i]->billpkgtaxlocationnum,
422                 amount                => $this_amount,
423                 setuprecur            => 'setup',
424                 # sdate/edate are null
425             });
426             my $error ||= $app->insert;
427             $pay_amount -= $this_amount;
428             $i++ if $left == 0;
429           }
430         }
431         foreach my $creditbillnum (keys %creditbillnum) {
432           my $credit_amount = $creditbillnum{$creditbillnum};
433           while ($i < $nlinks and $credit_amount > 0) {
434             my $this_amount = min($left, $credit_amount, $tax_links[$i]->amount);
435             $left = $credit_amount * 2; # just so it can't be selected twice
436             $i++ if    $this_amount == $left 
437                     or $this_amount == $tax_links[$i]->amount;
438             my $app = FS::cust_credit_bill_pkg->new({
439                 creditbillnum         => $creditbillnum,
440                 billpkgnum            => $tax_links[$i]->billpkgnum,
441                 billpkgtaxlocationnum => $tax_links[$i]->billpkgtaxlocationnum,
442                 amount                => $this_amount,
443                 setuprecur            => 'setup',
444                 # sdate/edate are null
445             });
446             my $error ||= $app->insert;
447             $credit_amount -= $this_amount;
448           }
449         }
450         if ( $error ) {
451           # we've just unapplied a bunch of stuff, so if it won't reapply
452           # we really need to revert the whole transaction
453           die "error reapplying payments/credits: $error; upgrade halted";
454         }
455       } # scalar(@tax_links) ?= scalar(@pkg_items)
456     } #taxnum/pkgnum
457   } #TAX_ITEM
458
459   $log->info('finish');
460
461   $dbh->commit if $oldAutoCommit;
462   return;
463 }
464
465 =cut
466
467 =back
468
469 =head1 BUGS
470
471 The presence of FS::cust_main_county::delete makes the cust_main_county method
472 unreliable.
473
474 Pre-3.0 versions of Freeside would only create one cust_bill_pkg_tax_location
475 per tax definition (taxtype/taxnum) per invoice.  The pkgnum and locationnum 
476 fields were arbitrarily set to those of the first line item subject to the 
477 tax.  This created problems if the tax contribution of each line item ever 
478 needed to be determined (for example, when applying credits).  For several
479 months in 2012, this was changed to create one record per tax definition 
480 per I<package> per invoice, which was still not specific enough to identify
481 a line item.
482
483 The current behavior is to create one record per tax definition per taxable
484 line item, and to store the billpkgnum of the taxed line item in the record.
485 The upgrade will try to convert existing records to the new format, but this 
486 is not perfectly reliable.
487
488 =head1 SEE ALSO
489
490 L<FS::Record>, schema.html from the base documentation.
491
492 =cut
493
494 1;
495