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 'duration' => (ref($detail) ? $detail->[5] : '' ),
157 'regionname' => (ref($detail) ? $detail->[6] : '' ),
159 $error = $cust_bill_pkg_detail->insert;
161 $dbh->rollback if $oldAutoCommit;
162 return "error inserting cust_bill_pkg_detail: $error";
167 if ( $self->get('display') ) {
168 foreach my $cust_bill_pkg_display ( @{ $self->get('display') } ) {
169 $cust_bill_pkg_display->billpkgnum($self->billpkgnum);
170 $error = $cust_bill_pkg_display->insert;
172 $dbh->rollback if $oldAutoCommit;
173 return "error inserting cust_bill_pkg_display: $error";
178 if ( $self->get('discounts') ) {
179 foreach my $cust_bill_pkg_discount ( @{$self->get('discounts')} ) {
180 $cust_bill_pkg_discount->billpkgnum($self->billpkgnum);
181 $error = $cust_bill_pkg_discount->insert;
183 $dbh->rollback if $oldAutoCommit;
184 return "error inserting cust_bill_pkg_discount: $error";
189 if ( $self->_cust_tax_exempt_pkg ) {
190 foreach my $cust_tax_exempt_pkg ( @{$self->_cust_tax_exempt_pkg} ) {
191 $cust_tax_exempt_pkg->billpkgnum($self->billpkgnum);
192 $error = $cust_tax_exempt_pkg->insert;
194 $dbh->rollback if $oldAutoCommit;
195 return "error inserting cust_tax_exempt_pkg: $error";
200 my $tax_location = $self->get('cust_bill_pkg_tax_location');
201 if ( $tax_location ) {
202 foreach my $cust_bill_pkg_tax_location ( @$tax_location ) {
203 $cust_bill_pkg_tax_location->billpkgnum($self->billpkgnum);
204 $error = $cust_bill_pkg_tax_location->insert;
206 $dbh->rollback if $oldAutoCommit;
207 return "error inserting cust_bill_pkg_tax_location: $error";
212 my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location');
213 if ( $tax_rate_location ) {
214 foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) {
215 $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum);
216 $error = $cust_bill_pkg_tax_rate_location->insert;
218 $dbh->rollback if $oldAutoCommit;
219 return "error inserting cust_bill_pkg_tax_rate_location: $error";
224 my $cust_tax_adjustment = $self->get('cust_tax_adjustment');
225 if ( $cust_tax_adjustment ) {
226 $cust_tax_adjustment->billpkgnum($self->billpkgnum);
227 $error = $cust_tax_adjustment->replace;
229 $dbh->rollback if $oldAutoCommit;
230 return "error replacing cust_tax_adjustment: $error";
234 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
248 local $SIG{HUP} = 'IGNORE';
249 local $SIG{INT} = 'IGNORE';
250 local $SIG{QUIT} = 'IGNORE';
251 local $SIG{TERM} = 'IGNORE';
252 local $SIG{TSTP} = 'IGNORE';
253 local $SIG{PIPE} = 'IGNORE';
255 my $oldAutoCommit = $FS::UID::AutoCommit;
256 local $FS::UID::AutoCommit = 0;
259 foreach my $table (qw(
261 cust_bill_pkg_display
262 cust_bill_pkg_tax_location
263 cust_bill_pkg_tax_rate_location
269 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
270 my $error = $linked->delete;
272 $dbh->rollback if $oldAutoCommit;
279 foreach my $cust_tax_adjustment (
280 qsearch('cust_tax_adjustment', { billpkgnum=>$self->billpkgnum })
282 $cust_tax_adjustment->billpkgnum(''); #NULL
283 my $error = $cust_tax_adjustment->replace;
285 $dbh->rollback if $oldAutoCommit;
290 my $error = $self->SUPER::delete(@_);
292 $dbh->rollback if $oldAutoCommit;
296 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
302 #alas, bin/follow-tax-rename
304 #=item replace OLD_RECORD
306 #Currently unimplemented. This would be even more of an accounting nightmare
307 #than deleteing the items. Just don't do it.
312 # return "Can't modify cust_bill_pkg records!";
317 Checks all fields to make sure this is a valid line item. If there is an
318 error, returns the error, otherwise returns false. Called by the insert
327 $self->ut_numbern('billpkgnum')
328 || $self->ut_snumber('pkgnum')
329 || $self->ut_number('invnum')
330 || $self->ut_money('setup')
331 || $self->ut_money('recur')
332 || $self->ut_numbern('sdate')
333 || $self->ut_numbern('edate')
334 || $self->ut_textn('itemdesc')
335 || $self->ut_textn('itemcomment')
336 || $self->ut_enum('hidden', [ '', 'Y' ])
338 return $error if $error;
340 #if ( $self->pkgnum != 0 ) { #allow unchecked pkgnum 0 for tax! (add to part_pkg?)
341 if ( $self->pkgnum > 0 ) { #allow -1 for non-pkg line items and 0 for tax (add to part_pkg?)
342 return "Unknown pkgnum ". $self->pkgnum
343 unless qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
346 return "Unknown invnum"
347 unless qsearchs( 'cust_bill' ,{ 'invnum' => $self->invnum } );
354 Returns the package (see L<FS::cust_pkg>) for this invoice line item.
360 carp "$me $self -> cust_pkg" if $DEBUG;
361 qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
366 Returns the package definition for this invoice line item.
372 if ( $self->pkgpart_override ) {
373 qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart_override } );
376 my $cust_pkg = $self->cust_pkg;
377 $part_pkg = $cust_pkg->part_pkg if $cust_pkg;
384 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
390 qsearchs( 'cust_bill', { 'invnum' => $self->invnum } );
393 =item previous_cust_bill_pkg
395 Returns the previous cust_bill_pkg for this package, if any.
399 sub previous_cust_bill_pkg {
401 return unless $self->sdate;
403 'table' => 'cust_bill_pkg',
404 'hashref' => { 'pkgnum' => $self->pkgnum,
405 'sdate' => { op=>'<', value=>$self->sdate },
407 'order_by' => 'ORDER BY sdate DESC LIMIT 1',
411 =item details [ OPTION => VALUE ... ]
413 Returns an array of detail information for the invoice line item.
415 Currently available options are: I<format> I<escape_function>
417 If I<format> is set to html or latex then the array members are improved
418 for tabular appearance in those environments if possible.
420 If I<escape_function> is set then the array members are processed by this
421 function before being returned.
426 my ( $self, %opt ) = @_;
427 my $format = $opt{format} || '';
428 my $escape_function = $opt{escape_function} || sub { shift };
429 return () unless defined dbdef->table('cust_bill_pkg_detail');
431 eval "use Text::CSV_XS;";
433 my $csv = new Text::CSV_XS;
435 my $format_sub = sub { my $detail = shift;
436 $csv->parse($detail) or return "can't parse $detail";
437 join(' - ', map { &$escape_function($_) }
442 $format_sub = sub { my $detail = shift;
443 $csv->parse($detail) or return "can't parse $detail";
444 join('</TD><TD>', map { &$escape_function($_) }
448 if $format eq 'html';
450 $format_sub = sub { my $detail = shift;
451 $csv->parse($detail) or return "can't parse $detail";
452 #join(' & ', map { '\small{'. &$escape_function($_). '}' }
456 foreach ($csv->fields) {
457 $result .= ' & ' if $column > 1;
458 if ($column > 6) { # KLUDGE ALERT!
459 $result .= '\multicolumn{1}{l}{\scriptsize{'.
460 &$escape_function($_). '}}';
462 $result .= '\scriptsize{'. &$escape_function($_). '}';
468 if $format eq 'latex';
470 $format_sub = $opt{format_function} if $opt{format_function};
472 map { ( $_->format eq 'C'
473 ? &{$format_sub}( $_->detail, $_ )
474 : &{$escape_function}( $_->detail )
477 qsearch ({ 'table' => 'cust_bill_pkg_detail',
478 'hashref' => { 'billpkgnum' => $self->billpkgnum },
479 'order_by' => 'ORDER BY detailnum',
481 #qsearch ( 'cust_bill_pkg_detail', { 'lineitemnum' => $self->lineitemnum });
484 =item details_header [ OPTION => VALUE ... ]
486 Returns a list representing an invoice line item detail header, if any.
487 This relies on the behavior of voip_cdr in that it expects the header
488 to be the first CSV formatted detail (as is expected by invoice generation
489 routines). Returns the empty list otherwise.
495 return '' unless defined dbdef->table('cust_bill_pkg_detail');
497 eval "use Text::CSV_XS;";
499 my $csv = new Text::CSV_XS;
502 qsearch ({ 'table' => 'cust_bill_pkg_detail',
503 'hashref' => { 'billpkgnum' => $self->billpkgnum,
506 'order_by' => 'ORDER BY detailnum LIMIT 1',
508 return() unless scalar(@detail);
509 $csv->parse($detail[0]->detail) or return ();
515 Returns a description for this line item. For typical line items, this is the
516 I<pkg> field of the corresponding B<FS::part_pkg> object (see L<FS::part_pkg>).
517 For one-shot line items and named taxes, it is the I<itemdesc> field of this
518 line item, and for generic taxes, simply returns "Tax".
525 if ( $self->pkgnum > 0 ) {
526 $self->itemdesc || $self->part_pkg->pkg;
528 my $desc = $self->itemdesc || 'Tax';
529 $desc .= ' '. $self->itemcomment if $self->itemcomment =~ /\S/;
536 Returns the amount owed (still outstanding) on this line item's setup fee,
537 which is the amount of the line item minus all payment applications (see
538 L<FS::cust_bill_pay_pkg> and credit applications (see
539 L<FS::cust_credit_bill_pkg>).
545 $self->owed('setup', @_);
550 Returns the amount owed (still outstanding) on this line item's recurring fee,
551 which is the amount of the line item minus all payment applications (see
552 L<FS::cust_bill_pay_pkg> and credit applications (see
553 L<FS::cust_credit_bill_pkg>).
559 $self->owed('recur', @_);
562 # modeled after cust_bill::owed...
564 my( $self, $field ) = @_;
565 my $balance = $self->$field();
566 $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg($field) );
567 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
568 $balance = sprintf( '%.2f', $balance );
569 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
575 my( $self, $field ) = @_;
576 my $balance = $self->$field();
577 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
578 $balance = sprintf( '%.2f', $balance );
579 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
583 sub cust_bill_pay_pkg {
584 my( $self, $field ) = @_;
585 qsearch( 'cust_bill_pay_pkg', { 'billpkgnum' => $self->billpkgnum,
586 'setuprecur' => $field,
591 sub cust_credit_bill_pkg {
592 my( $self, $field ) = @_;
593 qsearch( 'cust_credit_bill_pkg', { 'billpkgnum' => $self->billpkgnum,
594 'setuprecur' => $field,
601 Returns the number of billing units (for tax purposes) represented by this,
608 $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
616 my( $self, $value ) = @_;
617 if ( defined($value) ) {
618 $self->setfield('quantity', $value);
620 $self->getfield('quantity') || 1;
628 my( $self, $value ) = @_;
629 if ( defined($value) ) {
630 $self->setfield('unitsetup', $value);
632 $self->getfield('unitsetup') eq ''
633 ? $self->getfield('setup')
634 : $self->getfield('unitsetup');
642 my( $self, $value ) = @_;
643 if ( defined($value) ) {
644 $self->setfield('unitrecur', $value);
646 $self->getfield('unitrecur') eq ''
647 ? $self->getfield('recur')
648 : $self->getfield('unitrecur');
653 Returns a list of cust_bill_pkg objects each with no more than a single class
654 (including setup or recur) of charge.
660 # XXX this goes away with cust_bill_pkg refactor
662 my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash };
663 my %cust_bill_pkg = ();
665 $cust_bill_pkg{setup} = $cust_bill_pkg if $cust_bill_pkg->setup;
666 $cust_bill_pkg{recur} = $cust_bill_pkg if $cust_bill_pkg->recur;
669 #split setup and recur
670 if ($cust_bill_pkg->setup && $cust_bill_pkg->recur) {
671 my $cust_bill_pkg_recur = new FS::cust_bill_pkg { $cust_bill_pkg->hash };
672 $cust_bill_pkg->set('details', []);
673 $cust_bill_pkg->recur(0);
674 $cust_bill_pkg->unitrecur(0);
675 $cust_bill_pkg->type('');
676 $cust_bill_pkg_recur->setup(0);
677 $cust_bill_pkg_recur->unitsetup(0);
678 $cust_bill_pkg{recur} = $cust_bill_pkg_recur;
682 #split usage from recur
683 my $usage = sprintf( "%.2f", $cust_bill_pkg{recur}->usage )
684 if exists($cust_bill_pkg{recur});
685 warn "usage is $usage\n" if $DEBUG > 1;
687 my $cust_bill_pkg_usage =
688 new FS::cust_bill_pkg { $cust_bill_pkg{recur}->hash };
689 $cust_bill_pkg_usage->recur( $usage );
690 $cust_bill_pkg_usage->type( 'U' );
691 my $recur = sprintf( "%.2f", $cust_bill_pkg{recur}->recur - $usage );
692 $cust_bill_pkg{recur}->recur( $recur );
693 $cust_bill_pkg{recur}->type( '' );
694 $cust_bill_pkg{recur}->set('details', []);
695 $cust_bill_pkg{''} = $cust_bill_pkg_usage;
698 #subdivide usage by usage_class
699 if (exists($cust_bill_pkg{''})) {
700 foreach my $class (grep { $_ } $self->usage_classes) {
701 my $usage = sprintf( "%.2f", $cust_bill_pkg{''}->usage($class) );
702 my $cust_bill_pkg_usage =
703 new FS::cust_bill_pkg { $cust_bill_pkg{''}->hash };
704 $cust_bill_pkg_usage->recur( $usage );
705 $cust_bill_pkg_usage->set('details', []);
706 my $classless = sprintf( "%.2f", $cust_bill_pkg{''}->recur - $usage );
707 $cust_bill_pkg{''}->recur( $classless );
708 $cust_bill_pkg{$class} = $cust_bill_pkg_usage;
710 warn "Unexpected classless usage value: ". $cust_bill_pkg{''}->recur
711 if ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur < 0);
712 delete $cust_bill_pkg{''}
713 unless ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur > 0);
716 # # sort setup,recur,'', and the rest numeric && return
717 # my @result = map { $cust_bill_pkg{$_} }
718 # sort { my $ad = ($a=~/^\d+$/); my $bd = ($b=~/^\d+$/);
719 # ( $ad cmp $bd ) || ( $ad ? $a<=>$b : $b cmp $a )
721 # keys %cust_bill_pkg;
730 Returns the amount of the charge associated with usage class CLASSNUM if
731 CLASSNUM is defined. Otherwise returns the total charge associated with
737 my( $self, $classnum ) = @_;
741 if ( $self->get('details') ) {
745 grep { ref($_) && ( defined($classnum) ? $_->[3] eq $classnum : 1 ) }
746 @{ $self->get('details') };
750 my $hashref = { 'billpkgnum' => $self->billpkgnum };
751 $hashref->{ 'classnum' } = $classnum if defined($classnum);
752 @values = map { $_->amount } qsearch('cust_bill_pkg_detail', $hashref);
756 foreach ( @values ) {
764 Returns a list of usage classnums associated with this invoice line's
772 if ( $self->get('details') ) {
775 foreach my $detail ( grep { ref($_) } @{$self->get('details')} ) {
776 $seen{ $detail->[3] } = 1;
783 qsearch({ table => 'cust_bill_pkg_detail',
784 hashref => { billpkgnum => $self->billpkgnum },
785 select => 'DISTINCT classnum',
792 =item cust_bill_pkg_display [ type => TYPE ]
794 Returns an array of display information for the invoice line item optionally
799 sub cust_bill_pkg_display {
800 my ( $self, %opt ) = @_;
803 new FS::cust_bill_pkg_display { billpkgnum =>$self->billpkgnum };
805 return ( $default ) unless defined dbdef->table('cust_bill_pkg_display');#hmmm
807 my $type = $opt{type} if exists $opt{type};
810 if ( $self->get('display') ) {
811 @result = grep { defined($type) ? ($type eq $_->type) : 1 }
812 @{ $self->get('display') };
814 my $hashref = { 'billpkgnum' => $self->billpkgnum };
815 $hashref->{type} = $type if defined($type);
817 @result = qsearch ({ 'table' => 'cust_bill_pkg_display',
818 'hashref' => { 'billpkgnum' => $self->billpkgnum },
819 'order_by' => 'ORDER BY billpkgdisplaynum',
823 push @result, $default unless ( scalar(@result) || $type );
829 # reserving this name for my friends FS::{tax_rate|cust_main_county}::taxline
830 # and FS::cust_main::bill
832 sub _cust_tax_exempt_pkg {
835 $self->{Hash}->{_cust_tax_exempt_pkg} or
836 $self->{Hash}->{_cust_tax_exempt_pkg} = [];
840 =item cust_bill_pkg_tax_Xlocation
842 Returns the list of associated cust_bill_pkg_tax_location and/or
843 cust_bill_pkg_tax_rate_location objects
847 sub cust_bill_pkg_tax_Xlocation {
850 my %hash = ( 'billpkgnum' => $self->billpkgnum );
853 qsearch ( 'cust_bill_pkg_tax_location', { %hash } ),
854 qsearch ( 'cust_bill_pkg_tax_rate_location', { %hash } )
859 =item cust_bill_pkg_detail [ CLASSNUM ]
861 Returns the list of associated cust_bill_pkg_detail objects
862 The optional CLASSNUM argument will limit the details to the specified usage
867 sub cust_bill_pkg_detail {
869 my $classnum = shift || '';
871 my %hash = ( 'billpkgnum' => $self->billpkgnum );
872 $hash{classnum} = $classnum if $classnum;
874 qsearch ( 'cust_bill_pkg_detail', { %hash } ),
878 =item cust_bill_pkg_discount
880 Returns the list of associated cust_bill_pkg_discount objects.
884 sub cust_bill_pkg_discount {
886 qsearch ( 'cust_bill_pkg_discount', { 'billpkgnum' => $self->billpkgnum } );
893 setup and recur shouldn't be separate fields. There should be one "amount"
894 field and a flag to tell you if it is a setup/one-time fee or a recurring fee.
896 A line item with both should really be two separate records (preserving
897 sdate and edate for setup fees for recurring packages - that information may
898 be valuable later). Invoice generation (cust_main::bill), invoice printing
899 (cust_bill), tax reports (report_tax.cgi) and line item reports
900 (cust_bill_pkg.cgi) would need to be updated.
902 owed_setup and owed_recur could then be repaced by just owed, and
903 cust_bill::open_cust_bill_pkg and
904 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
908 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
909 from the base documentation.