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_pay_pkg;
14 use FS::cust_credit_bill_pkg;
15 use FS::cust_tax_exempt_pkg;
16 use FS::cust_bill_pkg_tax_location;
17 use FS::cust_bill_pkg_tax_rate_location;
18 use FS::cust_tax_adjustment;
20 @ISA = qw( FS::cust_main_Mixin FS::Record );
23 $me = '[FS::cust_bill_pkg]';
27 FS::cust_bill_pkg - Object methods for cust_bill_pkg records
31 use FS::cust_bill_pkg;
33 $record = new FS::cust_bill_pkg \%hash;
34 $record = new FS::cust_bill_pkg { 'column' => 'value' };
36 $error = $record->insert;
38 $error = $record->check;
42 An FS::cust_bill_pkg object represents an invoice line item.
43 FS::cust_bill_pkg inherits from FS::Record. The following fields are currently
54 invoice (see L<FS::cust_bill>)
58 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)
60 =item pkgpart_override
62 optional package definition (see L<FS::part_pkg>) override
74 starting date of recurring fee
78 ending date of recurring fee
82 Line item description (overrides normal package description)
86 If not set, defaults to 1
90 If not set, defaults to setup
94 If not set, defaults to recur
98 If set to Y, indicates data should not appear as separate line item on invoice
102 sdate and edate are specified as UNIX timestamps; see L<perlfunc/"time">. Also
103 see L<Time::Local> and L<Date::Parse> for conversion functions.
111 Creates a new line item. To add the line item to the database, see
112 L<"insert">. Line items are normally created by calling the bill method of a
113 customer object (see L<FS::cust_main>).
117 sub table { 'cust_bill_pkg'; }
121 Adds this line item to the database. If there is an error, returns the error,
122 otherwise returns false.
129 local $SIG{HUP} = 'IGNORE';
130 local $SIG{INT} = 'IGNORE';
131 local $SIG{QUIT} = 'IGNORE';
132 local $SIG{TERM} = 'IGNORE';
133 local $SIG{TSTP} = 'IGNORE';
134 local $SIG{PIPE} = 'IGNORE';
136 my $oldAutoCommit = $FS::UID::AutoCommit;
137 local $FS::UID::AutoCommit = 0;
140 my $error = $self->SUPER::insert;
142 $dbh->rollback if $oldAutoCommit;
146 if ( $self->get('details') ) {
147 foreach my $detail ( @{$self->get('details')} ) {
148 my $cust_bill_pkg_detail = new FS::cust_bill_pkg_detail {
149 'billpkgnum' => $self->billpkgnum,
150 'format' => (ref($detail) ? $detail->[0] : '' ),
151 'detail' => (ref($detail) ? $detail->[1] : $detail ),
152 'amount' => (ref($detail) ? $detail->[2] : '' ),
153 'classnum' => (ref($detail) ? $detail->[3] : '' ),
154 'phonenum' => (ref($detail) ? $detail->[4] : '' ),
155 'duration' => (ref($detail) ? $detail->[5] : '' ),
156 'regionname' => (ref($detail) ? $detail->[6] : '' ),
158 $error = $cust_bill_pkg_detail->insert;
160 $dbh->rollback if $oldAutoCommit;
161 return "error inserting cust_bill_pkg_detail: $error";
166 if ( $self->get('display') ) {
167 foreach my $cust_bill_pkg_display ( @{ $self->get('display') } ) {
168 $cust_bill_pkg_display->billpkgnum($self->billpkgnum);
169 $error = $cust_bill_pkg_display->insert;
171 $dbh->rollback if $oldAutoCommit;
172 return "error inserting cust_bill_pkg_display: $error";
177 if ( $self->get('discounts') ) {
178 foreach my $cust_bill_pkg_discount ( @{$self->get('discounts')} ) {
179 $cust_bill_pkg_discount->billpkgnum($self->billpkgnum);
180 $error = $cust_bill_pkg_discount->insert;
182 $dbh->rollback if $oldAutoCommit;
183 return "error inserting cust_bill_pkg_discount: $error";
188 if ( $self->_cust_tax_exempt_pkg ) {
189 foreach my $cust_tax_exempt_pkg ( @{$self->_cust_tax_exempt_pkg} ) {
190 $cust_tax_exempt_pkg->billpkgnum($self->billpkgnum);
191 $error = $cust_tax_exempt_pkg->insert;
193 $dbh->rollback if $oldAutoCommit;
194 return "error inserting cust_tax_exempt_pkg: $error";
199 my $tax_location = $self->get('cust_bill_pkg_tax_location');
200 if ( $tax_location ) {
201 foreach my $cust_bill_pkg_tax_location ( @$tax_location ) {
202 $cust_bill_pkg_tax_location->billpkgnum($self->billpkgnum);
203 $error = $cust_bill_pkg_tax_location->insert;
205 $dbh->rollback if $oldAutoCommit;
206 return "error inserting cust_bill_pkg_tax_location: $error";
211 my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location');
212 if ( $tax_rate_location ) {
213 foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) {
214 $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum);
215 $error = $cust_bill_pkg_tax_rate_location->insert;
217 $dbh->rollback if $oldAutoCommit;
218 return "error inserting cust_bill_pkg_tax_rate_location: $error";
223 my $cust_tax_adjustment = $self->get('cust_tax_adjustment');
224 if ( $cust_tax_adjustment ) {
225 $cust_tax_adjustment->billpkgnum($self->billpkgnum);
226 $error = $cust_tax_adjustment->replace;
228 $dbh->rollback if $oldAutoCommit;
229 return "error replacing cust_tax_adjustment: $error";
233 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
247 local $SIG{HUP} = 'IGNORE';
248 local $SIG{INT} = 'IGNORE';
249 local $SIG{QUIT} = 'IGNORE';
250 local $SIG{TERM} = 'IGNORE';
251 local $SIG{TSTP} = 'IGNORE';
252 local $SIG{PIPE} = 'IGNORE';
254 my $oldAutoCommit = $FS::UID::AutoCommit;
255 local $FS::UID::AutoCommit = 0;
258 foreach my $table (qw(
260 cust_bill_pkg_display
261 cust_bill_pkg_tax_location
262 cust_bill_pkg_tax_rate_location
268 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
269 my $error = $linked->delete;
271 $dbh->rollback if $oldAutoCommit;
278 foreach my $cust_tax_adjustment (
279 qsearch('cust_tax_adjustment', { billpkgnum=>$self->billpkgnum })
281 $cust_tax_adjustment->billpkgnum(''); #NULL
282 my $error = $cust_tax_adjustment->replace;
284 $dbh->rollback if $oldAutoCommit;
289 my $error = $self->SUPER::delete(@_);
291 $dbh->rollback if $oldAutoCommit;
295 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
301 #alas, bin/follow-tax-rename
303 #=item replace OLD_RECORD
305 #Currently unimplemented. This would be even more of an accounting nightmare
306 #than deleteing the items. Just don't do it.
311 # return "Can't modify cust_bill_pkg records!";
316 Checks all fields to make sure this is a valid line item. If there is an
317 error, returns the error, otherwise returns false. Called by the insert
326 $self->ut_numbern('billpkgnum')
327 || $self->ut_snumber('pkgnum')
328 || $self->ut_number('invnum')
329 || $self->ut_money('setup')
330 || $self->ut_money('recur')
331 || $self->ut_numbern('sdate')
332 || $self->ut_numbern('edate')
333 || $self->ut_textn('itemdesc')
334 || $self->ut_textn('itemcomment')
335 || $self->ut_enum('hidden', [ '', 'Y' ])
337 return $error if $error;
339 #if ( $self->pkgnum != 0 ) { #allow unchecked pkgnum 0 for tax! (add to part_pkg?)
340 if ( $self->pkgnum > 0 ) { #allow -1 for non-pkg line items and 0 for tax (add to part_pkg?)
341 return "Unknown pkgnum ". $self->pkgnum
342 unless qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
345 return "Unknown invnum"
346 unless qsearchs( 'cust_bill' ,{ 'invnum' => $self->invnum } );
353 Returns the package (see L<FS::cust_pkg>) for this invoice line item.
359 carp "$me $self -> cust_pkg" if $DEBUG;
360 qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
365 Returns the package definition for this invoice line item.
371 if ( $self->pkgpart_override ) {
372 qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart_override } );
375 my $cust_pkg = $self->cust_pkg;
376 $part_pkg = $cust_pkg->part_pkg if $cust_pkg;
383 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
389 qsearchs( 'cust_bill', { 'invnum' => $self->invnum } );
392 =item previous_cust_bill_pkg
394 Returns the previous cust_bill_pkg for this package, if any.
398 sub previous_cust_bill_pkg {
400 return unless $self->sdate;
402 'table' => 'cust_bill_pkg',
403 'hashref' => { 'pkgnum' => $self->pkgnum,
404 'sdate' => { op=>'<', value=>$self->sdate },
406 'order_by' => 'ORDER BY sdate DESC LIMIT 1',
410 =item details [ OPTION => VALUE ... ]
412 Returns an array of detail information for the invoice line item.
414 Currently available options are: I<format> I<escape_function>
416 If I<format> is set to html or latex then the array members are improved
417 for tabular appearance in those environments if possible.
419 If I<escape_function> is set then the array members are processed by this
420 function before being returned.
425 my ( $self, %opt ) = @_;
426 my $format = $opt{format} || '';
427 my $escape_function = $opt{escape_function} || sub { shift };
428 return () unless defined dbdef->table('cust_bill_pkg_detail');
430 eval "use Text::CSV_XS;";
432 my $csv = new Text::CSV_XS;
434 my $format_sub = sub { my $detail = shift;
435 $csv->parse($detail) or return "can't parse $detail";
436 join(' - ', map { &$escape_function($_) }
441 $format_sub = sub { my $detail = shift;
442 $csv->parse($detail) or return "can't parse $detail";
443 join('</TD><TD>', map { &$escape_function($_) }
447 if $format eq 'html';
449 $format_sub = sub { my $detail = shift;
450 $csv->parse($detail) or return "can't parse $detail";
451 #join(' & ', map { '\small{'. &$escape_function($_). '}' }
455 foreach ($csv->fields) {
456 $result .= ' & ' if $column > 1;
457 if ($column > 6) { # KLUDGE ALERT!
458 $result .= '\multicolumn{1}{l}{\scriptsize{'.
459 &$escape_function($_). '}}';
461 $result .= '\scriptsize{'. &$escape_function($_). '}';
467 if $format eq 'latex';
469 $format_sub = $opt{format_function} if $opt{format_function};
471 map { ( $_->format eq 'C'
472 ? &{$format_sub}( $_->detail, $_ )
473 : &{$escape_function}( $_->detail )
476 qsearch ({ 'table' => 'cust_bill_pkg_detail',
477 'hashref' => { 'billpkgnum' => $self->billpkgnum },
478 'order_by' => 'ORDER BY detailnum',
480 #qsearch ( 'cust_bill_pkg_detail', { 'lineitemnum' => $self->lineitemnum });
483 =item details_header [ OPTION => VALUE ... ]
485 Returns a list representing an invoice line item detail header, if any.
486 This relies on the behavior of voip_cdr in that it expects the header
487 to be the first CSV formatted detail (as is expected by invoice generation
488 routines). Returns the empty list otherwise.
494 return '' unless defined dbdef->table('cust_bill_pkg_detail');
496 eval "use Text::CSV_XS;";
498 my $csv = new Text::CSV_XS;
501 qsearch ({ 'table' => 'cust_bill_pkg_detail',
502 'hashref' => { 'billpkgnum' => $self->billpkgnum,
505 'order_by' => 'ORDER BY detailnum LIMIT 1',
507 return() unless scalar(@detail);
508 $csv->parse($detail[0]->detail) or return ();
514 Returns a description for this line item. For typical line items, this is the
515 I<pkg> field of the corresponding B<FS::part_pkg> object (see L<FS::part_pkg>).
516 For one-shot line items and named taxes, it is the I<itemdesc> field of this
517 line item, and for generic taxes, simply returns "Tax".
524 if ( $self->pkgnum > 0 ) {
525 $self->itemdesc || $self->part_pkg->pkg;
527 my $desc = $self->itemdesc || 'Tax';
528 $desc .= ' '. $self->itemcomment if $self->itemcomment =~ /\S/;
535 Returns the amount owed (still outstanding) on this line item's setup fee,
536 which is the amount of the line item minus all payment applications (see
537 L<FS::cust_bill_pay_pkg> and credit applications (see
538 L<FS::cust_credit_bill_pkg>).
544 $self->owed('setup', @_);
549 Returns the amount owed (still outstanding) on this line item's recurring fee,
550 which is the amount of the line item minus all payment applications (see
551 L<FS::cust_bill_pay_pkg> and credit applications (see
552 L<FS::cust_credit_bill_pkg>).
558 $self->owed('recur', @_);
561 # modeled after cust_bill::owed...
563 my( $self, $field ) = @_;
564 my $balance = $self->$field();
565 $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg($field) );
566 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
567 $balance = sprintf( '%.2f', $balance );
568 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
574 my( $self, $field ) = @_;
575 my $balance = $self->$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
582 sub cust_bill_pay_pkg {
583 my( $self, $field ) = @_;
584 qsearch( 'cust_bill_pay_pkg', { 'billpkgnum' => $self->billpkgnum,
585 'setuprecur' => $field,
590 sub cust_credit_bill_pkg {
591 my( $self, $field ) = @_;
592 qsearch( 'cust_credit_bill_pkg', { 'billpkgnum' => $self->billpkgnum,
593 'setuprecur' => $field,
600 Returns the number of billing units (for tax purposes) represented by this,
607 $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
615 my( $self, $value ) = @_;
616 if ( defined($value) ) {
617 $self->setfield('quantity', $value);
619 $self->getfield('quantity') || 1;
627 my( $self, $value ) = @_;
628 if ( defined($value) ) {
629 $self->setfield('unitsetup', $value);
631 $self->getfield('unitsetup') eq ''
632 ? $self->getfield('setup')
633 : $self->getfield('unitsetup');
641 my( $self, $value ) = @_;
642 if ( defined($value) ) {
643 $self->setfield('unitrecur', $value);
645 $self->getfield('unitrecur') eq ''
646 ? $self->getfield('recur')
647 : $self->getfield('unitrecur');
652 Returns a list of cust_bill_pkg objects each with no more than a single class
653 (including setup or recur) of charge.
659 # XXX this goes away with cust_bill_pkg refactor
661 my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash };
662 my %cust_bill_pkg = ();
664 $cust_bill_pkg{setup} = $cust_bill_pkg if $cust_bill_pkg->setup;
665 $cust_bill_pkg{recur} = $cust_bill_pkg if $cust_bill_pkg->recur;
668 #split setup and recur
669 if ($cust_bill_pkg->setup && $cust_bill_pkg->recur) {
670 my $cust_bill_pkg_recur = new FS::cust_bill_pkg { $cust_bill_pkg->hash };
671 $cust_bill_pkg->set('details', []);
672 $cust_bill_pkg->recur(0);
673 $cust_bill_pkg->unitrecur(0);
674 $cust_bill_pkg->type('');
675 $cust_bill_pkg_recur->setup(0);
676 $cust_bill_pkg_recur->unitsetup(0);
677 $cust_bill_pkg{recur} = $cust_bill_pkg_recur;
681 #split usage from recur
682 my $usage = sprintf( "%.2f", $cust_bill_pkg{recur}->usage )
683 if exists($cust_bill_pkg{recur});
684 warn "usage is $usage\n" if $DEBUG > 1;
686 my $cust_bill_pkg_usage =
687 new FS::cust_bill_pkg { $cust_bill_pkg{recur}->hash };
688 $cust_bill_pkg_usage->recur( $usage );
689 $cust_bill_pkg_usage->type( 'U' );
690 my $recur = sprintf( "%.2f", $cust_bill_pkg{recur}->recur - $usage );
691 $cust_bill_pkg{recur}->recur( $recur );
692 $cust_bill_pkg{recur}->type( '' );
693 $cust_bill_pkg{recur}->set('details', []);
694 $cust_bill_pkg{''} = $cust_bill_pkg_usage;
697 #subdivide usage by usage_class
698 if (exists($cust_bill_pkg{''})) {
699 foreach my $class (grep { $_ } $self->usage_classes) {
700 my $usage = sprintf( "%.2f", $cust_bill_pkg{''}->usage($class) );
701 my $cust_bill_pkg_usage =
702 new FS::cust_bill_pkg { $cust_bill_pkg{''}->hash };
703 $cust_bill_pkg_usage->recur( $usage );
704 $cust_bill_pkg_usage->set('details', []);
705 my $classless = sprintf( "%.2f", $cust_bill_pkg{''}->recur - $usage );
706 $cust_bill_pkg{''}->recur( $classless );
707 $cust_bill_pkg{$class} = $cust_bill_pkg_usage;
709 warn "Unexpected classless usage value: ". $cust_bill_pkg{''}->recur
710 if ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur < 0);
711 delete $cust_bill_pkg{''}
712 unless ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur > 0);
715 # # sort setup,recur,'', and the rest numeric && return
716 # my @result = map { $cust_bill_pkg{$_} }
717 # sort { my $ad = ($a=~/^\d+$/); my $bd = ($b=~/^\d+$/);
718 # ( $ad cmp $bd ) || ( $ad ? $a<=>$b : $b cmp $a )
720 # keys %cust_bill_pkg;
729 Returns the amount of the charge associated with usage class CLASSNUM if
730 CLASSNUM is defined. Otherwise returns the total charge associated with
736 my( $self, $classnum ) = @_;
740 if ( $self->get('details') ) {
744 grep { ref($_) && ( defined($classnum) ? $_->[3] eq $classnum : 1 ) }
745 @{ $self->get('details') };
749 my $hashref = { 'billpkgnum' => $self->billpkgnum };
750 $hashref->{ 'classnum' } = $classnum if defined($classnum);
751 @values = map { $_->amount } qsearch('cust_bill_pkg_detail', $hashref);
755 foreach ( @values ) {
763 Returns a list of usage classnums associated with this invoice line's
771 if ( $self->get('details') ) {
774 foreach my $detail ( grep { ref($_) } @{$self->get('details')} ) {
775 $seen{ $detail->[3] } = 1;
782 qsearch({ table => 'cust_bill_pkg_detail',
783 hashref => { billpkgnum => $self->billpkgnum },
784 select => 'DISTINCT classnum',
791 =item cust_bill_pkg_display [ type => TYPE ]
793 Returns an array of display information for the invoice line item optionally
798 sub cust_bill_pkg_display {
799 my ( $self, %opt ) = @_;
802 new FS::cust_bill_pkg_display { billpkgnum =>$self->billpkgnum };
804 return ( $default ) unless defined dbdef->table('cust_bill_pkg_display');#hmmm
806 my $type = $opt{type} if exists $opt{type};
809 if ( $self->get('display') ) {
810 @result = grep { defined($type) ? ($type eq $_->type) : 1 }
811 @{ $self->get('display') };
813 my $hashref = { 'billpkgnum' => $self->billpkgnum };
814 $hashref->{type} = $type if defined($type);
816 @result = qsearch ({ 'table' => 'cust_bill_pkg_display',
817 'hashref' => { 'billpkgnum' => $self->billpkgnum },
818 'order_by' => 'ORDER BY billpkgdisplaynum',
822 push @result, $default unless ( scalar(@result) || $type );
828 # reserving this name for my friends FS::{tax_rate|cust_main_county}::taxline
829 # and FS::cust_main::bill
831 sub _cust_tax_exempt_pkg {
834 $self->{Hash}->{_cust_tax_exempt_pkg} or
835 $self->{Hash}->{_cust_tax_exempt_pkg} = [];
839 =item cust_bill_pkg_tax_Xlocation
841 Returns the list of associated cust_bill_pkg_tax_location and/or
842 cust_bill_pkg_tax_rate_location objects
846 sub cust_bill_pkg_tax_Xlocation {
849 my %hash = ( 'billpkgnum' => $self->billpkgnum );
852 qsearch ( 'cust_bill_pkg_tax_location', { %hash } ),
853 qsearch ( 'cust_bill_pkg_tax_rate_location', { %hash } )
858 =item cust_bill_pkg_detail [ CLASSNUM ]
860 Returns the list of associated cust_bill_pkg_detail objects
861 The optional CLASSNUM argument will limit the details to the specified usage
866 sub cust_bill_pkg_detail {
868 my $classnum = shift || '';
870 my %hash = ( 'billpkgnum' => $self->billpkgnum );
871 $hash{classnum} = $classnum if $classnum;
873 qsearch ( 'cust_bill_pkg_detail', { %hash } ),
881 setup and recur shouldn't be separate fields. There should be one "amount"
882 field and a flag to tell you if it is a setup/one-time fee or a recurring fee.
884 A line item with both should really be two separate records (preserving
885 sdate and edate for setup fees for recurring packages - that information may
886 be valuable later). Invoice generation (cust_main::bill), invoice printing
887 (cust_bill), tax reports (report_tax.cgi) and line item reports
888 (cust_bill_pkg.cgi) would need to be updated.
890 owed_setup and owed_recur could then be repaced by just owed, and
891 cust_bill::open_cust_bill_pkg and
892 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
896 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
897 from the base documentation.