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