bundle bill linked packages into top line total when desired #5724
[freeside.git] / FS / FS / cust_bill_pkg.pm
1 package FS::cust_bill_pkg;
2
3 use strict;
4 use vars qw( @ISA $DEBUG );
5 use FS::Record qw( qsearch qsearchs dbdef dbh );
6 use FS::cust_main_Mixin;
7 use FS::cust_pkg;
8 use FS::part_pkg;
9 use FS::cust_bill;
10 use FS::cust_bill_pkg_detail;
11 use FS::cust_bill_pkg_display;
12 use FS::cust_bill_pay_pkg;
13 use FS::cust_credit_bill_pkg;
14 use FS::cust_tax_exempt_pkg;
15
16 @ISA = qw( FS::cust_main_Mixin FS::Record );
17
18 $DEBUG = 0;
19
20 =head1 NAME
21
22 FS::cust_bill_pkg - Object methods for cust_bill_pkg records
23
24 =head1 SYNOPSIS
25
26   use FS::cust_bill_pkg;
27
28   $record = new FS::cust_bill_pkg \%hash;
29   $record = new FS::cust_bill_pkg { 'column' => 'value' };
30
31   $error = $record->insert;
32
33   $error = $record->check;
34
35 =head1 DESCRIPTION
36
37 An FS::cust_bill_pkg object represents an invoice line item.
38 FS::cust_bill_pkg inherits from FS::Record.  The following fields are currently
39 supported:
40
41 =over 4
42
43 =item billpkgnum - primary key
44
45 =item invnum - invoice (see L<FS::cust_bill>)
46
47 =item pkgnum - 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)
48
49 =item pkgpart_override - optional package definition (see L<FS::part_pkg>) override
50 =item setup - setup fee
51
52 =item recur - recurring fee
53
54 =item sdate - starting date of recurring fee
55
56 =item edate - ending date of recurring fee
57
58 =item itemdesc - Line item description (overrides normal package description)
59
60 =item quantity - If not set, defaults to 1
61
62 =item unitsetup - If not set, defaults to setup
63
64 =item unitrecur - If not set, defaults to recur
65
66 =item hidden - If set to Y, indicates data should not appear as separate line item on invoice
67
68 =back
69
70 sdate and edate are specified as UNIX timestamps; see L<perlfunc/"time">.  Also
71 see L<Time::Local> and L<Date::Parse> for conversion functions.
72
73 =head1 METHODS
74
75 =over 4
76
77 =item new HASHREF
78
79 Creates a new line item.  To add the line item to the database, see
80 L<"insert">.  Line items are normally created by calling the bill method of a
81 customer object (see L<FS::cust_main>).
82
83 =cut
84
85 sub table { 'cust_bill_pkg'; }
86
87 =item insert
88
89 Adds this line item to the database.  If there is an error, returns the error,
90 otherwise returns false.
91
92 =cut
93
94 sub insert {
95   my $self = shift;
96
97   local $SIG{HUP} = 'IGNORE';
98   local $SIG{INT} = 'IGNORE';
99   local $SIG{QUIT} = 'IGNORE';
100   local $SIG{TERM} = 'IGNORE';
101   local $SIG{TSTP} = 'IGNORE';
102   local $SIG{PIPE} = 'IGNORE';
103
104   my $oldAutoCommit = $FS::UID::AutoCommit;
105   local $FS::UID::AutoCommit = 0;
106   my $dbh = dbh;
107
108   my $error = $self->SUPER::insert;
109   if ( $error ) {
110     $dbh->rollback if $oldAutoCommit;
111     return $error;
112   }
113
114   if ( defined dbdef->table('cust_bill_pkg_detail') && $self->get('details') ) {
115     foreach my $detail ( @{$self->get('details')} ) {
116       my $cust_bill_pkg_detail = new FS::cust_bill_pkg_detail {
117         'billpkgnum' => $self->billpkgnum,
118         'format'     => (ref($detail) ? $detail->[0] : '' ),
119         'detail'     => (ref($detail) ? $detail->[1] : $detail ),
120         'amount'     => (ref($detail) ? $detail->[2] : '' ),
121         'classnum'   => (ref($detail) ? $detail->[3] : '' ),
122       };
123       $error = $cust_bill_pkg_detail->insert;
124       if ( $error ) {
125         $dbh->rollback if $oldAutoCommit;
126         return $error;
127       }
128     }
129   }
130
131   if ( defined dbdef->table('cust_bill_pkg_display') && $self->get('display') ){
132     foreach my $cust_bill_pkg_display ( @{ $self->get('display') } ) {
133       $cust_bill_pkg_display->billpkgnum($self->billpkgnum);
134       $error = $cust_bill_pkg_display->insert;
135       if ( $error ) {
136         $dbh->rollback if $oldAutoCommit;
137         return $error;
138       }
139     }
140   }
141
142   if ( $self->_cust_tax_exempt_pkg ) {
143     foreach my $cust_tax_exempt_pkg ( @{$self->_cust_tax_exempt_pkg} ) {
144       $cust_tax_exempt_pkg->billpkgnum($self->billpkgnum);
145       $error = $cust_tax_exempt_pkg->insert;
146       if ( $error ) {
147         $dbh->rollback if $oldAutoCommit;
148         return $error;
149       }
150     }
151   }
152
153   my $tax_location = $self->get('cust_bill_pkg_tax_location');
154   if ( $tax_location ) {
155     foreach my $cust_bill_pkg_tax_location ( @$tax_location ) {
156       $cust_bill_pkg_tax_location->billpkgnum($self->billpkgnum);
157       warn $cust_bill_pkg_tax_location;
158       $error = $cust_bill_pkg_tax_location->insert;
159       warn $error;
160       if ( $error ) {
161         $dbh->rollback if $oldAutoCommit;
162         return $error;
163       }
164     }
165   }
166
167   my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location');
168   if ( $tax_rate_location ) {
169     foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) {
170       $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum);
171       $error = $cust_bill_pkg_tax_rate_location->insert;
172       warn $error;
173       if ( $error ) {
174         $dbh->rollback if $oldAutoCommit;
175         return $error;
176       }
177     }
178   }
179
180   my $cust_tax_adjustment = $self->get('cust_tax_adjustment');
181   if ( $cust_tax_adjustment ) {
182     $cust_tax_adjustment->billpkgnum($self->billpkgnum);
183     $error = $cust_tax_adjustment->replace;
184     warn $error;
185     if ( $error ) {
186       $dbh->rollback if $oldAutoCommit;
187       return $error;
188     }
189   }
190
191   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
192   '';
193
194 }
195
196 =item delete
197
198 Currently unimplemented.  I don't remove line items because there would then be
199 no record the items ever existed (which is bad, no?)
200
201 =cut
202
203 sub delete {
204   return "Can't delete cust_bill_pkg records!";
205 }
206
207 #alas, bin/follow-tax-rename
208 #
209 #=item replace OLD_RECORD
210 #
211 #Currently unimplemented.  This would be even more of an accounting nightmare
212 #than deleteing the items.  Just don't do it.
213 #
214 #=cut
215 #
216 #sub replace {
217 #  return "Can't modify cust_bill_pkg records!";
218 #}
219
220 =item check
221
222 Checks all fields to make sure this is a valid line item.  If there is an
223 error, returns the error, otherwise returns false.  Called by the insert
224 method.
225
226 =cut
227
228 sub check {
229   my $self = shift;
230
231   my $error =
232          $self->ut_numbern('billpkgnum')
233       || $self->ut_snumber('pkgnum')
234       || $self->ut_number('invnum')
235       || $self->ut_money('setup')
236       || $self->ut_money('recur')
237       || $self->ut_numbern('sdate')
238       || $self->ut_numbern('edate')
239       || $self->ut_textn('itemdesc')
240       || $self->ut_textn('itemcomment')
241       || $self->ut_enum('hidden', [ '', 'Y' ])
242   ;
243   return $error if $error;
244
245   #if ( $self->pkgnum != 0 ) { #allow unchecked pkgnum 0 for tax! (add to part_pkg?)
246   if ( $self->pkgnum > 0 ) { #allow -1 for non-pkg line items and 0 for tax (add to part_pkg?)
247     return "Unknown pkgnum ". $self->pkgnum
248       unless qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
249   }
250
251   return "Unknown invnum"
252     unless qsearchs( 'cust_bill' ,{ 'invnum' => $self->invnum } );
253
254   $self->SUPER::check;
255 }
256
257 =item cust_pkg
258
259 Returns the package (see L<FS::cust_pkg>) for this invoice line item.
260
261 =cut
262
263 sub cust_pkg {
264   my $self = shift;
265   qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
266 }
267
268 =item part_pkg
269
270 Returns the package definition for this invoice line item.
271
272 =cut
273
274 sub part_pkg {
275   my $self = shift;
276   if ( $self->pkgpart_override ) {
277     qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart_override } );
278   } else {
279     $self->cust_pkg->part_pkg;
280   }
281 }
282
283 =item cust_bill
284
285 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
286
287 =cut
288
289 sub cust_bill {
290   my $self = shift;
291   qsearchs( 'cust_bill', { 'invnum' => $self->invnum } );
292 }
293
294 =item previous_cust_bill_pkg
295
296 Returns the previous cust_bill_pkg for this package, if any.
297
298 =cut
299
300 sub previous_cust_bill_pkg {
301   my $self = shift;
302   qsearchs({
303     'table'    => 'cust_bill_pkg',
304     'hashref'  => { 'pkgnum' => $self->pkgnum,
305                     'sdate'  => { op=>'<', value=>$self->sdate },
306                   },
307     'order_by' => 'ORDER BY sdate DESC LIMIT 1',
308   });
309 }
310
311 =item details [ OPTION => VALUE ... ]
312
313 Returns an array of detail information for the invoice line item.
314
315 Currently available options are: I<format> I<escape_function>
316
317 If I<format> is set to html or latex then the array members are improved
318 for tabular appearance in those environments if possible.
319
320 If I<escape_function> is set then the array members are processed by this
321 function before being returned.
322
323 =cut
324
325 sub details {
326   my ( $self, %opt ) = @_;
327   my $format = $opt{format} || '';
328   my $escape_function = $opt{escape_function} || sub { shift };
329   return () unless defined dbdef->table('cust_bill_pkg_detail');
330
331   eval "use Text::CSV_XS;";
332   die $@ if $@;
333   my $csv = new Text::CSV_XS;
334
335   my $format_sub = sub { my $detail = shift;
336                          $csv->parse($detail) or return "can't parse $detail";
337                          join(' - ', map { &$escape_function($_) }
338                                      $csv->fields
339                              );
340                        };
341
342   $format_sub = sub { my $detail = shift;
343                       $csv->parse($detail) or return "can't parse $detail";
344                       join('</TD><TD>', map { &$escape_function($_) }
345                                         $csv->fields
346                           );
347                     }
348     if $format eq 'html';
349
350   $format_sub = sub { my $detail = shift;
351                       $csv->parse($detail) or return "can't parse $detail";
352                       #join(' & ', map { '\small{'. &$escape_function($_). '}' }
353                       #            $csv->fields );
354                       my $result = '';
355                       my $column = 1;
356                       foreach ($csv->fields) {
357                         $result .= ' & ' if $column > 1;
358                         if ($column > 6) {                     # KLUDGE ALERT!
359                           $result .= '\multicolumn{1}{l}{\scriptsize{'.
360                                      &$escape_function($_). '}}';
361                         }else{
362                           $result .= '\scriptsize{'.  &$escape_function($_). '}';
363                         }
364                         $column++;
365                       }
366                       $result;
367                     }
368     if $format eq 'latex';
369
370   $format_sub = $opt{format_function} if $opt{format_function};
371
372   map { ( $_->format eq 'C'
373           ? &{$format_sub}( $_->detail )
374           : &{$escape_function}( $_->detail )
375         )
376       }
377     qsearch ({ 'table'    => 'cust_bill_pkg_detail',
378                'hashref'  => { 'billpkgnum' => $self->billpkgnum },
379                'order_by' => 'ORDER BY detailnum',
380             });
381     #qsearch ( 'cust_bill_pkg_detail', { 'lineitemnum' => $self->lineitemnum });
382 }
383
384 =item desc
385
386 Returns a description for this line item.  For typical line items, this is the
387 I<pkg> field of the corresponding B<FS::part_pkg> object (see L<FS::part_pkg>).
388 For one-shot line items and named taxes, it is the I<itemdesc> field of this
389 line item, and for generic taxes, simply returns "Tax".
390
391 =cut
392
393 sub desc {
394   my $self = shift;
395
396   if ( $self->pkgnum > 0 ) {
397     $self->itemdesc || $self->part_pkg->pkg;
398   } else {
399     my $desc = $self->itemdesc || 'Tax';
400     $desc .= ' '. $self->itemcomment if $self->itemcomment =~ /\S/;
401     $desc;
402   }
403 }
404
405 =item owed_setup
406
407 Returns the amount owed (still outstanding) on this line item's setup fee,
408 which is the amount of the line item minus all payment applications (see
409 L<FS::cust_bill_pay_pkg> and credit applications (see
410 L<FS::cust_credit_bill_pkg>).
411
412 =cut
413
414 sub owed_setup {
415   my $self = shift;
416   $self->owed('setup', @_);
417 }
418
419 =item owed_recur
420
421 Returns the amount owed (still outstanding) on this line item's recurring fee,
422 which is the amount of the line item minus all payment applications (see
423 L<FS::cust_bill_pay_pkg> and credit applications (see
424 L<FS::cust_credit_bill_pkg>).
425
426 =cut
427
428 sub owed_recur {
429   my $self = shift;
430   $self->owed('recur', @_);
431 }
432
433 # modeled after cust_bill::owed...
434 sub owed {
435   my( $self, $field ) = @_;
436   my $balance = $self->$field();
437   $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg($field) );
438   $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
439   $balance = sprintf( '%.2f', $balance );
440   $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
441   $balance;
442 }
443
444 sub cust_bill_pay_pkg {
445   my( $self, $field ) = @_;
446   qsearch( 'cust_bill_pay_pkg', { 'billpkgnum' => $self->billpkgnum,
447                                   'setuprecur' => $field,
448                                 }
449          );
450 }
451
452 sub cust_credit_bill_pkg {
453   my( $self, $field ) = @_;
454   qsearch( 'cust_credit_bill_pkg', { 'billpkgnum' => $self->billpkgnum,
455                                      'setuprecur' => $field,
456                                    }
457          );
458 }
459
460 =item units
461
462 Returns the number of billing units (for tax purposes) represented by this,
463 line item.
464
465 =cut
466
467 sub units {
468   my $self = shift;
469   $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
470 }
471
472 =item quantity
473
474 =cut
475
476 sub quantity {
477   my( $self, $value ) = @_;
478   if ( defined($value) ) {
479     $self->setfield('quantity', $value);
480   }
481   $self->getfield('quantity') || 1;
482 }
483
484 =item unitsetup
485
486 =cut
487
488 sub unitsetup {
489   my( $self, $value ) = @_;
490   if ( defined($value) ) {
491     $self->setfield('unitsetup', $value);
492   }
493   $self->getfield('unitsetup') eq ''
494     ? $self->getfield('setup')
495     : $self->getfield('unitsetup');
496 }
497
498 =item unitrecur
499
500 =cut
501
502 sub unitrecur {
503   my( $self, $value ) = @_;
504   if ( defined($value) ) {
505     $self->setfield('unitrecur', $value);
506   }
507   $self->getfield('unitrecur') eq ''
508     ? $self->getfield('recur')
509     : $self->getfield('unitrecur');
510 }
511
512 =item disintegrate
513
514 Returns a list of cust_bill_pkg objects each with no more than a single class
515 (including setup or recur) of charge.
516
517 =cut
518
519 sub disintegrate {
520   my $self = shift;
521   # XXX this goes away with cust_bill_pkg refactor
522
523   my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash };
524   my %cust_bill_pkg = ();
525
526   $cust_bill_pkg{setup} = $cust_bill_pkg if $cust_bill_pkg->setup;
527   $cust_bill_pkg{recur} = $cust_bill_pkg if $cust_bill_pkg->recur;
528
529
530   #split setup and recur
531   if ($cust_bill_pkg->setup && $cust_bill_pkg->recur) {
532     my $cust_bill_pkg_recur = new FS::cust_bill_pkg { $cust_bill_pkg->hash };
533     $cust_bill_pkg->set('details', []);
534     $cust_bill_pkg->recur(0);
535     $cust_bill_pkg->unitrecur(0);
536     $cust_bill_pkg->type('');
537     $cust_bill_pkg_recur->setup(0);
538     $cust_bill_pkg_recur->unitsetup(0);
539     $cust_bill_pkg{recur} = $cust_bill_pkg_recur;
540
541   }
542
543   #split usage from recur
544   my $usage = sprintf( "%.2f", $cust_bill_pkg{recur}->usage );
545   warn "usage is $usage\n" if $DEBUG;
546   if ($usage) {
547     my $cust_bill_pkg_usage =
548         new FS::cust_bill_pkg { $cust_bill_pkg{recur}->hash };
549     $cust_bill_pkg_usage->recur( $usage );
550     $cust_bill_pkg_usage->type( 'U' );
551     my $recur = sprintf( "%.2f", $cust_bill_pkg{recur}->recur - $usage );
552     $cust_bill_pkg{recur}->recur( $recur );
553     $cust_bill_pkg{recur}->type( '' );
554     $cust_bill_pkg{recur}->set('details', []);
555     $cust_bill_pkg{''} = $cust_bill_pkg_usage;
556   }
557
558   #subdivide usage by usage_class
559   if (exists($cust_bill_pkg{''})) {
560     foreach my $class (grep { $_ } $self->usage_classes) {
561       my $usage = sprintf( "%.2f", $cust_bill_pkg{''}->usage($class) );
562       my $cust_bill_pkg_usage =
563           new FS::cust_bill_pkg { $cust_bill_pkg{''}->hash };
564       $cust_bill_pkg_usage->recur( $usage );
565       $cust_bill_pkg_usage->set('details', []);
566       my $classless = sprintf( "%.2f", $cust_bill_pkg{''}->recur - $usage );
567       $cust_bill_pkg{''}->recur( $classless );
568       $cust_bill_pkg{$class} = $cust_bill_pkg_usage;
569     }
570     delete $cust_bill_pkg{''} unless $cust_bill_pkg{''}->recur;
571   }
572
573 #  # sort setup,recur,'', and the rest numeric && return
574 #  my @result = map { $cust_bill_pkg{$_} }
575 #               sort { my $ad = ($a=~/^\d+$/); my $bd = ($b=~/^\d+$/);
576 #                      ( $ad cmp $bd ) || ( $ad ? $a<=>$b : $b cmp $a )
577 #                    }
578 #               keys %cust_bill_pkg;
579 #
580 #  return (@result);
581
582    %cust_bill_pkg;
583 }
584
585 =item usage CLASSNUM
586
587 Returns the amount of the charge associated with usage class CLASSNUM if
588 CLASSNUM is defined.  Otherwise returns the total charge associated with
589 usage.
590   
591 =cut
592
593 sub usage {
594   my( $self, $classnum ) = @_;
595   my $sum = 0;
596   my @values = ();
597
598   if ( $self->get('details') ) {
599
600     @values = 
601       map { $_->[2] }
602       grep { ref($_) && ( defined($classnum) ? $_->[3] eq $classnum : 1 ) }
603       @{ $self->get('details') };
604
605   }else{
606
607     my $hashref = { 'billpkgnum' => $self->billpkgnum };
608     $hashref->{ 'classnum' } = $classnum if defined($classnum);
609     @values = map { $_->amount } qsearch('cust_bill_pkg_detail', $hashref);
610
611   }
612
613   foreach ( @values ) {
614     $sum += $_ if $_;
615   }
616   $sum;
617 }
618
619 =item usage_classes
620
621 Returns a list of usage classnums associated with this invoice line's
622 details.
623   
624 =cut
625
626 sub usage_classes {
627   my( $self ) = @_;
628
629   if ( $self->get('details') ) {
630
631     my %seen = ();
632     foreach my $detail ( grep { ref($_) } @{$self->get('details')} ) {
633       $seen{ $detail->[3] } = 1;
634     }
635     keys %seen;
636
637   }else{
638
639     map { $_->classnum }
640         qsearch({ table   => 'cust_bill_pkg_detail',
641                   hashref => { billpkgnum => $self->billpkgnum },
642                   select  => 'DISTINCT classnum',
643                });
644
645   }
646
647 }
648
649 =item cust_bill_pkg_display [ type => TYPE ]
650
651 Returns an array of display information for the invoice line item optionally
652 limited to 'TYPE'.
653
654 =cut
655
656 sub cust_bill_pkg_display {
657   my ( $self, %opt ) = @_;
658
659   my $default =
660     new FS::cust_bill_pkg_display { billpkgnum =>$self->billpkgnum };
661
662   return ( $default ) unless defined dbdef->table('cust_bill_pkg_display');#hmmm
663
664   my $type = $opt{type} if exists $opt{type};
665   my @result;
666
667   if ( scalar( $self->get('display') ) ) {
668     @result = grep { defined($type) ? ($type eq $_->type) : 1 }
669               @{ $self->get('display') };
670   }else{
671     my $hashref = { 'billpkgnum' => $self->billpkgnum };
672     $hashref->{type} = $type if defined($type);
673     
674     @result = qsearch ({ 'table'    => 'cust_bill_pkg_display',
675                          'hashref'  => { 'billpkgnum' => $self->billpkgnum },
676                          'order_by' => 'ORDER BY billpkgdisplaynum',
677                       });
678   }
679
680   push @result, $default unless ( scalar(@result) || $type );
681
682   @result;
683
684 }
685
686 # reserving this name for my friends FS::{tax_rate|cust_main_county}::taxline
687 # and FS::cust_main::bill
688
689 sub _cust_tax_exempt_pkg {
690   my ( $self ) = @_;
691
692   $self->{Hash}->{_cust_tax_exempt_pkg} or
693   $self->{Hash}->{_cust_tax_exempt_pkg} = [];
694
695 }
696
697
698 =back
699
700 =head1 BUGS
701
702 setup and recur shouldn't be separate fields.  There should be one "amount"
703 field and a flag to tell you if it is a setup/one-time fee or a recurring fee.
704
705 A line item with both should really be two separate records (preserving
706 sdate and edate for setup fees for recurring packages - that information may
707 be valuable later).  Invoice generation (cust_main::bill), invoice printing
708 (cust_bill), tax reports (report_tax.cgi) and line item reports 
709 (cust_bill_pkg.cgi) would need to be updated.
710
711 owed_setup and owed_recur could then be repaced by just owed, and
712 cust_bill::open_cust_bill_pkg and
713 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
714
715 =head1 SEE ALSO
716
717 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
718 from the base documentation.
719
720 =cut
721
722 1;
723