Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / cust_bill_pkg.pm
1 package FS::cust_bill_pkg;
2 use base qw( FS::TemplateItem_Mixin FS::cust_main_Mixin FS::Record );
3
4 use strict;
5 use vars qw( @ISA $DEBUG $me );
6 use Carp;
7 use List::Util qw( sum min );
8 use Text::CSV_XS;
9 use FS::Record qw( qsearch qsearchs dbh );
10 use FS::cust_pkg;
11 use FS::cust_bill_pkg_detail;
12 use FS::cust_bill_pkg_display;
13 use FS::cust_bill_pkg_discount;
14 use FS::cust_bill_pkg_fee;
15 use FS::cust_bill_pay_pkg;
16 use FS::cust_credit_bill_pkg;
17 use FS::cust_tax_exempt_pkg;
18 use FS::cust_bill_pkg_tax_location;
19 use FS::cust_bill_pkg_tax_rate_location;
20 use FS::cust_tax_adjustment;
21 use FS::cust_bill_pkg_void;
22 use FS::cust_bill_pkg_detail_void;
23 use FS::cust_bill_pkg_display_void;
24 use FS::cust_bill_pkg_discount_void;
25 use FS::cust_bill_pkg_tax_location_void;
26 use FS::cust_bill_pkg_tax_rate_location_void;
27 use FS::cust_tax_exempt_pkg_void;
28 use FS::cust_bill_pkg_fee_void;
29 use FS::reason;
30 use FS::reason_type;
31
32 use FS::Cursor;
33
34 $DEBUG = 0;
35 $me = '[FS::cust_bill_pkg]';
36
37 =head1 NAME
38
39 FS::cust_bill_pkg - Object methods for cust_bill_pkg records
40
41 =head1 SYNOPSIS
42
43   use FS::cust_bill_pkg;
44
45   $record = new FS::cust_bill_pkg \%hash;
46   $record = new FS::cust_bill_pkg { 'column' => 'value' };
47
48   $error = $record->insert;
49
50   $error = $record->check;
51
52 =head1 DESCRIPTION
53
54 An FS::cust_bill_pkg object represents an invoice line item.
55 FS::cust_bill_pkg inherits from FS::Record.  The following fields are
56 currently supported:
57
58 =over 4
59
60 =item billpkgnum
61
62 primary key
63
64 =item invnum
65
66 invoice (see L<FS::cust_bill>)
67
68 =item pkgnum
69
70 package (see L<FS::cust_pkg>) or 0 for the special virtual sales tax package, or -1 for the virtual line item (itemdesc is used for the line)
71
72 =item pkgpart_override
73
74 optional package definition (see L<FS::part_pkg>) override
75
76 =item setup
77
78 setup fee
79
80 =item recur
81
82 recurring fee
83
84 =item sdate
85
86 starting date of recurring fee
87
88 =item edate
89
90 ending date of recurring fee
91
92 =item itemdesc
93
94 Line item description (overrides normal package description)
95
96 =item quantity
97
98 If not set, defaults to 1
99
100 =item unitsetup
101
102 If not set, defaults to setup
103
104 =item unitrecur
105
106 If not set, defaults to recur
107
108 =item hidden
109
110 If set to Y, indicates data should not appear as separate line item on invoice
111
112 =back
113
114 sdate and edate are specified as UNIX timestamps; see L<perlfunc/"time">.  Also
115 see L<Time::Local> and L<Date::Parse> for conversion functions.
116
117 =head1 METHODS
118
119 =over 4
120
121 =item new HASHREF
122
123 Creates a new line item.  To add the line item to the database, see
124 L<"insert">.  Line items are normally created by calling the bill method of a
125 customer object (see L<FS::cust_main>).
126
127 =cut
128
129 sub table { 'cust_bill_pkg'; }
130
131 sub detail_table            { 'cust_bill_pkg_detail'; }
132 sub display_table           { 'cust_bill_pkg_display'; }
133 sub discount_table          { 'cust_bill_pkg_discount'; }
134 #sub tax_location_table      { 'cust_bill_pkg_tax_location'; }
135 #sub tax_rate_location_table { 'cust_bill_pkg_tax_rate_location'; }
136 #sub tax_exempt_pkg_table    { 'cust_tax_exempt_pkg'; }
137
138 =item insert
139
140 Adds this line item to the database.  If there is an error, returns the error,
141 otherwise returns false.
142
143 =cut
144
145 sub insert {
146   my $self = shift;
147
148   local $SIG{HUP} = 'IGNORE';
149   local $SIG{INT} = 'IGNORE';
150   local $SIG{QUIT} = 'IGNORE';
151   local $SIG{TERM} = 'IGNORE';
152   local $SIG{TSTP} = 'IGNORE';
153   local $SIG{PIPE} = 'IGNORE';
154
155   my $oldAutoCommit = $FS::UID::AutoCommit;
156   local $FS::UID::AutoCommit = 0;
157   my $dbh = dbh;
158
159   my $error = $self->SUPER::insert;
160   if ( $error ) {
161     $dbh->rollback if $oldAutoCommit;
162     return $error;
163   }
164
165   if ( $self->get('details') ) {
166     foreach my $detail ( @{$self->get('details')} ) {
167       $detail->billpkgnum($self->billpkgnum);
168       $error = $detail->insert;
169       if ( $error ) {
170         $dbh->rollback if $oldAutoCommit;
171         return "error inserting cust_bill_pkg_detail: $error";
172       }
173     }
174   }
175
176   if ( $self->get('display') ) {
177     foreach my $cust_bill_pkg_display ( @{ $self->get('display') } ) {
178       $cust_bill_pkg_display->billpkgnum($self->billpkgnum);
179       $error = $cust_bill_pkg_display->insert;
180       if ( $error ) {
181         $dbh->rollback if $oldAutoCommit;
182         return "error inserting cust_bill_pkg_display: $error";
183       }
184     }
185   }
186
187   if ( $self->get('discounts') ) {
188     foreach my $cust_bill_pkg_discount ( @{$self->get('discounts')} ) {
189       $cust_bill_pkg_discount->billpkgnum($self->billpkgnum);
190       $error = $cust_bill_pkg_discount->insert;
191       if ( $error ) {
192         $dbh->rollback if $oldAutoCommit;
193         return "error inserting cust_bill_pkg_discount: $error";
194       }
195     }
196   }
197
198   foreach my $cust_tax_exempt_pkg ( @{$self->cust_tax_exempt_pkg} ) {
199     $cust_tax_exempt_pkg->billpkgnum($self->billpkgnum);
200     $error = $cust_tax_exempt_pkg->insert;
201     if ( $error ) {
202       $dbh->rollback if $oldAutoCommit;
203       return "error inserting cust_tax_exempt_pkg: $error";
204     }
205   }
206
207   foreach my $tax_link_table (qw(cust_bill_pkg_tax_location
208                                  cust_bill_pkg_tax_rate_location))
209   {
210     my $tax_location = $self->get($tax_link_table) || [];
211     foreach my $link ( @$tax_location ) {
212       my $pkey = $link->primary_key;
213       next if $link->get($pkey); # don't try to double-insert
214       # This cust_bill_pkg can be linked on either side (i.e. it can be the
215       # tax or the taxed item).  If the other side is already inserted, 
216       # then set billpkgnum to ours, and insert the link.  Otherwise,
217       # set billpkgnum to ours and pass the link off to the cust_bill_pkg
218       # on the other side, to be inserted later.
219
220       my $tax_cust_bill_pkg = $link->get('tax_cust_bill_pkg');
221       if ( $tax_cust_bill_pkg && $tax_cust_bill_pkg->billpkgnum ) {
222         $link->set('billpkgnum', $tax_cust_bill_pkg->billpkgnum);
223         # break circular links when doing this
224         $link->set('tax_cust_bill_pkg', '');
225       }
226       my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg');
227       if ( $taxable_cust_bill_pkg && $taxable_cust_bill_pkg->billpkgnum ) {
228         $link->set('taxable_billpkgnum', $taxable_cust_bill_pkg->billpkgnum);
229         # XXX pkgnum is zero for tax on tax; it might be better to use
230         # the underlying package?
231         $link->set('pkgnum', $taxable_cust_bill_pkg->pkgnum);
232         $link->set('locationnum', $taxable_cust_bill_pkg->tax_locationnum);
233         $link->set('taxable_cust_bill_pkg', '');
234       }
235
236       if ( $link->billpkgnum and $link->taxable_billpkgnum ) {
237         $error = $link->insert;
238         if ( $error ) {
239           $dbh->rollback if $oldAutoCommit;
240           return "error inserting cust_bill_pkg_tax_location: $error";
241         }
242       } else { # handoff
243         my $other; # the as yet uninserted cust_bill_pkg
244         $other = $link->billpkgnum ? $link->get('taxable_cust_bill_pkg')
245                                    : $link->get('tax_cust_bill_pkg');
246         my $link_array = $other->get( $tax_link_table ) || [];
247         push @$link_array, $link;
248         $other->set( $tax_link_table => $link_array);
249       }
250     } #foreach my $link
251   }
252
253   # someday you will be as awesome as cust_bill_pkg_tax_location...
254   # and today is that day
255   #my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location');
256   #if ( $tax_rate_location ) {
257   #  foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) {
258   #    $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum);
259   #    $error = $cust_bill_pkg_tax_rate_location->insert;
260   #    if ( $error ) {
261   #      $dbh->rollback if $oldAutoCommit;
262   #      return "error inserting cust_bill_pkg_tax_rate_location: $error";
263   #    }
264   #  }
265   #}
266
267   my $fee_links = $self->get('cust_bill_pkg_fee');
268   if ( $fee_links ) {
269     foreach my $link ( @$fee_links ) {
270       # very similar to cust_bill_pkg_tax_location, for obvious reasons
271       next if $link->billpkgfeenum; # don't try to double-insert
272
273       my $target = $link->get('cust_bill_pkg'); # the line item of the fee
274       my $base = $link->get('base_cust_bill_pkg'); # line item it was based on
275
276       if ( $target and $target->billpkgnum ) {
277         $link->set('billpkgnum', $target->billpkgnum);
278         # base_invnum => null indicates that the fee is based on its own
279         # invoice
280         $link->set('base_invnum', $target->invnum) unless $link->base_invnum;
281         $link->set('cust_bill_pkg', '');
282       }
283
284       if ( $base and $base->billpkgnum ) {
285         $link->set('base_billpkgnum', $base->billpkgnum);
286         $link->set('base_cust_bill_pkg', '');
287       } elsif ( $base ) {
288         # it's based on a line item that's not yet inserted
289         my $link_array = $base->get('cust_bill_pkg_fee') || [];
290         push @$link_array, $link;
291         $base->set('cust_bill_pkg_fee' => $link_array);
292         next; # don't insert the link yet
293       }
294
295       $error = $link->insert;
296       if ( $error ) {
297         $dbh->rollback if $oldAutoCommit;
298         return "error inserting cust_bill_pkg_fee: $error";
299       }
300     } # foreach my $link
301   }
302
303   if ( my $fee_origin = $self->get('fee_origin') ) {
304     $fee_origin->set('billpkgnum' => $self->billpkgnum);
305     $error = $fee_origin->replace;
306     if ( $error ) {
307       $dbh->rollback if $oldAutoCommit;
308       return "error updating fee origin record: $error";
309     }
310   }
311
312   my $cust_tax_adjustment = $self->get('cust_tax_adjustment');
313   if ( $cust_tax_adjustment ) {
314     $cust_tax_adjustment->billpkgnum($self->billpkgnum);
315     $error = $cust_tax_adjustment->replace;
316     if ( $error ) {
317       $dbh->rollback if $oldAutoCommit;
318       return "error replacing cust_tax_adjustment: $error";
319     }
320   }
321
322   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
323   '';
324
325 }
326
327 =item void [ REASON [ , REPROCESS_CDRS ] ]
328
329 Voids this line item: deletes the line item and adds a record of the voided
330 line item to the FS::cust_bill_pkg_void table (and related tables).
331
332 =cut
333
334 sub void {
335   my $self = shift;
336   my $reason = scalar(@_) ? shift : '';
337   my $reprocess_cdrs = scalar(@_) ? shift : '';
338
339   unless (ref($reason) || !$reason) {
340     $reason = FS::reason->new_or_existing(
341       'class'  => 'I',
342       'type'   => 'Invoice void',
343       'reason' => $reason
344     );
345   }
346
347   local $SIG{HUP} = 'IGNORE';
348   local $SIG{INT} = 'IGNORE';
349   local $SIG{QUIT} = 'IGNORE';
350   local $SIG{TERM} = 'IGNORE';
351   local $SIG{TSTP} = 'IGNORE';
352   local $SIG{PIPE} = 'IGNORE';
353
354   my $oldAutoCommit = $FS::UID::AutoCommit;
355   local $FS::UID::AutoCommit = 0;
356   my $dbh = dbh;
357
358   my $cust_bill_pkg_void = new FS::cust_bill_pkg_void ( {
359     map { $_ => $self->get($_) } $self->fields
360   } );
361   $cust_bill_pkg_void->reasonnum($reason->reasonnum) if $reason;
362   my $error = $cust_bill_pkg_void->insert;
363   if ( $error ) {
364     $dbh->rollback if $oldAutoCommit;
365     return $error;
366   }
367
368   #more efficiently than below, because there could be lots
369   $self->void_cust_bill_pkg_detail($reprocess_cdrs);
370
371   foreach my $table (qw(
372     cust_bill_pkg_display
373     cust_bill_pkg_discount
374     cust_bill_pkg_tax_location
375     cust_bill_pkg_tax_rate_location
376     cust_tax_exempt_pkg
377     cust_bill_pkg_fee
378   )) {
379     foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
380
381       my $vclass = 'FS::'.$table.'_void';
382       my $void = $vclass->new( {
383         map { $_ => $linked->get($_) } $linked->fields
384       });
385       my $error = $void->insert || $linked->delete;
386       if ( $error ) {
387         $dbh->rollback if $oldAutoCommit;
388         return $error;
389       }
390
391     }
392
393   }
394
395   $error = $self->delete;
396   if ( $error ) {
397     $dbh->rollback if $oldAutoCommit;
398     return $error;
399   }
400
401   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
402
403   '';
404
405 }
406
407 sub void_cust_bill_pkg_detail {
408   my( $self, $reprocess_cdrs ) = @_;
409
410   my $from_cust_bill_pkg_detail =
411     'FROM cust_bill_pkg_detail WHERE billpkgnum = ?';
412   my $where_detailnum =
413     "WHERE detailnum IN ( SELECT detailnum $from_cust_bill_pkg_detail )";
414
415   if ( $reprocess_cdrs ) {
416     #well, technically this could have been on other invoices / termination
417     # partners... separate flag?
418     $self->scalar_sql(
419       "DELETE FROM cdr_termination
420          WHERE acctid IN ( SELECT acctid FROM cdr $where_detailnum )
421       ",
422       $self->billpkgnum
423     );
424   }
425
426   my $setstatus = $reprocess_cdrs ? ', freesidestatus = NULL' : '';
427   $self->scalar_sql(
428     "UPDATE cdr SET detailnum = NULL $setstatus $where_detailnum",
429     $self->billpkgnum
430   );
431
432   $self->scalar_sql("INSERT INTO cust_bill_pkg_detail_void
433                        SELECT * $from_cust_bill_pkg_detail",
434                     $self->billpkgnum
435                    );
436
437   $self->scalar_sql("DELETE $from_cust_bill_pkg_detail", $self->billpkgnum);
438
439 }
440
441 =item delete
442
443 Not recommended.
444
445 =cut
446
447 sub delete {
448   my $self = shift;
449
450   local $SIG{HUP} = 'IGNORE';
451   local $SIG{INT} = 'IGNORE';
452   local $SIG{QUIT} = 'IGNORE';
453   local $SIG{TERM} = 'IGNORE';
454   local $SIG{TSTP} = 'IGNORE';
455   local $SIG{PIPE} = 'IGNORE';
456
457   my $oldAutoCommit = $FS::UID::AutoCommit;
458   local $FS::UID::AutoCommit = 0;
459   my $dbh = dbh;
460
461   foreach my $table (qw(
462     cust_bill_pkg_detail
463     cust_bill_pkg_display
464     cust_bill_pkg_discount
465     cust_bill_pkg_tax_location
466     cust_bill_pkg_tax_rate_location
467     cust_tax_exempt_pkg
468     cust_bill_pay_pkg
469     cust_credit_bill_pkg
470     cust_bill_pkg_fee
471   )) {
472
473     foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
474       my $error = $linked->delete;
475       if ( $error ) {
476         $dbh->rollback if $oldAutoCommit;
477         return $error;
478       }
479     }
480
481   }
482
483   foreach my $cust_tax_adjustment (
484     qsearch('cust_tax_adjustment', { billpkgnum=>$self->billpkgnum })
485   ) {
486     $cust_tax_adjustment->billpkgnum(''); #NULL
487     my $error = $cust_tax_adjustment->replace;
488     if ( $error ) {
489       $dbh->rollback if $oldAutoCommit;
490       return $error;
491     }
492   }
493
494   my $error = $self->SUPER::delete(@_);
495   if ( $error ) {
496     $dbh->rollback if $oldAutoCommit;
497     return $error;
498   }
499
500   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
501
502   '';
503
504 }
505
506 #alas, bin/follow-tax-rename
507 #
508 #=item replace OLD_RECORD
509 #
510 #Currently unimplemented.  This would be even more of an accounting nightmare
511 #than deleteing the items.  Just don't do it.
512 #
513 #=cut
514 #
515 #sub replace {
516 #  return "Can't modify cust_bill_pkg records!";
517 #}
518
519 =item check
520
521 Checks all fields to make sure this is a valid line item.  If there is an
522 error, returns the error, otherwise returns false.  Called by the insert
523 method.
524
525 =cut
526
527 sub check {
528   my $self = shift;
529
530   my $error =
531          $self->ut_numbern('billpkgnum')
532       || $self->ut_snumber('pkgnum')
533       || $self->ut_number('invnum')
534       || $self->ut_money('setup')
535       || $self->ut_moneyn('unitsetup')
536       || $self->ut_currencyn('setup_billed_currency')
537       || $self->ut_moneyn('setup_billed_amount')
538       || $self->ut_money('recur')
539       || $self->ut_moneyn('unitrecur')
540       || $self->ut_currencyn('recur_billed_currency')
541       || $self->ut_moneyn('recur_billed_amount')
542       || $self->ut_numbern('sdate')
543       || $self->ut_numbern('edate')
544       || $self->ut_textn('itemdesc')
545       || $self->ut_textn('itemcomment')
546       || $self->ut_enum('hidden', [ '', 'Y' ])
547   ;
548   return $error if $error;
549
550   $self->regularize_details;
551
552   #if ( $self->pkgnum != 0 ) { #allow unchecked pkgnum 0 for tax! (add to part_pkg?)
553   if ( $self->pkgnum > 0 ) { #allow -1 for non-pkg line items and 0 for tax (add to part_pkg?)
554     return "Unknown pkgnum ". $self->pkgnum
555       unless qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
556   }
557
558   return "Unknown invnum"
559     unless qsearchs( 'cust_bill' ,{ 'invnum' => $self->invnum } );
560
561   $self->SUPER::check;
562 }
563
564 =item regularize_details
565
566 Converts the contents of the 'details' pseudo-field to 
567 L<FS::cust_bill_pkg_detail> objects, if they aren't already.
568
569 =cut
570
571 sub regularize_details {
572   my $self = shift;
573   if ( $self->get('details') ) {
574     foreach my $detail ( @{$self->get('details')} ) {
575       if ( ref($detail) ne 'FS::cust_bill_pkg_detail' ) {
576         # then turn it into one
577         my %hash = ();
578         if ( ! ref($detail) ) {
579           $hash{'detail'} = $detail;
580         }
581         elsif ( ref($detail) eq 'HASH' ) {
582           %hash = %$detail;
583         }
584         elsif ( ref($detail) eq 'ARRAY' ) {
585           carp "passing invoice details as arrays is deprecated";
586           #carp "this way sucks, use a hash"; #but more useful/friendly
587           $hash{'format'}      = $detail->[0];
588           $hash{'detail'}      = $detail->[1];
589           $hash{'amount'}      = $detail->[2];
590           $hash{'classnum'}    = $detail->[3];
591           $hash{'phonenum'}    = $detail->[4];
592           $hash{'accountcode'} = $detail->[5];
593           $hash{'startdate'}   = $detail->[6];
594           $hash{'duration'}    = $detail->[7];
595           $hash{'regionname'}  = $detail->[8];
596         }
597         else {
598           die "unknown detail type ". ref($detail);
599         }
600         $detail = new FS::cust_bill_pkg_detail \%hash;
601       }
602       $detail->billpkgnum($self->billpkgnum) if $self->billpkgnum;
603     }
604   }
605   return;
606 }
607
608 =item set_exemptions TAXOBJECT, OPTIONS
609
610 Sets up tax exemptions.  TAXOBJECT is the L<FS::cust_main_county> or 
611 L<FS::tax_rate> record for the tax.
612
613 This will deal with the following cases:
614
615 =over 4
616
617 =item Fully exempt customers (cust_main.tax flag) or customer classes 
618 (cust_class.tax).
619
620 =item Customers exempt from specific named taxes (cust_main_exemption 
621 records).
622
623 =item Taxes that don't apply to setup or recurring fees 
624 (cust_main_county.setuptax and recurtax, tax_rate.setuptax and recurtax).
625
626 =item Packages that are marked as tax-exempt (part_pkg.setuptax,
627 part_pkg.recurtax).
628
629 =item Fees that aren't marked as taxable (part_fee.taxable).
630
631 =back
632
633 It does NOT deal with monthly tax exemptions, which need more context 
634 than this humble little method cares to deal with.
635
636 OPTIONS should include "custnum" => the customer number if this tax line
637 hasn't been inserted (which it probably hasn't).
638
639 Returns a list of exemption objects, which will also be attached to the 
640 line item as the 'cust_tax_exempt_pkg' pseudo-field.  Inserting the line
641 item will insert these records as well.
642
643 =cut
644
645 sub set_exemptions {
646   my $self = shift;
647   my $tax = shift;
648   my %opt = @_;
649
650   my $part_pkg  = $self->part_pkg;
651   my $part_fee  = $self->part_fee;
652
653   my $cust_main;
654   my $custnum = $opt{custnum};
655   $custnum ||= $self->cust_bill->custnum if $self->cust_bill;
656
657   $cust_main = FS::cust_main->by_key( $custnum )
658     or die "set_exemptions can't identify customer (pass custnum option)\n";
659
660   my @new_exemptions;
661   my $taxable_charged = $self->setup + $self->recur;
662   return unless $taxable_charged > 0;
663
664   ### Fully exempt customer ###
665   my $exempt_cust;
666   my $conf = FS::Conf->new;
667   if ( $conf->exists('cust_class-tax_exempt') ) {
668     my $cust_class = $cust_main->cust_class;
669     $exempt_cust = $cust_class->tax if $cust_class;
670   } else {
671     $exempt_cust = $cust_main->tax;
672   }
673
674   ### Exemption from named tax ###
675   my $exempt_cust_taxname;
676   if ( !$exempt_cust and $tax->taxname ) {
677     $exempt_cust_taxname = $cust_main->tax_exemption($tax->taxname);
678   }
679
680   if ( $exempt_cust ) {
681
682     push @new_exemptions, FS::cust_tax_exempt_pkg->new({
683         amount => $taxable_charged,
684         exempt_cust => 'Y',
685       });
686     $taxable_charged = 0;
687
688   } elsif ( $exempt_cust_taxname ) {
689
690     push @new_exemptions, FS::cust_tax_exempt_pkg->new({
691         amount => $taxable_charged,
692         exempt_cust_taxname => 'Y',
693       });
694     $taxable_charged = 0;
695
696   }
697
698   my $exempt_setup = ( ($part_fee and not $part_fee->taxable)
699       or ($part_pkg and $part_pkg->setuptax)
700       or $tax->setuptax );
701
702   if ( $exempt_setup
703       and $self->setup > 0
704       and $taxable_charged > 0 ) {
705
706     push @new_exemptions, FS::cust_tax_exempt_pkg->new({
707         amount => $self->setup,
708         exempt_setup => 'Y'
709       });
710     $taxable_charged -= $self->setup;
711
712   }
713
714   my $exempt_recur = ( ($part_fee and not $part_fee->taxable)
715       or ($part_pkg and $part_pkg->recurtax)
716       or $tax->recurtax );
717
718   if ( $exempt_recur
719       and $self->recur > 0
720       and $taxable_charged > 0 ) {
721
722     push @new_exemptions, FS::cust_tax_exempt_pkg->new({
723         amount => $self->recur,
724         exempt_recur => 'Y'
725       });
726     $taxable_charged -= $self->recur;
727
728   }
729
730   foreach (@new_exemptions) {
731     $_->set('taxnum', $tax->taxnum);
732     $_->set('taxtype', ref($tax));
733   }
734
735   push @{ $self->cust_tax_exempt_pkg }, @new_exemptions;
736   return @new_exemptions;
737
738 }
739
740 =item cust_bill
741
742 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
743
744 =item cust_main
745
746 Returns the customer (L<FS::cust_main> object) for this line item.
747
748 =cut
749
750 sub cust_main {
751   carp "->cust_main called" if $DEBUG;
752   # required for cust_main_Mixin equivalence
753   # and use cust_bill instead of cust_pkg because this might not have a 
754   # cust_pkg
755   my $self = shift;
756   my $cust_bill = $self->cust_bill or return '';
757   $cust_bill->cust_main;
758 }
759
760 =item previous_cust_bill_pkg
761
762 Returns the previous cust_bill_pkg for this package, if any.
763
764 =cut
765
766 sub previous_cust_bill_pkg {
767   my $self = shift;
768   return unless $self->sdate;
769   qsearchs({
770     'table'    => 'cust_bill_pkg',
771     'hashref'  => { 'pkgnum' => $self->pkgnum,
772                     'sdate'  => { op=>'<', value=>$self->sdate },
773                   },
774     'order_by' => 'ORDER BY sdate DESC LIMIT 1',
775   });
776 }
777
778 =item owed_setup
779
780 Returns the amount owed (still outstanding) on this line item's setup fee,
781 which is the amount of the line item minus all payment applications (see
782 L<FS::cust_bill_pay_pkg> and credit applications (see
783 L<FS::cust_credit_bill_pkg>).
784
785 =cut
786
787 sub owed_setup {
788   my $self = shift;
789   $self->owed('setup', @_);
790 }
791
792 =item owed_recur
793
794 Returns the amount owed (still outstanding) on this line item's recurring fee,
795 which is the amount of the line item minus all payment applications (see
796 L<FS::cust_bill_pay_pkg> and credit applications (see
797 L<FS::cust_credit_bill_pkg>).
798
799 =cut
800
801 sub owed_recur {
802   my $self = shift;
803   $self->owed('recur', @_);
804 }
805
806 # modeled after cust_bill::owed...
807 sub owed {
808   my( $self, $field ) = @_;
809   my $balance = $self->$field();
810   $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg($field) );
811   $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
812   $balance = sprintf( '%.2f', $balance );
813   $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
814   $balance;
815 }
816
817 #modeled after owed
818 sub payable {
819   my( $self, $field ) = @_;
820   my $balance = $self->$field();
821   $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
822   $balance = sprintf( '%.2f', $balance );
823   $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
824   $balance;
825 }
826
827 sub cust_bill_pay_pkg {
828   my( $self, $field ) = @_;
829   qsearch( 'cust_bill_pay_pkg', { 'billpkgnum' => $self->billpkgnum,
830                                   'setuprecur' => $field,
831                                 }
832          );
833 }
834
835 sub cust_credit_bill_pkg {
836   my( $self, $field ) = @_;
837   qsearch( 'cust_credit_bill_pkg', { 'billpkgnum' => $self->billpkgnum,
838                                      'setuprecur' => $field,
839                                    }
840          );
841 }
842
843 =item units
844
845 Returns the number of billing units (for tax purposes) represented by this,
846 line item.
847
848 =cut
849
850 sub units {
851   my $self = shift;
852   $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
853 }
854
855 =item _item_discount
856
857 If this item has any discounts, returns a hashref in the format used
858 by L<FS::Template_Mixin/_items_cust_bill_pkg> to describe the discount(s)
859 on an invoice. This will contain the keys 'description', 'amount', 
860 'ext_description' (an arrayref of text lines describing the discounts),
861 and '_is_discount' (a flag).
862
863 The value for 'amount' will be negative, and will be scaled for the package
864 quantity.
865
866 =cut
867
868 sub _item_discount {
869   my $self = shift;
870   my %options = @_;
871
872   my $d; # this will be returned.
873
874   my @pkg_discounts = $self->pkg_discount;
875   if (@pkg_discounts) {
876     # special case: if there are old "discount details" on this line item,
877     # don't show discount line items
878     if ( FS::cust_bill_pkg_detail->count("detail LIKE 'Includes discount%' AND billpkgnum = ?", $self->billpkgnum || 0) > 0 ) {
879       return;
880     } 
881     
882     my @ext;
883     $d = {
884       _is_discount    => 1,
885       description     => $self->mt('Discount'),
886       setup_amount    => 0,
887       recur_amount    => 0,
888       ext_description => \@ext,
889       pkgpart         => $self->pkgpart,
890       feepart         => $self->feepart,
891       # maybe should show quantity/unit discount?
892     };
893     foreach my $pkg_discount (@pkg_discounts) {
894       push @ext, $pkg_discount->description;
895       my $setuprecur = $pkg_discount->cust_pkg_discount->setuprecur;
896       $d->{$setuprecur.'_amount'} -= $pkg_discount->amount;
897     }
898   }
899
900   # show introductory rate as a pseudo-discount
901   if (!$d) { # this will conflict with showing real discounts
902     my $part_pkg = $self->part_pkg;
903     if ( $part_pkg and $part_pkg->option('show_as_discount',1) ) {
904       my $cust_pkg = $self->cust_pkg;
905       my $intro_end = $part_pkg->intro_end($cust_pkg);
906       my $_date = $self->cust_bill->_date;
907       if ( $intro_end > $_date ) {
908         $d = $part_pkg->item_discount($cust_pkg);
909       }
910     }
911   }
912
913   if ( $d ) {
914     $d->{setup_amount} *= $self->quantity || 1; # ??
915     $d->{recur_amount} *= $self->quantity || 1; # ??
916   }
917     
918   $d;
919 }
920
921 =item set_display OPTION => VALUE ...
922
923 A helper method for I<insert>, populates the pseudo-field B<display> with
924 appropriate FS::cust_bill_pkg_display objects.
925
926 Options are passed as a list of name/value pairs.  Options are:
927
928 part_pkg: FS::part_pkg object from this line item's package.
929
930 real_pkgpart: if this line item comes from a bundled package, the pkgpart 
931 of the owning package.  Otherwise the same as the part_pkg's pkgpart above.
932
933 =cut
934
935 sub set_display {
936   my( $self, %opt ) = @_;
937   my $part_pkg = $opt{'part_pkg'};
938   my $cust_pkg = new FS::cust_pkg { pkgpart => $opt{real_pkgpart} };
939
940   my $conf = new FS::Conf;
941
942   # whether to break this down into setup/recur/usage
943   my $separate = $conf->exists('separate_usage');
944
945   my $usage_mandate =            $part_pkg->option('usage_mandate', 'Hush!')
946                     || $cust_pkg->part_pkg->option('usage_mandate', 'Hush!');
947
948   # or use the category from $opt{'part_pkg'} if its not bundled?
949   my $categoryname = $cust_pkg->part_pkg->categoryname;
950
951   # if we don't have to separate setup/recur/usage, or put this in a 
952   # package-specific section, or display a usage summary, then don't 
953   # even create one of these.  The item will just display in the unnamed
954   # section as a single line plus details.
955   return $self->set('display', [])
956     unless $separate || $categoryname || $usage_mandate;
957   
958   my @display = ();
959
960   my %hash = ( 'section' => $categoryname );
961
962   # whether to put usage details in a separate section, and if so, which one
963   my $usage_section =            $part_pkg->option('usage_section', 'Hush!')
964                     || $cust_pkg->part_pkg->option('usage_section', 'Hush!');
965
966   # whether to show a usage summary line (total usage charges, no details)
967   my $summary =            $part_pkg->option('summarize_usage', 'Hush!')
968               || $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
969
970   if ( $separate ) {
971     # create lines for setup and (non-usage) recur, in the main section
972     push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
973     push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
974   } else {
975     # display everything in a single line
976     push @display, new FS::cust_bill_pkg_display
977                      { type => '',
978                        %hash,
979                        # and if usage_mandate is enabled, hide details
980                        # (this only works on multisection invoices...)
981                        ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
982                      };
983   }
984
985   if ($separate && $usage_section && $summary) {
986     # create a line for the usage summary in the main section
987     push @display, new FS::cust_bill_pkg_display { type    => 'U',
988                                                    summary => 'Y',
989                                                    %hash,
990                                                  };
991   }
992
993   if ($usage_mandate || ($usage_section && $summary) ) {
994     $hash{post_total} = 'Y';
995   }
996
997   if ($separate || $usage_mandate) {
998     # show call details for this line item in the usage section.
999     # if usage_mandate is on, this will display below the section subtotal.
1000     # this also happens if usage is in a separate section and there's a 
1001     # summary in the main section, though I'm not sure why.
1002     $hash{section} = $usage_section if $usage_section;
1003     push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
1004   }
1005
1006   $self->set('display', \@display);
1007
1008 }
1009
1010 =item disintegrate
1011
1012 Returns a hash: keys are "setup", "recur" or usage classnum, values are
1013 FS::cust_bill_pkg objects, each with no more than a single class (setup or
1014 recur) of charge.
1015
1016 =cut
1017
1018 sub disintegrate {
1019   my $self = shift;
1020   # XXX this goes away with cust_bill_pkg refactor
1021   # or at least I wish it would, but it turns out to be harder than
1022   # that.
1023
1024   #my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash }; # wha huh?
1025   my %cust_bill_pkg = ();
1026
1027   my $usage_total;
1028   foreach my $classnum ($self->usage_classes) {
1029     my $amount = $self->usage($classnum);
1030     next if $amount == 0; # though if so we shouldn't be here
1031     my $usage_item = FS::cust_bill_pkg->new({
1032         $self->hash,
1033         'setup'     => 0,
1034         'recur'     => $amount,
1035         'taxclass'  => $classnum,
1036         'inherit'   => $self
1037     });
1038     $cust_bill_pkg{$classnum} = $usage_item;
1039     $usage_total += $amount;
1040   }
1041
1042   foreach (qw(setup recur)) {
1043     next if ($self->get($_) == 0);
1044     my $item = FS::cust_bill_pkg->new({
1045         $self->hash,
1046         'setup'     => 0,
1047         'recur'     => 0,
1048         'taxclass'  => $_,
1049         'inherit'   => $self,
1050     });
1051     $item->set($_, $self->get($_));
1052     $cust_bill_pkg{$_} = $item;
1053   }
1054
1055   if ($usage_total) {
1056     $cust_bill_pkg{recur}->set('recur',
1057       sprintf('%.2f', $cust_bill_pkg{recur}->get('recur') - $usage_total)
1058     );
1059   }
1060
1061   %cust_bill_pkg;
1062 }
1063
1064 =item usage CLASSNUM
1065
1066 Returns the amount of the charge associated with usage class CLASSNUM if
1067 CLASSNUM is defined.  Otherwise returns the total charge associated with
1068 usage.
1069   
1070 =cut
1071
1072 sub usage {
1073   my( $self, $classnum ) = @_;
1074   $self->regularize_details;
1075
1076   if ( $self->get('details') ) {
1077
1078     return sum( 0, 
1079       map { $_->amount || 0 }
1080       grep { !defined($classnum) or $classnum eq $_->classnum }
1081       @{ $self->get('details') }
1082     );
1083
1084   } else {
1085
1086     my $sql = 'SELECT SUM(COALESCE(amount,0)) FROM cust_bill_pkg_detail '.
1087               ' WHERE billpkgnum = '. $self->billpkgnum;
1088     if (defined $classnum) {
1089       if ($classnum =~ /^(\d+)$/) {
1090         $sql .= " AND classnum = $1";
1091       } elsif ($classnum eq '') {
1092         $sql .= " AND classnum IS NULL";
1093       }
1094     }
1095
1096     my $sth = dbh->prepare($sql) or die dbh->errstr;
1097     $sth->execute or die $sth->errstr;
1098
1099     return $sth->fetchrow_arrayref->[0] || 0;
1100
1101   }
1102
1103 }
1104
1105 =item usage_classes
1106
1107 Returns a list of usage classnums associated with this invoice line's
1108 details.
1109   
1110 =cut
1111
1112 sub usage_classes {
1113   my( $self ) = @_;
1114   $self->regularize_details;
1115
1116   if ( $self->get('details') ) {
1117
1118     my %seen = ( map { $_->classnum => 1 } @{ $self->get('details') } );
1119     keys %seen;
1120
1121   } else {
1122
1123     map { $_->classnum }
1124         qsearch({ table   => 'cust_bill_pkg_detail',
1125                   hashref => { billpkgnum => $self->billpkgnum },
1126                   select  => 'DISTINCT classnum',
1127                });
1128
1129   }
1130
1131 }
1132
1133 sub cust_tax_exempt_pkg {
1134   my ( $self ) = @_;
1135
1136   my $array = $self->{Hash}->{cust_tax_exempt_pkg} ||= [];
1137 }
1138
1139 =item cust_bill_pkg_tax_Xlocation
1140
1141 Returns the list of associated cust_bill_pkg_tax_location and/or
1142 cust_bill_pkg_tax_rate_location objects
1143
1144 =cut
1145
1146 sub cust_bill_pkg_tax_Xlocation {
1147   my $self = shift;
1148
1149   my %hash = ( 'billpkgnum' => $self->billpkgnum );
1150
1151   (
1152     qsearch ( 'cust_bill_pkg_tax_location', { %hash  } ),
1153     qsearch ( 'cust_bill_pkg_tax_rate_location', { %hash } )
1154   );
1155
1156 }
1157
1158 =item recur_show_zero
1159
1160 Whether to show a zero recurring amount. This is true if the package or its
1161 definition has the recur_show_zero flag, and the recurring fee is actually
1162 zero for this period.
1163
1164 =cut
1165
1166 sub recur_show_zero {
1167   my( $self, $what ) = @_;
1168
1169   return 0 unless $self->get('recur') == 0 && $self->pkgnum;
1170
1171   $self->cust_pkg->_X_show_zero('recur');
1172 }
1173
1174 =item setup_show_zero
1175
1176 Whether to show a zero setup charge. This requires the package or its
1177 definition to have the setup_show_zero flag, but it also returns false if
1178 the package's setup date is before this line item's start date.
1179
1180 =cut
1181
1182 sub setup_show_zero {
1183   my $self = shift;
1184   return 0 unless $self->get('setup') == 0 && $self->pkgnum;
1185   my $cust_pkg = $self->cust_pkg;
1186   return 0 if ( $self->sdate || 0 ) > ( $cust_pkg->setup || 0 );
1187   return $cust_pkg->_X_show_zero('setup');
1188 }
1189
1190 =item credited [ BEFORE, AFTER, OPTIONS ]
1191
1192 Returns the sum of credits applied to this item.  Arguments are the same as
1193 owed_sql/paid_sql/credited_sql.
1194
1195 =cut
1196
1197 sub credited {
1198   my $self = shift;
1199   $self->scalar_sql('SELECT '. $self->credited_sql(@_).' FROM cust_bill_pkg WHERE billpkgnum = ?', $self->billpkgnum);
1200 }
1201
1202 =item tax_locationnum
1203
1204 Returns the L<FS::cust_location> number that this line item is in for tax
1205 purposes.  For package sales, it's the package tax location; for fees, 
1206 it's the customer's default service location.
1207
1208 =cut
1209
1210 sub tax_locationnum {
1211   my $self = shift;
1212   if ( $self->pkgnum ) { # normal sales
1213     return $self->cust_pkg->tax_locationnum;
1214   } elsif ( $self->feepart ) { # fees
1215     my $custnum = $self->fee_origin->custnum;
1216     if ( $custnum ) {
1217       return FS::cust_main->by_key($custnum)->ship_locationnum;
1218     }
1219   } else { # taxes
1220     return '';
1221   }
1222 }
1223
1224 sub tax_location {
1225   my $self = shift;
1226   if ( $self->pkgnum ) { # normal sales
1227     return $self->cust_pkg->tax_location;
1228   } elsif ( $self->feepart ) { # fees
1229     my $fee_origin = $self->fee_origin;
1230     if ( $fee_origin ) {
1231       my $custnum = $fee_origin->custnum;
1232       if ( $custnum ) {
1233         return FS::cust_main->by_key($custnum)->ship_location;
1234       }
1235     }
1236   } else { # taxes
1237     return;
1238   }
1239 }
1240
1241 =back
1242
1243 =head1 CLASS METHODS
1244
1245 =over 4
1246
1247 =item usage_sql
1248
1249 Returns an SQL expression for the total usage charges in details on
1250 an item.
1251
1252 =cut
1253
1254 my $usage_sql =
1255   '(SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0) 
1256     FROM cust_bill_pkg_detail 
1257     WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum)';
1258
1259 sub usage_sql { $usage_sql }
1260
1261 # this makes owed_sql, etc. much more concise
1262 sub charged_sql {
1263   my ($class, $start, $end, %opt) = @_;
1264   my $setuprecur = $opt{setuprecur} || '';
1265   my $charged = 
1266     $setuprecur =~ /^s/ ? 'cust_bill_pkg.setup' :
1267     $setuprecur =~ /^r/ ? 'cust_bill_pkg.recur' :
1268     'cust_bill_pkg.setup + cust_bill_pkg.recur';
1269
1270   if ($opt{no_usage} and $charged =~ /recur/) { 
1271     $charged = "$charged - $usage_sql"
1272   }
1273
1274   $charged;
1275 }
1276
1277
1278 =item owed_sql [ BEFORE, AFTER, OPTIONS ]
1279
1280 Returns an SQL expression for the amount owed.  BEFORE and AFTER specify
1281 a date window.  OPTIONS may include 'no_usage' (excludes usage charges)
1282 and 'setuprecur' (set to "setup" or "recur" to limit to one or the other).
1283
1284 =cut
1285
1286 sub owed_sql {
1287   my $class = shift;
1288   '(' . $class->charged_sql(@_) . 
1289   ' - ' . $class->paid_sql(@_) .
1290   ' - ' . $class->credited_sql(@_) . ')'
1291 }
1292
1293 =item paid_sql [ BEFORE, AFTER, OPTIONS ]
1294
1295 Returns an SQL expression for the sum of payments applied to this item.
1296
1297 =cut
1298
1299 sub paid_sql {
1300   my ($class, $start, $end, %opt) = @_;
1301   my $s = $start ? "AND cust_pay._date <= $start" : '';
1302   my $e = $end   ? "AND cust_pay._date >  $end"   : '';
1303   my $setuprecur = $opt{setuprecur} || '';
1304   $setuprecur = 'setup' if $setuprecur =~ /^s/;
1305   $setuprecur = 'recur' if $setuprecur =~ /^r/;
1306   $setuprecur &&= "AND setuprecur = '$setuprecur'";
1307
1308   my $paid = "( SELECT COALESCE(SUM(cust_bill_pay_pkg.amount),0)
1309      FROM cust_bill_pay_pkg JOIN cust_bill_pay USING (billpaynum)
1310                             JOIN cust_pay      USING (paynum)
1311      WHERE cust_bill_pay_pkg.billpkgnum = cust_bill_pkg.billpkgnum
1312            $s $e $setuprecur )";
1313
1314   if ( $opt{no_usage} ) {
1315     # cap the amount paid at the sum of non-usage charges, 
1316     # minus the amount credited against non-usage charges
1317     "LEAST($paid, ". 
1318       $class->charged_sql($start, $end, %opt) . ' - ' .
1319       $class->credited_sql($start, $end, %opt).')';
1320   }
1321   else {
1322     $paid;
1323   }
1324
1325 }
1326
1327 sub credited_sql {
1328   my ($class, $start, $end, %opt) = @_;
1329   my $s = $start ? "AND cust_credit._date <= $start" : '';
1330   my $e = $end   ? "AND cust_credit._date >  $end"   : '';
1331   my $setuprecur = $opt{setuprecur} || '';
1332   $setuprecur = 'setup' if $setuprecur =~ /^s/;
1333   $setuprecur = 'recur' if $setuprecur =~ /^r/;
1334   $setuprecur &&= "AND setuprecur = '$setuprecur'";
1335
1336   my $credited = "( SELECT COALESCE(SUM(cust_credit_bill_pkg.amount),0)
1337      FROM cust_credit_bill_pkg JOIN cust_credit_bill USING (creditbillnum)
1338                                JOIN cust_credit      USING (crednum)
1339      WHERE cust_credit_bill_pkg.billpkgnum = cust_bill_pkg.billpkgnum
1340            $s $e $setuprecur )";
1341
1342   if ( $opt{no_usage} ) {
1343     # cap the amount credited at the sum of non-usage charges
1344     "LEAST($credited, ". $class->charged_sql($start, $end, %opt).')';
1345   }
1346   else {
1347     $credited;
1348   }
1349
1350 }
1351
1352 sub upgrade_tax_location {
1353   # For taxes that were calculated/invoiced before cust_location refactoring
1354   # (May-June 2012), there are no cust_bill_pkg_tax_location records unless
1355   # they were calculated on a package-location basis.  Create them here, 
1356   # along with any necessary cust_location records and any tax exemption 
1357   # records.
1358
1359   my ($class, %opt) = @_;
1360   # %opt may include 's' and 'e': start and end date ranges
1361   # and 'X': abort on any error, instead of just rolling back changes to 
1362   # that invoice
1363   my $dbh = dbh;
1364   my $oldAutoCommit = $FS::UID::AutoCommit;
1365   local $FS::UID::AutoCommit = 0;
1366
1367   eval {
1368     use FS::h_cust_main;
1369     use FS::h_cust_bill;
1370     use FS::h_part_pkg;
1371     use FS::h_cust_main_exemption;
1372   };
1373
1374   local $FS::cust_location::import = 1;
1375
1376   my $conf = FS::Conf->new; # h_conf?
1377   return if $conf->config('tax_data_vendor'); #don't touch this case
1378   my $use_ship = $conf->exists('tax-ship_address');
1379   my $use_pkgloc = $conf->exists('tax-pkg_address');
1380
1381   my $date_where = '';
1382   if ($opt{s}) {
1383     $date_where .= " AND cust_bill._date >= $opt{s}";
1384   }
1385   if ($opt{e}) {
1386     $date_where .= " AND cust_bill._date < $opt{e}";
1387   }
1388
1389   my $commit_each_invoice = 1 unless $opt{X};
1390
1391   # if an invoice has either of these kinds of objects, then it doesn't
1392   # need to be upgraded...probably
1393   my $sub_has_tax_link = 'SELECT 1 FROM cust_bill_pkg_tax_location'.
1394   ' JOIN cust_bill_pkg USING (billpkgnum)'.
1395   ' WHERE cust_bill_pkg.invnum = cust_bill.invnum';
1396   my $sub_has_exempt = 'SELECT 1 FROM cust_tax_exempt_pkg'.
1397   ' JOIN cust_bill_pkg USING (billpkgnum)'.
1398   ' WHERE cust_bill_pkg.invnum = cust_bill.invnum'.
1399   ' AND exempt_monthly IS NULL';
1400
1401   my %all_tax_names = (
1402     '' => 1,
1403     'Tax' => 1,
1404     map { $_->taxname => 1 }
1405       qsearch('h_cust_main_county', { taxname => { op => '!=', value => '' }})
1406   );
1407
1408   my $search = FS::Cursor->new({
1409       table => 'cust_bill',
1410       hashref => {},
1411       extra_sql => "WHERE NOT EXISTS($sub_has_tax_link) ".
1412                    "AND NOT EXISTS($sub_has_exempt) ".
1413                     $date_where,
1414   });
1415
1416 #print "Processing ".scalar(@invnums)." invoices...\n";
1417
1418   my $committed;
1419   INVOICE:
1420   while (my $cust_bill = $search->fetch) {
1421     my $invnum = $cust_bill->invnum;
1422     $committed = 0;
1423     print STDERR "Invoice #$invnum\n";
1424     my $pre = '';
1425     my %pkgpart_taxclass; # pkgpart => taxclass
1426     my %pkgpart_exempt_setup;
1427     my %pkgpart_exempt_recur;
1428     my $h_cust_bill = qsearchs('h_cust_bill',
1429       { invnum => $invnum,
1430         history_action => 'insert' });
1431     if (!$h_cust_bill) {
1432       warn "no insert record for invoice $invnum; skipped\n";
1433       #$date = $cust_bill->_date as a fallback?
1434       # We're trying to avoid using non-real dates (-d/-y invoice dates)
1435       # when looking up history records in other tables.
1436       next INVOICE;
1437     }
1438     my $custnum = $h_cust_bill->custnum;
1439
1440     # Determine the address corresponding to this tax region.
1441     # It's either the bill or ship address of the customer as of the
1442     # invoice date-of-insertion.  (Not necessarily the invoice date.)
1443     my $date = $h_cust_bill->history_date;
1444     local($FS::Record::qsearch_qualify_columns) = 0;
1445     my $h_cust_main = qsearchs('h_cust_main',
1446         { custnum   => $custnum },
1447         FS::h_cust_main->sql_h_searchs($date)
1448       );
1449     if (!$h_cust_main ) {
1450       warn "no historical address for cust#".$h_cust_bill->custnum."; skipped\n";
1451       next INVOICE;
1452       # fallback to current $cust_main?  sounds dangerous.
1453     }
1454
1455     # This is a historical customer record, so it has a historical address.
1456     # If there's no cust_location matching this custnum and address (there 
1457     # probably isn't), create one.
1458     my %tax_loc; # keys are pkgnums, values are cust_location objects
1459     my $default_tax_loc;
1460     if ( $h_cust_main->bill_locationnum ) {
1461       # the location has already been upgraded
1462       if ($use_ship) {
1463         $default_tax_loc = $h_cust_main->ship_location;
1464       } else {
1465         $default_tax_loc = $h_cust_main->bill_location;
1466       }
1467     } else {
1468       $pre = 'ship_' if $use_ship and length($h_cust_main->get('ship_last'));
1469       my %hash = map { $_ => $h_cust_main->get($pre.$_) }
1470                     FS::cust_main->location_fields;
1471       # not really needed for this, and often result in duplicate locations
1472       delete @hash{qw(censustract censusyear latitude longitude coord_auto)};
1473
1474       $hash{custnum} = $h_cust_main->custnum;
1475       $default_tax_loc = FS::cust_location->new(\%hash);
1476       my $error = $default_tax_loc->find_or_insert || $default_tax_loc->disable_if_unused;
1477       if ( $error ) {
1478         warn "couldn't create historical location record for cust#".
1479         $h_cust_main->custnum.": $error\n";
1480         next INVOICE;
1481       }
1482     }
1483     my $exempt_cust;
1484     $exempt_cust = 1 if $h_cust_main->tax;
1485
1486     # classify line items
1487     my @tax_items;
1488     my %nontax_items; # taxclass => array of cust_bill_pkg
1489     foreach my $item ($h_cust_bill->cust_bill_pkg) {
1490       my $pkgnum = $item->pkgnum;
1491
1492       if ( $pkgnum == 0 ) {
1493
1494         push @tax_items, $item;
1495
1496       } else {
1497         # (pkgparts really shouldn't change, right?)
1498         local($FS::Record::qsearch_qualify_columns) = 0;
1499         my $h_cust_pkg = qsearchs('h_cust_pkg', { pkgnum => $pkgnum },
1500           FS::h_cust_pkg->sql_h_searchs($date)
1501         );
1502         if ( !$h_cust_pkg ) {
1503           warn "no historical package #".$item->pkgpart."; skipped\n";
1504           next INVOICE;
1505         }
1506         my $pkgpart = $h_cust_pkg->pkgpart;
1507
1508         if ( $use_pkgloc and $h_cust_pkg->locationnum ) {
1509           # then this package already had a locationnum assigned, and that's 
1510           # the one to use for tax calculation
1511           $tax_loc{$pkgnum} = FS::cust_location->by_key($h_cust_pkg->locationnum);
1512         } else {
1513           # use the customer's bill or ship loc, which was inserted earlier
1514           $tax_loc{$pkgnum} = $default_tax_loc;
1515         }
1516
1517         if (!exists $pkgpart_taxclass{$pkgpart}) {
1518           local($FS::Record::qsearch_qualify_columns) = 0;
1519           my $h_part_pkg = qsearchs('h_part_pkg', { pkgpart => $pkgpart },
1520             FS::h_part_pkg->sql_h_searchs($date)
1521           );
1522           if ( !$h_part_pkg ) {
1523             warn "no historical package def #$pkgpart; skipped\n";
1524             next INVOICE;
1525           }
1526           $pkgpart_taxclass{$pkgpart} = $h_part_pkg->taxclass || '';
1527           $pkgpart_exempt_setup{$pkgpart} = 1 if $h_part_pkg->setuptax;
1528           $pkgpart_exempt_recur{$pkgpart} = 1 if $h_part_pkg->recurtax;
1529         }
1530         
1531         # mark any exemptions that apply
1532         if ( $pkgpart_exempt_setup{$pkgpart} ) {
1533           $item->set('exempt_setup' => 1);
1534         }
1535
1536         if ( $pkgpart_exempt_recur{$pkgpart} ) {
1537           $item->set('exempt_recur' => 1);
1538         }
1539
1540         my $taxclass = $pkgpart_taxclass{ $pkgpart };
1541
1542         $nontax_items{$taxclass} ||= [];
1543         push @{ $nontax_items{$taxclass} }, $item;
1544       }
1545     }
1546
1547     printf("%d tax items: \$%.2f\n", scalar(@tax_items), map {$_->setup} @tax_items)
1548       if @tax_items;
1549
1550     # Get any per-customer taxname exemptions that were in effect.
1551     my %exempt_cust_taxname;
1552     foreach (keys %all_tax_names) {
1553      local($FS::Record::qsearch_qualify_columns) = 0;
1554       my $h_exemption = qsearchs('h_cust_main_exemption', {
1555           'custnum' => $custnum,
1556           'taxname' => $_,
1557         },
1558         FS::h_cust_main_exemption->sql_h_searchs($date, $date)
1559       );
1560       if ($h_exemption) {
1561         $exempt_cust_taxname{ $_ } = 1;
1562       }
1563     }
1564
1565     # Use a variation on the procedure in 
1566     # FS::cust_main::Billing::_handle_taxes to identify taxes that apply 
1567     # to this bill.
1568     my @loc_keys = qw( district city county state country );
1569     my %taxdef_by_name; # by name, and then by taxclass
1570     my %est_tax; # by name, and then by taxclass
1571     my %taxable_items; # by taxnum, and then an array
1572
1573     foreach my $taxclass (keys %nontax_items) {
1574       foreach my $orig_item (@{ $nontax_items{$taxclass} }) {
1575         my $my_tax_loc = $tax_loc{ $orig_item->pkgnum };
1576         my %myhash = map { $_ => $my_tax_loc->get($pre.$_) } @loc_keys;
1577         my @elim = qw( district city county state );
1578         my @taxdefs; # because there may be several with different taxnames
1579         do {
1580           $myhash{taxclass} = $taxclass;
1581           @taxdefs = qsearch('cust_main_county', \%myhash);
1582           if ( !@taxdefs ) {
1583             $myhash{taxclass} = '';
1584             @taxdefs = qsearch('cust_main_county', \%myhash);
1585           }
1586           $myhash{ shift @elim } = '';
1587         } while scalar(@elim) and !@taxdefs;
1588
1589         foreach my $taxdef (@taxdefs) {
1590           next if $taxdef->tax == 0;
1591           $taxdef_by_name{$taxdef->taxname}{$taxdef->taxclass} = $taxdef;
1592
1593           $taxable_items{$taxdef->taxnum} ||= [];
1594           # clone the item so that taxdef-dependent changes don't
1595           # change it for other taxdefs
1596           my $item = FS::cust_bill_pkg->new({ $orig_item->hash });
1597
1598           # these flags are already set if the part_pkg declares itself exempt
1599           $item->set('exempt_setup' => 1) if $taxdef->setuptax;
1600           $item->set('exempt_recur' => 1) if $taxdef->recurtax;
1601
1602           my @new_exempt;
1603           my $taxable = $item->setup + $item->recur;
1604           # credits
1605           # h_cust_credit_bill_pkg?
1606           # NO.  Because if these exemptions HAD been created at the time of 
1607           # billing, and then a credit applied later, the exemption would 
1608           # have been adjusted by the amount of the credit.  So we adjust
1609           # the taxable amount before creating the exemption.
1610           # But don't deduct the credit from taxable, because the tax was 
1611           # calculated before the credit was applied.
1612           foreach my $f (qw(setup recur)) {
1613             my $credited = FS::Record->scalar_sql(
1614               "SELECT SUM(amount) FROM cust_credit_bill_pkg ".
1615               "WHERE billpkgnum = ? AND setuprecur = ?",
1616               $item->billpkgnum,
1617               $f
1618             );
1619             $item->set($f, $item->get($f) - $credited) if $credited;
1620           }
1621           my $existing_exempt = FS::Record->scalar_sql(
1622             "SELECT SUM(amount) FROM cust_tax_exempt_pkg WHERE ".
1623             "billpkgnum = ? AND taxnum = ?",
1624             $item->billpkgnum, $taxdef->taxnum
1625           ) || 0;
1626           $taxable -= $existing_exempt;
1627
1628           if ( $taxable and $exempt_cust ) {
1629             push @new_exempt, { exempt_cust => 'Y',  amount => $taxable };
1630             $taxable = 0;
1631           }
1632           if ( $taxable and $exempt_cust_taxname{$taxdef->taxname} ){
1633             push @new_exempt, { exempt_cust_taxname => 'Y', amount => $taxable };
1634             $taxable = 0;
1635           }
1636           if ( $taxable and $item->exempt_setup ) {
1637             push @new_exempt, { exempt_setup => 'Y', amount => $item->setup };
1638             $taxable -= $item->setup;
1639           }
1640           if ( $taxable and $item->exempt_recur ) {
1641             push @new_exempt, { exempt_recur => 'Y', amount => $item->recur };
1642             $taxable -= $item->recur;
1643           }
1644
1645           $item->set('taxable' => $taxable);
1646           push @{ $taxable_items{$taxdef->taxnum} }, $item
1647             if $taxable > 0;
1648
1649           # estimate the amount of tax (this is necessary because different
1650           # taxdefs with the same taxname may have different tax rates) 
1651           # and sum that for each taxname/taxclass combination
1652           # (in cents)
1653           $est_tax{$taxdef->taxname} ||= {};
1654           $est_tax{$taxdef->taxname}{$taxdef->taxclass} ||= 0;
1655           $est_tax{$taxdef->taxname}{$taxdef->taxclass} += 
1656             $taxable * $taxdef->tax;
1657
1658           foreach (@new_exempt) {
1659             next if $_->{amount} == 0;
1660             my $cust_tax_exempt_pkg = FS::cust_tax_exempt_pkg->new({
1661                 %$_,
1662                 billpkgnum  => $item->billpkgnum,
1663                 taxnum      => $taxdef->taxnum,
1664               });
1665             my $error = $cust_tax_exempt_pkg->insert;
1666             if ($error) {
1667               my $pkgnum = $item->pkgnum;
1668               warn "error creating tax exemption for inv$invnum pkg$pkgnum:".
1669                 "\n$error\n\n";
1670               next INVOICE;
1671             }
1672           } #foreach @new_exempt
1673         } #foreach $taxdef
1674       } #foreach $item
1675     } #foreach $taxclass
1676
1677     # Now go through the billed taxes and match them up with the line items.
1678     TAX_ITEM: foreach my $tax_item ( @tax_items )
1679     {
1680       my $taxname = $tax_item->itemdesc;
1681       $taxname = '' if $taxname eq 'Tax';
1682
1683       if ( !exists( $taxdef_by_name{$taxname} ) ) {
1684         # then we didn't find any applicable taxes with this name
1685         warn "no definition found for tax item '$taxname', custnum $custnum\n";
1686         # possibly all of these should be "next TAX_ITEM", but whole invoices
1687         # are transaction protected and we can go back and retry them.
1688         next INVOICE;
1689       }
1690       # classname => cust_main_county
1691       my %taxdef_by_class = %{ $taxdef_by_name{$taxname} };
1692
1693       # Divide the tax item among taxclasses, if necessary
1694       # classname => estimated tax amount
1695       my $this_est_tax = $est_tax{$taxname};
1696       if (!defined $this_est_tax) {
1697         warn "no taxable sales found for inv#$invnum, tax item '$taxname'.\n";
1698         next INVOICE;
1699       }
1700       my $est_total = sum(values %$this_est_tax);
1701       if ( $est_total == 0 ) {
1702         # shouldn't happen
1703         warn "estimated tax on invoice #$invnum is zero.\n";
1704         next INVOICE;
1705       }
1706
1707       my $real_tax = $tax_item->setup;
1708       printf ("Distributing \$%.2f tax:\n", $real_tax);
1709       my $cents_remaining = $real_tax * 100; # for rounding error
1710       my @tax_links; # partial CBPTL hashrefs
1711       foreach my $taxclass (keys %taxdef_by_class) {
1712         my $taxdef = $taxdef_by_class{$taxclass};
1713         # these items already have "taxable" set to their charge amount
1714         # after applying any credits or exemptions
1715         my @items = @{ $taxable_items{$taxdef->taxnum} };
1716         my $subtotal = sum(map {$_->get('taxable')} @items);
1717         printf("\t$taxclass: %.2f\n", $this_est_tax->{$taxclass}/$est_total);
1718
1719         foreach my $nontax (@items) {
1720           my $my_tax_loc = $tax_loc{ $nontax->pkgnum };
1721           my $part = int($real_tax
1722                             # class allocation
1723                          * ($this_est_tax->{$taxclass}/$est_total) 
1724                             # item allocation
1725                          * ($nontax->get('taxable'))/$subtotal
1726                             # convert to cents
1727                          * 100
1728                        );
1729           $cents_remaining -= $part;
1730           push @tax_links, {
1731             taxnum      => $taxdef->taxnum,
1732             pkgnum      => $nontax->pkgnum,
1733             locationnum => $my_tax_loc->locationnum,
1734             billpkgnum  => $nontax->billpkgnum,
1735             cents       => $part,
1736           };
1737         } #foreach $nontax
1738       } #foreach $taxclass
1739       # Distribute any leftover tax round-robin style, one cent at a time.
1740       my $i = 0;
1741       my $nlinks = scalar(@tax_links);
1742       if ( $nlinks ) {
1743         # ensure that it really is an integer
1744         $cents_remaining = sprintf('%.0f', $cents_remaining);
1745         while ($cents_remaining > 0) {
1746           $tax_links[$i % $nlinks]->{cents} += 1;
1747           $cents_remaining--;
1748           $i++;
1749         }
1750       } else {
1751         warn "Can't create tax links--no taxable items found.\n";
1752         next INVOICE;
1753       }
1754
1755       # Gather credit/payment applications so that we can link them
1756       # appropriately.
1757       my @unlinked = (
1758         qsearch( 'cust_credit_bill_pkg',
1759           { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1760         ),
1761         qsearch( 'cust_bill_pay_pkg',
1762           { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1763         )
1764       );
1765
1766       # grab the first one
1767       my $this_unlinked = shift @unlinked;
1768       my $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1769
1770       # Create tax links (yay!)
1771       printf("Creating %d tax links.\n",scalar(@tax_links));
1772       foreach (@tax_links) {
1773         my $link = FS::cust_bill_pkg_tax_location->new({
1774             billpkgnum  => $tax_item->billpkgnum,
1775             taxtype     => 'FS::cust_main_county',
1776             locationnum => $_->{locationnum},
1777             taxnum      => $_->{taxnum},
1778             pkgnum      => $_->{pkgnum},
1779             amount      => sprintf('%.2f', $_->{cents} / 100),
1780             taxable_billpkgnum => $_->{billpkgnum},
1781         });
1782         my $error = $link->insert;
1783         if ( $error ) {
1784           warn "Can't create tax link for inv#$invnum: $error\n";
1785           next INVOICE;
1786         }
1787
1788         my $link_cents = $_->{cents};
1789         # update/create subitem links
1790         #
1791         # If $this_unlinked is undef, then we've allocated all of the
1792         # credit/payment applications to the tax item.  If $link_cents is 0,
1793         # then we've applied credits/payments to all of this package fraction,
1794         # so go on to the next.
1795         while ($this_unlinked and $link_cents) {
1796           # apply as much as possible of $link_amount to this credit/payment
1797           # link
1798           my $apply_cents = min($link_cents, $unlinked_cents);
1799           $link_cents -= $apply_cents;
1800           $unlinked_cents -= $apply_cents;
1801           # $link_cents or $unlinked_cents or both are now zero
1802           $this_unlinked->set('amount' => sprintf('%.2f',$apply_cents/100));
1803           $this_unlinked->set('billpkgtaxlocationnum' => $link->billpkgtaxlocationnum);
1804           my $pkey = $this_unlinked->primary_key; #creditbillpkgnum or billpaypkgnum
1805           if ( $this_unlinked->$pkey ) {
1806             # then it's an existing link--replace it
1807             $error = $this_unlinked->replace;
1808           } else {
1809             $this_unlinked->insert;
1810           }
1811           # what do we do with errors at this stage?
1812           if ( $error ) {
1813             warn "Error creating tax application link: $error\n";
1814             next INVOICE; # for lack of a better idea
1815           }
1816           
1817           if ( $unlinked_cents == 0 ) {
1818             # then we've allocated all of this payment/credit application, 
1819             # so grab the next one
1820             $this_unlinked = shift @unlinked;
1821             $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1822           } elsif ( $link_cents == 0 ) {
1823             # then we've covered all of this package tax fraction, so split
1824             # off a new application from this one
1825             $this_unlinked = $this_unlinked->new({
1826                 $this_unlinked->hash,
1827                 $pkey     => '',
1828             });
1829             # $unlinked_cents is still what it is
1830           }
1831
1832         } #while $this_unlinked and $link_cents
1833       } #foreach (@tax_links)
1834     } #foreach $tax_item
1835
1836     $dbh->commit if $commit_each_invoice and $oldAutoCommit;
1837     $committed = 1;
1838
1839   } #foreach $invnum
1840   continue {
1841     if (!$committed) {
1842       $dbh->rollback if $oldAutoCommit;
1843       die "Upgrade halted.\n" unless $commit_each_invoice;
1844     }
1845   }
1846
1847   $dbh->commit if $oldAutoCommit and !$commit_each_invoice;
1848   '';
1849 }
1850
1851 sub _pkg_tax_list {
1852   # Return an array of hashrefs for each cust_bill_pkg_tax_location
1853   # applied to this bill for this cust_bill_pkg.pkgnum.
1854   #
1855   # ! Important Note:
1856   #   In some situations, this list will contain more tax records than the
1857   #   ones directly related to $self->billpkgnum.  The returned list contains
1858   #   all records, for this bill, charged against this billpkgnum's pkgnum.
1859   #
1860   #   One must keep this in mind when using data returned by this method.
1861   #
1862   #   An unaddressed deficiency in the cust_bill_pkg_tax_location model makes
1863   #   this necessary:  When a linked-hidden package generates a tax/fee as a row
1864   #   in cust_bill_pkg_tax_location, there is not enough information to surmise
1865   #   with specificity which billpkgnum row represents the direct parent of the
1866   #   the linked-hidden package's tax row.  The closest we can get to this
1867   #   backwards reassociation is to use the pkgnum.  Therefore, when multiple
1868   #   billpkgnum's appear with the same pkgnum, this method is going to return
1869   #   the tax records for ALL of those billpkgnum's, not just $self->billpkgnum.
1870   #
1871   #   This could be addressed with an update to the model, and to the billing
1872   #   routine that generates rows into cust_bill_pkg_tax_location.  Perhaps a
1873   #   column, link_billpkgnum or parent_billpkgnum, recording the link. I'm not
1874   #   doing that now, because there would be no possible repair of data stored
1875   #   historically prior to such a fix.  I need _pkg_tax_list() to not be
1876   #   broken for already-generated bills.
1877   #
1878   #   Any code you write relying on _pkg_tax_list() MUST be aware of, and
1879   #   account for, the possible return of duplicated tax records returned
1880   #   when method is called on multiple cust_bill_pkg_tax_location rows.
1881   #   Duplicates can be identified by billpkgtaxlocationnum column.
1882
1883   my $self = shift;
1884   return unless $self->pkgnum;
1885
1886   map +{
1887       billpkgtaxlocationnum => $_->billpkgtaxlocationnum,
1888       billpkgnum            => $_->billpkgnum,
1889       taxnum                => $_->taxnum,
1890       amount                => $_->amount,
1891       taxname               => $_->taxname,
1892   },
1893   qsearch({
1894     table  => 'cust_bill_pkg_tax_location',
1895     addl_from => '
1896       LEFT JOIN cust_bill_pkg
1897               ON cust_bill_pkg.billpkgnum
1898          = cust_bill_pkg_tax_location.taxable_billpkgnum
1899     ',
1900     select => join( ', ', (qw|
1901       cust_bill_pkg.billpkgnum
1902       cust_bill_pkg_tax_location.billpkgtaxlocationnum
1903       cust_bill_pkg_tax_location.taxnum
1904       cust_bill_pkg_tax_location.amount
1905     |)),
1906     extra_sql =>
1907       ' WHERE '.
1908       ' cust_bill_pkg.invnum = ' . dbh->quote( $self->invnum ) .
1909       ' AND '.
1910       ' cust_bill_pkg_tax_location.pkgnum = ' . dbh->quote( $self->pkgnum ),
1911   });
1912
1913 }
1914
1915 sub _upgrade_data {
1916   # Create a queue job to run upgrade_tax_location from January 1, 2012 to 
1917   # the present date.
1918   eval {
1919     use FS::queue;
1920     use Date::Parse 'str2time';
1921   };
1922   my $class = shift;
1923   my $upgrade = 'tax_location_2012';
1924   return if FS::upgrade_journal->is_done($upgrade);
1925   my $job = FS::queue->new({
1926       'job' => 'FS::cust_bill_pkg::upgrade_tax_location'
1927   });
1928   # call it kind of like a class method, not that it matters much
1929   $job->insert($class, 's' => str2time('2012-01-01'));
1930   # if there's a customer location upgrade queued also, wait for it to 
1931   # finish
1932   my $location_job = qsearchs('queue', {
1933       job => 'FS::cust_main::Location::process_upgrade_location'
1934     });
1935   if ( $location_job ) {
1936     $job->depend_insert($location_job->jobnum);
1937   }
1938   # Then mark the upgrade as done, so that we don't queue the job twice
1939   # and somehow run two of them concurrently.
1940   FS::upgrade_journal->set_done($upgrade);
1941   # This upgrade now does the job of assigning taxable_billpkgnums to 
1942   # cust_bill_pkg_tax_location, so set that task done also.
1943   FS::upgrade_journal->set_done('tax_location_taxable_billpkgnum');
1944 }
1945
1946 =back
1947
1948 =head1 BUGS
1949
1950 setup and recur shouldn't be separate fields.  There should be one "amount"
1951 field and a flag to tell you if it is a setup/one-time fee or a recurring fee.
1952
1953 A line item with both should really be two separate records (preserving
1954 sdate and edate for setup fees for recurring packages - that information may
1955 be valuable later).  Invoice generation (cust_main::bill), invoice printing
1956 (cust_bill), tax reports (report_tax.cgi) and line item reports 
1957 (cust_bill_pkg.cgi) would need to be updated.
1958
1959 owed_setup and owed_recur could then be repaced by just owed, and
1960 cust_bill::open_cust_bill_pkg and
1961 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
1962
1963 The upgrade procedure is pretty sketchy.
1964
1965 =head1 SEE ALSO
1966
1967 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
1968 from the base documentation.
1969
1970 =cut
1971
1972 1;