don't bomb when the line item has no start date
[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   return unless $self->sdate;
303   qsearchs({
304     'table'    => 'cust_bill_pkg',
305     'hashref'  => { 'pkgnum' => $self->pkgnum,
306                     'sdate'  => { op=>'<', value=>$self->sdate },
307                   },
308     'order_by' => 'ORDER BY sdate DESC LIMIT 1',
309   });
310 }
311
312 =item details [ OPTION => VALUE ... ]
313
314 Returns an array of detail information for the invoice line item.
315
316 Currently available options are: I<format> I<escape_function>
317
318 If I<format> is set to html or latex then the array members are improved
319 for tabular appearance in those environments if possible.
320
321 If I<escape_function> is set then the array members are processed by this
322 function before being returned.
323
324 =cut
325
326 sub details {
327   my ( $self, %opt ) = @_;
328   my $format = $opt{format} || '';
329   my $escape_function = $opt{escape_function} || sub { shift };
330   return () unless defined dbdef->table('cust_bill_pkg_detail');
331
332   eval "use Text::CSV_XS;";
333   die $@ if $@;
334   my $csv = new Text::CSV_XS;
335
336   my $format_sub = sub { my $detail = shift;
337                          $csv->parse($detail) or return "can't parse $detail";
338                          join(' - ', map { &$escape_function($_) }
339                                      $csv->fields
340                              );
341                        };
342
343   $format_sub = sub { my $detail = shift;
344                       $csv->parse($detail) or return "can't parse $detail";
345                       join('</TD><TD>', map { &$escape_function($_) }
346                                         $csv->fields
347                           );
348                     }
349     if $format eq 'html';
350
351   $format_sub = sub { my $detail = shift;
352                       $csv->parse($detail) or return "can't parse $detail";
353                       #join(' & ', map { '\small{'. &$escape_function($_). '}' }
354                       #            $csv->fields );
355                       my $result = '';
356                       my $column = 1;
357                       foreach ($csv->fields) {
358                         $result .= ' & ' if $column > 1;
359                         if ($column > 6) {                     # KLUDGE ALERT!
360                           $result .= '\multicolumn{1}{l}{\scriptsize{'.
361                                      &$escape_function($_). '}}';
362                         }else{
363                           $result .= '\scriptsize{'.  &$escape_function($_). '}';
364                         }
365                         $column++;
366                       }
367                       $result;
368                     }
369     if $format eq 'latex';
370
371   $format_sub = $opt{format_function} if $opt{format_function};
372
373   map { ( $_->format eq 'C'
374           ? &{$format_sub}( $_->detail )
375           : &{$escape_function}( $_->detail )
376         )
377       }
378     qsearch ({ 'table'    => 'cust_bill_pkg_detail',
379                'hashref'  => { 'billpkgnum' => $self->billpkgnum },
380                'order_by' => 'ORDER BY detailnum',
381             });
382     #qsearch ( 'cust_bill_pkg_detail', { 'lineitemnum' => $self->lineitemnum });
383 }
384
385 =item desc
386
387 Returns a description for this line item.  For typical line items, this is the
388 I<pkg> field of the corresponding B<FS::part_pkg> object (see L<FS::part_pkg>).
389 For one-shot line items and named taxes, it is the I<itemdesc> field of this
390 line item, and for generic taxes, simply returns "Tax".
391
392 =cut
393
394 sub desc {
395   my $self = shift;
396
397   if ( $self->pkgnum > 0 ) {
398     $self->itemdesc || $self->part_pkg->pkg;
399   } else {
400     my $desc = $self->itemdesc || 'Tax';
401     $desc .= ' '. $self->itemcomment if $self->itemcomment =~ /\S/;
402     $desc;
403   }
404 }
405
406 =item owed_setup
407
408 Returns the amount owed (still outstanding) on this line item's setup fee,
409 which is the amount of the line item minus all payment applications (see
410 L<FS::cust_bill_pay_pkg> and credit applications (see
411 L<FS::cust_credit_bill_pkg>).
412
413 =cut
414
415 sub owed_setup {
416   my $self = shift;
417   $self->owed('setup', @_);
418 }
419
420 =item owed_recur
421
422 Returns the amount owed (still outstanding) on this line item's recurring fee,
423 which is the amount of the line item minus all payment applications (see
424 L<FS::cust_bill_pay_pkg> and credit applications (see
425 L<FS::cust_credit_bill_pkg>).
426
427 =cut
428
429 sub owed_recur {
430   my $self = shift;
431   $self->owed('recur', @_);
432 }
433
434 # modeled after cust_bill::owed...
435 sub owed {
436   my( $self, $field ) = @_;
437   my $balance = $self->$field();
438   $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg($field) );
439   $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
440   $balance = sprintf( '%.2f', $balance );
441   $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
442   $balance;
443 }
444
445 sub cust_bill_pay_pkg {
446   my( $self, $field ) = @_;
447   qsearch( 'cust_bill_pay_pkg', { 'billpkgnum' => $self->billpkgnum,
448                                   'setuprecur' => $field,
449                                 }
450          );
451 }
452
453 sub cust_credit_bill_pkg {
454   my( $self, $field ) = @_;
455   qsearch( 'cust_credit_bill_pkg', { 'billpkgnum' => $self->billpkgnum,
456                                      'setuprecur' => $field,
457                                    }
458          );
459 }
460
461 =item units
462
463 Returns the number of billing units (for tax purposes) represented by this,
464 line item.
465
466 =cut
467
468 sub units {
469   my $self = shift;
470   $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
471 }
472
473 =item quantity
474
475 =cut
476
477 sub quantity {
478   my( $self, $value ) = @_;
479   if ( defined($value) ) {
480     $self->setfield('quantity', $value);
481   }
482   $self->getfield('quantity') || 1;
483 }
484
485 =item unitsetup
486
487 =cut
488
489 sub unitsetup {
490   my( $self, $value ) = @_;
491   if ( defined($value) ) {
492     $self->setfield('unitsetup', $value);
493   }
494   $self->getfield('unitsetup') eq ''
495     ? $self->getfield('setup')
496     : $self->getfield('unitsetup');
497 }
498
499 =item unitrecur
500
501 =cut
502
503 sub unitrecur {
504   my( $self, $value ) = @_;
505   if ( defined($value) ) {
506     $self->setfield('unitrecur', $value);
507   }
508   $self->getfield('unitrecur') eq ''
509     ? $self->getfield('recur')
510     : $self->getfield('unitrecur');
511 }
512
513 =item disintegrate
514
515 Returns a list of cust_bill_pkg objects each with no more than a single class
516 (including setup or recur) of charge.
517
518 =cut
519
520 sub disintegrate {
521   my $self = shift;
522   # XXX this goes away with cust_bill_pkg refactor
523
524   my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash };
525   my %cust_bill_pkg = ();
526
527   $cust_bill_pkg{setup} = $cust_bill_pkg if $cust_bill_pkg->setup;
528   $cust_bill_pkg{recur} = $cust_bill_pkg if $cust_bill_pkg->recur;
529
530
531   #split setup and recur
532   if ($cust_bill_pkg->setup && $cust_bill_pkg->recur) {
533     my $cust_bill_pkg_recur = new FS::cust_bill_pkg { $cust_bill_pkg->hash };
534     $cust_bill_pkg->set('details', []);
535     $cust_bill_pkg->recur(0);
536     $cust_bill_pkg->unitrecur(0);
537     $cust_bill_pkg->type('');
538     $cust_bill_pkg_recur->setup(0);
539     $cust_bill_pkg_recur->unitsetup(0);
540     $cust_bill_pkg{recur} = $cust_bill_pkg_recur;
541
542   }
543
544   #split usage from recur
545   my $usage = sprintf( "%.2f", $cust_bill_pkg{recur}->usage );
546   warn "usage is $usage\n" if $DEBUG;
547   if ($usage) {
548     my $cust_bill_pkg_usage =
549         new FS::cust_bill_pkg { $cust_bill_pkg{recur}->hash };
550     $cust_bill_pkg_usage->recur( $usage );
551     $cust_bill_pkg_usage->type( 'U' );
552     my $recur = sprintf( "%.2f", $cust_bill_pkg{recur}->recur - $usage );
553     $cust_bill_pkg{recur}->recur( $recur );
554     $cust_bill_pkg{recur}->type( '' );
555     $cust_bill_pkg{recur}->set('details', []);
556     $cust_bill_pkg{''} = $cust_bill_pkg_usage;
557   }
558
559   #subdivide usage by usage_class
560   if (exists($cust_bill_pkg{''})) {
561     foreach my $class (grep { $_ } $self->usage_classes) {
562       my $usage = sprintf( "%.2f", $cust_bill_pkg{''}->usage($class) );
563       my $cust_bill_pkg_usage =
564           new FS::cust_bill_pkg { $cust_bill_pkg{''}->hash };
565       $cust_bill_pkg_usage->recur( $usage );
566       $cust_bill_pkg_usage->set('details', []);
567       my $classless = sprintf( "%.2f", $cust_bill_pkg{''}->recur - $usage );
568       $cust_bill_pkg{''}->recur( $classless );
569       $cust_bill_pkg{$class} = $cust_bill_pkg_usage;
570     }
571     delete $cust_bill_pkg{''} unless $cust_bill_pkg{''}->recur;
572   }
573
574 #  # sort setup,recur,'', and the rest numeric && return
575 #  my @result = map { $cust_bill_pkg{$_} }
576 #               sort { my $ad = ($a=~/^\d+$/); my $bd = ($b=~/^\d+$/);
577 #                      ( $ad cmp $bd ) || ( $ad ? $a<=>$b : $b cmp $a )
578 #                    }
579 #               keys %cust_bill_pkg;
580 #
581 #  return (@result);
582
583    %cust_bill_pkg;
584 }
585
586 =item usage CLASSNUM
587
588 Returns the amount of the charge associated with usage class CLASSNUM if
589 CLASSNUM is defined.  Otherwise returns the total charge associated with
590 usage.
591   
592 =cut
593
594 sub usage {
595   my( $self, $classnum ) = @_;
596   my $sum = 0;
597   my @values = ();
598
599   if ( $self->get('details') ) {
600
601     @values = 
602       map { $_->[2] }
603       grep { ref($_) && ( defined($classnum) ? $_->[3] eq $classnum : 1 ) }
604       @{ $self->get('details') };
605
606   }else{
607
608     my $hashref = { 'billpkgnum' => $self->billpkgnum };
609     $hashref->{ 'classnum' } = $classnum if defined($classnum);
610     @values = map { $_->amount } qsearch('cust_bill_pkg_detail', $hashref);
611
612   }
613
614   foreach ( @values ) {
615     $sum += $_ if $_;
616   }
617   $sum;
618 }
619
620 =item usage_classes
621
622 Returns a list of usage classnums associated with this invoice line's
623 details.
624   
625 =cut
626
627 sub usage_classes {
628   my( $self ) = @_;
629
630   if ( $self->get('details') ) {
631
632     my %seen = ();
633     foreach my $detail ( grep { ref($_) } @{$self->get('details')} ) {
634       $seen{ $detail->[3] } = 1;
635     }
636     keys %seen;
637
638   }else{
639
640     map { $_->classnum }
641         qsearch({ table   => 'cust_bill_pkg_detail',
642                   hashref => { billpkgnum => $self->billpkgnum },
643                   select  => 'DISTINCT classnum',
644                });
645
646   }
647
648 }
649
650 =item cust_bill_pkg_display [ type => TYPE ]
651
652 Returns an array of display information for the invoice line item optionally
653 limited to 'TYPE'.
654
655 =cut
656
657 sub cust_bill_pkg_display {
658   my ( $self, %opt ) = @_;
659
660   my $default =
661     new FS::cust_bill_pkg_display { billpkgnum =>$self->billpkgnum };
662
663   return ( $default ) unless defined dbdef->table('cust_bill_pkg_display');#hmmm
664
665   my $type = $opt{type} if exists $opt{type};
666   my @result;
667
668   if ( scalar( $self->get('display') ) ) {
669     @result = grep { defined($type) ? ($type eq $_->type) : 1 }
670               @{ $self->get('display') };
671   }else{
672     my $hashref = { 'billpkgnum' => $self->billpkgnum };
673     $hashref->{type} = $type if defined($type);
674     
675     @result = qsearch ({ 'table'    => 'cust_bill_pkg_display',
676                          'hashref'  => { 'billpkgnum' => $self->billpkgnum },
677                          'order_by' => 'ORDER BY billpkgdisplaynum',
678                       });
679   }
680
681   push @result, $default unless ( scalar(@result) || $type );
682
683   @result;
684
685 }
686
687 # reserving this name for my friends FS::{tax_rate|cust_main_county}::taxline
688 # and FS::cust_main::bill
689
690 sub _cust_tax_exempt_pkg {
691   my ( $self ) = @_;
692
693   $self->{Hash}->{_cust_tax_exempt_pkg} or
694   $self->{Hash}->{_cust_tax_exempt_pkg} = [];
695
696 }
697
698
699 =back
700
701 =head1 BUGS
702
703 setup and recur shouldn't be separate fields.  There should be one "amount"
704 field and a flag to tell you if it is a setup/one-time fee or a recurring fee.
705
706 A line item with both should really be two separate records (preserving
707 sdate and edate for setup fees for recurring packages - that information may
708 be valuable later).  Invoice generation (cust_main::bill), invoice printing
709 (cust_bill), tax reports (report_tax.cgi) and line item reports 
710 (cust_bill_pkg.cgi) would need to be updated.
711
712 owed_setup and owed_recur could then be repaced by just owed, and
713 cust_bill::open_cust_bill_pkg and
714 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
715
716 =head1 SEE ALSO
717
718 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
719 from the base documentation.
720
721 =cut
722
723 1;
724