1 package FS::cust_bill_pkg;
4 use vars qw( @ISA $DEBUG $me );
6 use FS::Record qw( qsearch qsearchs dbdef dbh );
7 use FS::cust_main_Mixin;
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_pay_pkg;
15 use FS::cust_credit_bill_pkg;
16 use FS::cust_tax_exempt_pkg;
17 use FS::cust_bill_pkg_tax_location;
18 use FS::cust_bill_pkg_tax_rate_location;
19 use FS::cust_tax_adjustment;
21 @ISA = qw( FS::cust_main_Mixin FS::Record );
24 $me = '[FS::cust_bill_pkg]';
28 FS::cust_bill_pkg - Object methods for cust_bill_pkg records
32 use FS::cust_bill_pkg;
34 $record = new FS::cust_bill_pkg \%hash;
35 $record = new FS::cust_bill_pkg { 'column' => 'value' };
37 $error = $record->insert;
39 $error = $record->check;
43 An FS::cust_bill_pkg object represents an invoice line item.
44 FS::cust_bill_pkg inherits from FS::Record. The following fields are currently
55 invoice (see L<FS::cust_bill>)
59 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)
61 =item pkgpart_override
63 optional package definition (see L<FS::part_pkg>) override
75 starting date of recurring fee
79 ending date of recurring fee
83 Line item description (overrides normal package description)
87 If not set, defaults to 1
91 If not set, defaults to setup
95 If not set, defaults to recur
99 If set to Y, indicates data should not appear as separate line item on invoice
103 sdate and edate are specified as UNIX timestamps; see L<perlfunc/"time">. Also
104 see L<Time::Local> and L<Date::Parse> for conversion functions.
112 Creates a new line item. To add the line item to the database, see
113 L<"insert">. Line items are normally created by calling the bill method of a
114 customer object (see L<FS::cust_main>).
118 sub table { 'cust_bill_pkg'; }
122 Adds this line item to the database. If there is an error, returns the error,
123 otherwise returns false.
130 local $SIG{HUP} = 'IGNORE';
131 local $SIG{INT} = 'IGNORE';
132 local $SIG{QUIT} = 'IGNORE';
133 local $SIG{TERM} = 'IGNORE';
134 local $SIG{TSTP} = 'IGNORE';
135 local $SIG{PIPE} = 'IGNORE';
137 my $oldAutoCommit = $FS::UID::AutoCommit;
138 local $FS::UID::AutoCommit = 0;
141 my $error = $self->SUPER::insert;
143 $dbh->rollback if $oldAutoCommit;
147 if ( $self->get('details') ) {
148 foreach my $detail ( @{$self->get('details')} ) {
149 my $cust_bill_pkg_detail = new FS::cust_bill_pkg_detail {
150 'billpkgnum' => $self->billpkgnum,
151 'format' => (ref($detail) ? $detail->[0] : '' ),
152 'detail' => (ref($detail) ? $detail->[1] : $detail ),
153 'amount' => (ref($detail) ? $detail->[2] : '' ),
154 'classnum' => (ref($detail) ? $detail->[3] : '' ),
155 'phonenum' => (ref($detail) ? $detail->[4] : '' ),
156 'accountcode' => (ref($detail) ? $detail->[5] : '' ),
157 'startdate' => (ref($detail) ? $detail->[6] : '' ),
158 'duration' => (ref($detail) ? $detail->[7] : '' ),
159 'regionname' => (ref($detail) ? $detail->[8] : '' ),
161 $error = $cust_bill_pkg_detail->insert;
163 $dbh->rollback if $oldAutoCommit;
164 return "error inserting cust_bill_pkg_detail: $error";
169 if ( $self->get('display') ) {
170 foreach my $cust_bill_pkg_display ( @{ $self->get('display') } ) {
171 $cust_bill_pkg_display->billpkgnum($self->billpkgnum);
172 $error = $cust_bill_pkg_display->insert;
174 $dbh->rollback if $oldAutoCommit;
175 return "error inserting cust_bill_pkg_display: $error";
180 if ( $self->get('discounts') ) {
181 foreach my $cust_bill_pkg_discount ( @{$self->get('discounts')} ) {
182 $cust_bill_pkg_discount->billpkgnum($self->billpkgnum);
183 $error = $cust_bill_pkg_discount->insert;
185 $dbh->rollback if $oldAutoCommit;
186 return "error inserting cust_bill_pkg_discount: $error";
191 if ( $self->_cust_tax_exempt_pkg ) {
192 foreach my $cust_tax_exempt_pkg ( @{$self->_cust_tax_exempt_pkg} ) {
193 $cust_tax_exempt_pkg->billpkgnum($self->billpkgnum);
194 $error = $cust_tax_exempt_pkg->insert;
196 $dbh->rollback if $oldAutoCommit;
197 return "error inserting cust_tax_exempt_pkg: $error";
202 my $tax_location = $self->get('cust_bill_pkg_tax_location');
203 if ( $tax_location ) {
204 foreach my $cust_bill_pkg_tax_location ( @$tax_location ) {
205 $cust_bill_pkg_tax_location->billpkgnum($self->billpkgnum);
206 $error = $cust_bill_pkg_tax_location->insert;
208 $dbh->rollback if $oldAutoCommit;
209 return "error inserting cust_bill_pkg_tax_location: $error";
214 my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location');
215 if ( $tax_rate_location ) {
216 foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) {
217 $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum);
218 $error = $cust_bill_pkg_tax_rate_location->insert;
220 $dbh->rollback if $oldAutoCommit;
221 return "error inserting cust_bill_pkg_tax_rate_location: $error";
226 my $cust_tax_adjustment = $self->get('cust_tax_adjustment');
227 if ( $cust_tax_adjustment ) {
228 $cust_tax_adjustment->billpkgnum($self->billpkgnum);
229 $error = $cust_tax_adjustment->replace;
231 $dbh->rollback if $oldAutoCommit;
232 return "error replacing cust_tax_adjustment: $error";
236 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
250 local $SIG{HUP} = 'IGNORE';
251 local $SIG{INT} = 'IGNORE';
252 local $SIG{QUIT} = 'IGNORE';
253 local $SIG{TERM} = 'IGNORE';
254 local $SIG{TSTP} = 'IGNORE';
255 local $SIG{PIPE} = 'IGNORE';
257 my $oldAutoCommit = $FS::UID::AutoCommit;
258 local $FS::UID::AutoCommit = 0;
261 foreach my $table (qw(
263 cust_bill_pkg_display
264 cust_bill_pkg_tax_location
265 cust_bill_pkg_tax_rate_location
271 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
272 my $error = $linked->delete;
274 $dbh->rollback if $oldAutoCommit;
281 foreach my $cust_tax_adjustment (
282 qsearch('cust_tax_adjustment', { billpkgnum=>$self->billpkgnum })
284 $cust_tax_adjustment->billpkgnum(''); #NULL
285 my $error = $cust_tax_adjustment->replace;
287 $dbh->rollback if $oldAutoCommit;
292 my $error = $self->SUPER::delete(@_);
294 $dbh->rollback if $oldAutoCommit;
298 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
304 #alas, bin/follow-tax-rename
306 #=item replace OLD_RECORD
308 #Currently unimplemented. This would be even more of an accounting nightmare
309 #than deleteing the items. Just don't do it.
314 # return "Can't modify cust_bill_pkg records!";
319 Checks all fields to make sure this is a valid line item. If there is an
320 error, returns the error, otherwise returns false. Called by the insert
329 $self->ut_numbern('billpkgnum')
330 || $self->ut_snumber('pkgnum')
331 || $self->ut_number('invnum')
332 || $self->ut_money('setup')
333 || $self->ut_money('recur')
334 || $self->ut_numbern('sdate')
335 || $self->ut_numbern('edate')
336 || $self->ut_textn('itemdesc')
337 || $self->ut_textn('itemcomment')
338 || $self->ut_enum('hidden', [ '', 'Y' ])
340 return $error if $error;
342 #if ( $self->pkgnum != 0 ) { #allow unchecked pkgnum 0 for tax! (add to part_pkg?)
343 if ( $self->pkgnum > 0 ) { #allow -1 for non-pkg line items and 0 for tax (add to part_pkg?)
344 return "Unknown pkgnum ". $self->pkgnum
345 unless qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
348 return "Unknown invnum"
349 unless qsearchs( 'cust_bill' ,{ 'invnum' => $self->invnum } );
356 Returns the package (see L<FS::cust_pkg>) for this invoice line item.
362 carp "$me $self -> cust_pkg" if $DEBUG;
363 qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
368 Returns the package definition for this invoice line item.
374 if ( $self->pkgpart_override ) {
375 qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart_override } );
378 my $cust_pkg = $self->cust_pkg;
379 $part_pkg = $cust_pkg->part_pkg if $cust_pkg;
386 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
392 qsearchs( 'cust_bill', { 'invnum' => $self->invnum } );
395 =item previous_cust_bill_pkg
397 Returns the previous cust_bill_pkg for this package, if any.
401 sub previous_cust_bill_pkg {
403 return unless $self->sdate;
405 'table' => 'cust_bill_pkg',
406 'hashref' => { 'pkgnum' => $self->pkgnum,
407 'sdate' => { op=>'<', value=>$self->sdate },
409 'order_by' => 'ORDER BY sdate DESC LIMIT 1',
413 =item details [ OPTION => VALUE ... ]
415 Returns an array of detail information for the invoice line item.
417 Currently available options are: I<format>, I<escape_function> and
420 If I<format> is set to html or latex then the array members are improved
421 for tabular appearance in those environments if possible.
423 If I<escape_function> is set then the array members are processed by this
424 function before being returned.
426 I<format_function> overrides the normal HTML or LaTeX function for returning
427 formatted CDRs. It can be set to a subroutine which returns an empty list
428 to skip usage detail:
430 'format_function' => sub { () },
435 my ( $self, %opt ) = @_;
436 my $format = $opt{format} || '';
437 my $escape_function = $opt{escape_function} || sub { shift };
438 return () unless defined dbdef->table('cust_bill_pkg_detail');
440 eval "use Text::CSV_XS;";
442 my $csv = new Text::CSV_XS;
444 my $format_sub = sub { my $detail = shift;
445 $csv->parse($detail) or return "can't parse $detail";
446 join(' - ', map { &$escape_function($_) }
451 $format_sub = sub { my $detail = shift;
452 $csv->parse($detail) or return "can't parse $detail";
453 join('</TD><TD>', map { &$escape_function($_) }
457 if $format eq 'html';
459 $format_sub = sub { my $detail = shift;
460 $csv->parse($detail) or return "can't parse $detail";
461 #join(' & ', map { '\small{'. &$escape_function($_). '}' }
465 foreach ($csv->fields) {
466 $result .= ' & ' if $column > 1;
467 if ($column > 6) { # KLUDGE ALERT!
468 $result .= '\multicolumn{1}{l}{\scriptsize{'.
469 &$escape_function($_). '}}';
471 $result .= '\scriptsize{'. &$escape_function($_). '}';
477 if $format eq 'latex';
479 $format_sub = $opt{format_function} if $opt{format_function};
481 map { ( $_->format eq 'C'
482 ? &{$format_sub}( $_->detail, $_ )
483 : &{$escape_function}( $_->detail )
486 qsearch ({ 'table' => 'cust_bill_pkg_detail',
487 'hashref' => { 'billpkgnum' => $self->billpkgnum },
488 'order_by' => 'ORDER BY detailnum',
490 #qsearch ( 'cust_bill_pkg_detail', { 'lineitemnum' => $self->lineitemnum });
493 =item details_header [ OPTION => VALUE ... ]
495 Returns a list representing an invoice line item detail header, if any.
496 This relies on the behavior of voip_cdr in that it expects the header
497 to be the first CSV formatted detail (as is expected by invoice generation
498 routines). Returns the empty list otherwise.
504 return '' unless defined dbdef->table('cust_bill_pkg_detail');
506 eval "use Text::CSV_XS;";
508 my $csv = new Text::CSV_XS;
511 qsearch ({ 'table' => 'cust_bill_pkg_detail',
512 'hashref' => { 'billpkgnum' => $self->billpkgnum,
515 'order_by' => 'ORDER BY detailnum LIMIT 1',
517 return() unless scalar(@detail);
518 $csv->parse($detail[0]->detail) or return ();
524 Returns a description for this line item. For typical line items, this is the
525 I<pkg> field of the corresponding B<FS::part_pkg> object (see L<FS::part_pkg>).
526 For one-shot line items and named taxes, it is the I<itemdesc> field of this
527 line item, and for generic taxes, simply returns "Tax".
534 if ( $self->pkgnum > 0 ) {
535 $self->itemdesc || $self->part_pkg->pkg;
537 my $desc = $self->itemdesc || 'Tax';
538 $desc .= ' '. $self->itemcomment if $self->itemcomment =~ /\S/;
545 Returns the amount owed (still outstanding) on this line item's setup fee,
546 which is the amount of the line item minus all payment applications (see
547 L<FS::cust_bill_pay_pkg> and credit applications (see
548 L<FS::cust_credit_bill_pkg>).
554 $self->owed('setup', @_);
559 Returns the amount owed (still outstanding) on this line item's recurring fee,
560 which is the amount of the line item minus all payment applications (see
561 L<FS::cust_bill_pay_pkg> and credit applications (see
562 L<FS::cust_credit_bill_pkg>).
568 $self->owed('recur', @_);
571 # modeled after cust_bill::owed...
573 my( $self, $field ) = @_;
574 my $balance = $self->$field();
575 $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg($field) );
576 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
577 $balance = sprintf( '%.2f', $balance );
578 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
584 my( $self, $field ) = @_;
585 my $balance = $self->$field();
586 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
587 $balance = sprintf( '%.2f', $balance );
588 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
592 sub cust_bill_pay_pkg {
593 my( $self, $field ) = @_;
594 qsearch( 'cust_bill_pay_pkg', { 'billpkgnum' => $self->billpkgnum,
595 'setuprecur' => $field,
600 sub cust_credit_bill_pkg {
601 my( $self, $field ) = @_;
602 qsearch( 'cust_credit_bill_pkg', { 'billpkgnum' => $self->billpkgnum,
603 'setuprecur' => $field,
610 Returns the number of billing units (for tax purposes) represented by this,
617 $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
625 my( $self, $value ) = @_;
626 if ( defined($value) ) {
627 $self->setfield('quantity', $value);
629 $self->getfield('quantity') || 1;
637 my( $self, $value ) = @_;
638 if ( defined($value) ) {
639 $self->setfield('unitsetup', $value);
641 $self->getfield('unitsetup') eq ''
642 ? $self->getfield('setup')
643 : $self->getfield('unitsetup');
651 my( $self, $value ) = @_;
652 if ( defined($value) ) {
653 $self->setfield('unitrecur', $value);
655 $self->getfield('unitrecur') eq ''
656 ? $self->getfield('recur')
657 : $self->getfield('unitrecur');
662 Returns a list of cust_bill_pkg objects each with no more than a single class
663 (including setup or recur) of charge.
669 # XXX this goes away with cust_bill_pkg refactor
671 my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash };
672 my %cust_bill_pkg = ();
674 $cust_bill_pkg{setup} = $cust_bill_pkg if $cust_bill_pkg->setup;
675 $cust_bill_pkg{recur} = $cust_bill_pkg if $cust_bill_pkg->recur;
678 #split setup and recur
679 if ($cust_bill_pkg->setup && $cust_bill_pkg->recur) {
680 my $cust_bill_pkg_recur = new FS::cust_bill_pkg { $cust_bill_pkg->hash };
681 $cust_bill_pkg->set('details', []);
682 $cust_bill_pkg->recur(0);
683 $cust_bill_pkg->unitrecur(0);
684 $cust_bill_pkg->type('');
685 $cust_bill_pkg_recur->setup(0);
686 $cust_bill_pkg_recur->unitsetup(0);
687 $cust_bill_pkg{recur} = $cust_bill_pkg_recur;
691 #split usage from recur
692 my $usage = sprintf( "%.2f", $cust_bill_pkg{recur}->usage )
693 if exists($cust_bill_pkg{recur});
694 warn "usage is $usage\n" if $DEBUG > 1;
696 my $cust_bill_pkg_usage =
697 new FS::cust_bill_pkg { $cust_bill_pkg{recur}->hash };
698 $cust_bill_pkg_usage->recur( $usage );
699 $cust_bill_pkg_usage->type( 'U' );
700 my $recur = sprintf( "%.2f", $cust_bill_pkg{recur}->recur - $usage );
701 $cust_bill_pkg{recur}->recur( $recur );
702 $cust_bill_pkg{recur}->type( '' );
703 $cust_bill_pkg{recur}->set('details', []);
704 $cust_bill_pkg{''} = $cust_bill_pkg_usage;
707 #subdivide usage by usage_class
708 if (exists($cust_bill_pkg{''})) {
709 foreach my $class (grep { $_ } $self->usage_classes) {
710 my $usage = sprintf( "%.2f", $cust_bill_pkg{''}->usage($class) );
711 my $cust_bill_pkg_usage =
712 new FS::cust_bill_pkg { $cust_bill_pkg{''}->hash };
713 $cust_bill_pkg_usage->recur( $usage );
714 $cust_bill_pkg_usage->set('details', []);
715 my $classless = sprintf( "%.2f", $cust_bill_pkg{''}->recur - $usage );
716 $cust_bill_pkg{''}->recur( $classless );
717 $cust_bill_pkg{$class} = $cust_bill_pkg_usage;
719 warn "Unexpected classless usage value: ". $cust_bill_pkg{''}->recur
720 if ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur < 0);
721 delete $cust_bill_pkg{''}
722 unless ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur > 0);
725 # # sort setup,recur,'', and the rest numeric && return
726 # my @result = map { $cust_bill_pkg{$_} }
727 # sort { my $ad = ($a=~/^\d+$/); my $bd = ($b=~/^\d+$/);
728 # ( $ad cmp $bd ) || ( $ad ? $a<=>$b : $b cmp $a )
730 # keys %cust_bill_pkg;
739 Returns the amount of the charge associated with usage class CLASSNUM if
740 CLASSNUM is defined. Otherwise returns the total charge associated with
746 my( $self, $classnum ) = @_;
750 if ( $self->get('details') ) {
754 grep { ref($_) && ( defined($classnum) ? $_->[3] eq $classnum : 1 ) }
755 @{ $self->get('details') };
759 my $hashref = { 'billpkgnum' => $self->billpkgnum };
760 $hashref->{ 'classnum' } = $classnum if defined($classnum);
761 @values = map { $_->amount } qsearch('cust_bill_pkg_detail', $hashref);
765 foreach ( @values ) {
773 Returns a list of usage classnums associated with this invoice line's
781 if ( $self->get('details') ) {
784 foreach my $detail ( grep { ref($_) } @{$self->get('details')} ) {
785 $seen{ $detail->[3] } = 1;
792 qsearch({ table => 'cust_bill_pkg_detail',
793 hashref => { billpkgnum => $self->billpkgnum },
794 select => 'DISTINCT classnum',
801 =item cust_bill_pkg_display [ type => TYPE ]
803 Returns an array of display information for the invoice line item optionally
808 sub cust_bill_pkg_display {
809 my ( $self, %opt ) = @_;
812 new FS::cust_bill_pkg_display { billpkgnum =>$self->billpkgnum };
814 return ( $default ) unless defined dbdef->table('cust_bill_pkg_display');#hmmm
816 my $type = $opt{type} if exists $opt{type};
819 if ( $self->get('display') ) {
820 @result = grep { defined($type) ? ($type eq $_->type) : 1 }
821 @{ $self->get('display') };
823 my $hashref = { 'billpkgnum' => $self->billpkgnum };
824 $hashref->{type} = $type if defined($type);
826 @result = qsearch ({ 'table' => 'cust_bill_pkg_display',
827 'hashref' => { 'billpkgnum' => $self->billpkgnum },
828 'order_by' => 'ORDER BY billpkgdisplaynum',
832 push @result, $default unless ( scalar(@result) || $type );
838 # reserving this name for my friends FS::{tax_rate|cust_main_county}::taxline
839 # and FS::cust_main::bill
841 sub _cust_tax_exempt_pkg {
844 $self->{Hash}->{_cust_tax_exempt_pkg} or
845 $self->{Hash}->{_cust_tax_exempt_pkg} = [];
849 =item cust_bill_pkg_tax_Xlocation
851 Returns the list of associated cust_bill_pkg_tax_location and/or
852 cust_bill_pkg_tax_rate_location objects
856 sub cust_bill_pkg_tax_Xlocation {
859 my %hash = ( 'billpkgnum' => $self->billpkgnum );
862 qsearch ( 'cust_bill_pkg_tax_location', { %hash } ),
863 qsearch ( 'cust_bill_pkg_tax_rate_location', { %hash } )
868 =item cust_bill_pkg_detail [ CLASSNUM ]
870 Returns the list of associated cust_bill_pkg_detail objects
871 The optional CLASSNUM argument will limit the details to the specified usage
876 sub cust_bill_pkg_detail {
878 my $classnum = shift || '';
880 my %hash = ( 'billpkgnum' => $self->billpkgnum );
881 $hash{classnum} = $classnum if $classnum;
883 qsearch( 'cust_bill_pkg_detail', \%hash ),
887 =item cust_bill_pkg_discount
889 Returns the list of associated cust_bill_pkg_discount objects.
893 sub cust_bill_pkg_discount {
895 qsearch( 'cust_bill_pkg_discount', { 'billpkgnum' => $self->billpkgnum } );
898 =item recur_show_zero
902 sub recur_show_zero {
906 #&& $self->cust_pkg->part_pkg->recur_show_zero;
908 shift->_X_show_zero('recur');
912 sub setup_show_zero {
913 shift->_X_show_zero('setup');
917 my( $self, $what ) = @_;
919 return 0 unless $self->$what() == 0 && $self->pkgnum;
921 $self->cust_pkg->_X_show_zero($what);
928 setup and recur shouldn't be separate fields. There should be one "amount"
929 field and a flag to tell you if it is a setup/one-time fee or a recurring fee.
931 A line item with both should really be two separate records (preserving
932 sdate and edate for setup fees for recurring packages - that information may
933 be valuable later). Invoice generation (cust_main::bill), invoice printing
934 (cust_bill), tax reports (report_tax.cgi) and line item reports
935 (cust_bill_pkg.cgi) would need to be updated.
937 owed_setup and owed_recur could then be repaced by just owed, and
938 cust_bill::open_cust_bill_pkg and
939 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
943 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
944 from the base documentation.