1 package FS::cust_bill_pkg;
2 use base qw( FS::TemplateItem_Mixin FS::cust_main_Mixin FS::Record );
5 use vars qw( @ISA $DEBUG $me );
7 use List::Util qw( sum min );
9 use FS::Record qw( qsearch qsearchs dbh );
12 use FS::cust_bill_pkg_detail;
13 use FS::cust_bill_pkg_display;
14 use FS::cust_bill_pkg_discount;
15 use FS::cust_bill_pay_pkg;
16 use FS::cust_credit_bill_pkg;
17 use FS::cust_tax_exempt_pkg;
18 use FS::cust_bill_pkg_tax_location;
19 use FS::cust_bill_pkg_tax_rate_location;
20 use FS::cust_tax_adjustment;
21 use FS::cust_bill_pkg_void;
22 use FS::cust_bill_pkg_detail_void;
23 use FS::cust_bill_pkg_display_void;
24 use FS::cust_bill_pkg_discount_void;
25 use FS::cust_bill_pkg_tax_location_void;
26 use FS::cust_bill_pkg_tax_rate_location_void;
27 use FS::cust_tax_exempt_pkg_void;
30 $me = '[FS::cust_bill_pkg]';
34 FS::cust_bill_pkg - Object methods for cust_bill_pkg records
38 use FS::cust_bill_pkg;
40 $record = new FS::cust_bill_pkg \%hash;
41 $record = new FS::cust_bill_pkg { 'column' => 'value' };
43 $error = $record->insert;
45 $error = $record->check;
49 An FS::cust_bill_pkg object represents an invoice line item.
50 FS::cust_bill_pkg inherits from FS::Record. The following fields are currently
61 invoice (see L<FS::cust_bill>)
65 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)
67 =item pkgpart_override
69 optional package definition (see L<FS::part_pkg>) override
81 starting date of recurring fee
85 ending date of recurring fee
89 Line item description (overrides normal package description)
93 If not set, defaults to 1
97 If not set, defaults to setup
101 If not set, defaults to recur
105 If set to Y, indicates data should not appear as separate line item on invoice
109 sdate and edate are specified as UNIX timestamps; see L<perlfunc/"time">. Also
110 see L<Time::Local> and L<Date::Parse> for conversion functions.
118 Creates a new line item. To add the line item to the database, see
119 L<"insert">. Line items are normally created by calling the bill method of a
120 customer object (see L<FS::cust_main>).
124 sub table { 'cust_bill_pkg'; }
126 sub detail_table { 'cust_bill_pkg_detail'; }
127 sub display_table { 'cust_bill_pkg_display'; }
128 sub discount_table { 'cust_bill_pkg_discount'; }
129 #sub tax_location_table { 'cust_bill_pkg_tax_location'; }
130 #sub tax_rate_location_table { 'cust_bill_pkg_tax_rate_location'; }
131 #sub tax_exempt_pkg_table { 'cust_tax_exempt_pkg'; }
135 Adds this line item to the database. If there is an error, returns the error,
136 otherwise returns false.
143 local $SIG{HUP} = 'IGNORE';
144 local $SIG{INT} = 'IGNORE';
145 local $SIG{QUIT} = 'IGNORE';
146 local $SIG{TERM} = 'IGNORE';
147 local $SIG{TSTP} = 'IGNORE';
148 local $SIG{PIPE} = 'IGNORE';
150 my $oldAutoCommit = $FS::UID::AutoCommit;
151 local $FS::UID::AutoCommit = 0;
154 my $error = $self->SUPER::insert;
156 $dbh->rollback if $oldAutoCommit;
160 if ( $self->get('details') ) {
161 foreach my $detail ( @{$self->get('details')} ) {
162 $detail->billpkgnum($self->billpkgnum);
163 $error = $detail->insert;
165 $dbh->rollback if $oldAutoCommit;
166 return "error inserting cust_bill_pkg_detail: $error";
171 if ( $self->get('display') ) {
172 foreach my $cust_bill_pkg_display ( @{ $self->get('display') } ) {
173 $cust_bill_pkg_display->billpkgnum($self->billpkgnum);
174 $error = $cust_bill_pkg_display->insert;
176 $dbh->rollback if $oldAutoCommit;
177 return "error inserting cust_bill_pkg_display: $error";
182 if ( $self->get('discounts') ) {
183 foreach my $cust_bill_pkg_discount ( @{$self->get('discounts')} ) {
184 $cust_bill_pkg_discount->billpkgnum($self->billpkgnum);
185 $error = $cust_bill_pkg_discount->insert;
187 $dbh->rollback if $oldAutoCommit;
188 return "error inserting cust_bill_pkg_discount: $error";
193 foreach my $cust_tax_exempt_pkg ( @{$self->cust_tax_exempt_pkg} ) {
194 $cust_tax_exempt_pkg->billpkgnum($self->billpkgnum);
195 $error = $cust_tax_exempt_pkg->insert;
197 $dbh->rollback if $oldAutoCommit;
198 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;
243 Voids this line item: deletes the line item and adds a record of the voided
244 line item to the FS::cust_bill_pkg_void table (and related tables).
250 my $reason = scalar(@_) ? shift : '';
252 local $SIG{HUP} = 'IGNORE';
253 local $SIG{INT} = 'IGNORE';
254 local $SIG{QUIT} = 'IGNORE';
255 local $SIG{TERM} = 'IGNORE';
256 local $SIG{TSTP} = 'IGNORE';
257 local $SIG{PIPE} = 'IGNORE';
259 my $oldAutoCommit = $FS::UID::AutoCommit;
260 local $FS::UID::AutoCommit = 0;
263 my $cust_bill_pkg_void = new FS::cust_bill_pkg_void ( {
264 map { $_ => $self->get($_) } $self->fields
266 $cust_bill_pkg_void->reason($reason);
267 my $error = $cust_bill_pkg_void->insert;
269 $dbh->rollback if $oldAutoCommit;
273 foreach my $table (qw(
275 cust_bill_pkg_display
276 cust_bill_pkg_discount
277 cust_bill_pkg_tax_location
278 cust_bill_pkg_tax_rate_location
282 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
284 my $vclass = 'FS::'.$table.'_void';
285 my $void = $vclass->new( {
286 map { $_ => $linked->get($_) } $linked->fields
288 my $error = $void->insert || $linked->delete;
290 $dbh->rollback if $oldAutoCommit;
298 $error = $self->delete;
300 $dbh->rollback if $oldAutoCommit;
304 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
319 local $SIG{HUP} = 'IGNORE';
320 local $SIG{INT} = 'IGNORE';
321 local $SIG{QUIT} = 'IGNORE';
322 local $SIG{TERM} = 'IGNORE';
323 local $SIG{TSTP} = 'IGNORE';
324 local $SIG{PIPE} = 'IGNORE';
326 my $oldAutoCommit = $FS::UID::AutoCommit;
327 local $FS::UID::AutoCommit = 0;
330 foreach my $table (qw(
332 cust_bill_pkg_display
333 cust_bill_pkg_discount
334 cust_bill_pkg_tax_location
335 cust_bill_pkg_tax_rate_location
341 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
342 my $error = $linked->delete;
344 $dbh->rollback if $oldAutoCommit;
351 foreach my $cust_tax_adjustment (
352 qsearch('cust_tax_adjustment', { billpkgnum=>$self->billpkgnum })
354 $cust_tax_adjustment->billpkgnum(''); #NULL
355 my $error = $cust_tax_adjustment->replace;
357 $dbh->rollback if $oldAutoCommit;
362 my $error = $self->SUPER::delete(@_);
364 $dbh->rollback if $oldAutoCommit;
368 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
374 #alas, bin/follow-tax-rename
376 #=item replace OLD_RECORD
378 #Currently unimplemented. This would be even more of an accounting nightmare
379 #than deleteing the items. Just don't do it.
384 # return "Can't modify cust_bill_pkg records!";
389 Checks all fields to make sure this is a valid line item. If there is an
390 error, returns the error, otherwise returns false. Called by the insert
399 $self->ut_numbern('billpkgnum')
400 || $self->ut_snumber('pkgnum')
401 || $self->ut_number('invnum')
402 || $self->ut_money('setup')
403 || $self->ut_money('recur')
404 || $self->ut_numbern('sdate')
405 || $self->ut_numbern('edate')
406 || $self->ut_textn('itemdesc')
407 || $self->ut_textn('itemcomment')
408 || $self->ut_enum('hidden', [ '', 'Y' ])
410 return $error if $error;
412 $self->regularize_details;
414 #if ( $self->pkgnum != 0 ) { #allow unchecked pkgnum 0 for tax! (add to part_pkg?)
415 if ( $self->pkgnum > 0 ) { #allow -1 for non-pkg line items and 0 for tax (add to part_pkg?)
416 return "Unknown pkgnum ". $self->pkgnum
417 unless qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
420 return "Unknown invnum"
421 unless qsearchs( 'cust_bill' ,{ 'invnum' => $self->invnum } );
426 =item regularize_details
428 Converts the contents of the 'details' pseudo-field to
429 L<FS::cust_bill_pkg_detail> objects, if they aren't already.
433 sub regularize_details {
435 if ( $self->get('details') ) {
436 foreach my $detail ( @{$self->get('details')} ) {
437 if ( ref($detail) ne 'FS::cust_bill_pkg_detail' ) {
438 # then turn it into one
440 if ( ! ref($detail) ) {
441 $hash{'detail'} = $detail;
443 elsif ( ref($detail) eq 'HASH' ) {
446 elsif ( ref($detail) eq 'ARRAY' ) {
447 carp "passing invoice details as arrays is deprecated";
448 #carp "this way sucks, use a hash"; #but more useful/friendly
449 $hash{'format'} = $detail->[0];
450 $hash{'detail'} = $detail->[1];
451 $hash{'amount'} = $detail->[2];
452 $hash{'classnum'} = $detail->[3];
453 $hash{'phonenum'} = $detail->[4];
454 $hash{'accountcode'} = $detail->[5];
455 $hash{'startdate'} = $detail->[6];
456 $hash{'duration'} = $detail->[7];
457 $hash{'regionname'} = $detail->[8];
460 die "unknown detail type ". ref($detail);
462 $detail = new FS::cust_bill_pkg_detail \%hash;
464 $detail->billpkgnum($self->billpkgnum) if $self->billpkgnum;
472 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
478 qsearchs( 'cust_bill', { 'invnum' => $self->invnum } );
481 =item previous_cust_bill_pkg
483 Returns the previous cust_bill_pkg for this package, if any.
487 sub previous_cust_bill_pkg {
489 return unless $self->sdate;
491 'table' => 'cust_bill_pkg',
492 'hashref' => { 'pkgnum' => $self->pkgnum,
493 'sdate' => { op=>'<', value=>$self->sdate },
495 'order_by' => 'ORDER BY sdate DESC LIMIT 1',
501 Returns the amount owed (still outstanding) on this line item's setup fee,
502 which is the amount of the line item minus all payment applications (see
503 L<FS::cust_bill_pay_pkg> and credit applications (see
504 L<FS::cust_credit_bill_pkg>).
510 $self->owed('setup', @_);
515 Returns the amount owed (still outstanding) on this line item's recurring fee,
516 which is the amount of the line item minus all payment applications (see
517 L<FS::cust_bill_pay_pkg> and credit applications (see
518 L<FS::cust_credit_bill_pkg>).
524 $self->owed('recur', @_);
527 # modeled after cust_bill::owed...
529 my( $self, $field ) = @_;
530 my $balance = $self->$field();
531 $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg($field) );
532 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
533 $balance = sprintf( '%.2f', $balance );
534 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
540 my( $self, $field ) = @_;
541 my $balance = $self->$field();
542 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
543 $balance = sprintf( '%.2f', $balance );
544 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
548 sub cust_bill_pay_pkg {
549 my( $self, $field ) = @_;
550 qsearch( 'cust_bill_pay_pkg', { 'billpkgnum' => $self->billpkgnum,
551 'setuprecur' => $field,
556 sub cust_credit_bill_pkg {
557 my( $self, $field ) = @_;
558 qsearch( 'cust_credit_bill_pkg', { 'billpkgnum' => $self->billpkgnum,
559 'setuprecur' => $field,
566 Returns the number of billing units (for tax purposes) represented by this,
573 $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
577 =item set_display OPTION => VALUE ...
579 A helper method for I<insert>, populates the pseudo-field B<display> with
580 appropriate FS::cust_bill_pkg_display objects.
582 Options are passed as a list of name/value pairs. Options are:
584 part_pkg: FS::part_pkg object from this line item's package.
586 real_pkgpart: if this line item comes from a bundled package, the pkgpart
587 of the owning package. Otherwise the same as the part_pkg's pkgpart above.
592 my( $self, %opt ) = @_;
593 my $part_pkg = $opt{'part_pkg'};
594 my $cust_pkg = new FS::cust_pkg { pkgpart => $opt{real_pkgpart} };
596 my $conf = new FS::Conf;
598 # whether to break this down into setup/recur/usage
599 my $separate = $conf->exists('separate_usage');
601 my $usage_mandate = $part_pkg->option('usage_mandate', 'Hush!')
602 || $cust_pkg->part_pkg->option('usage_mandate', 'Hush!');
604 # or use the category from $opt{'part_pkg'} if its not bundled?
605 my $categoryname = $cust_pkg->part_pkg->categoryname;
607 # if we don't have to separate setup/recur/usage, or put this in a
608 # package-specific section, or display a usage summary, then don't
609 # even create one of these. The item will just display in the unnamed
610 # section as a single line plus details.
611 return $self->set('display', [])
612 unless $separate || $categoryname || $usage_mandate;
616 my %hash = ( 'section' => $categoryname );
618 # whether to put usage details in a separate section, and if so, which one
619 my $usage_section = $part_pkg->option('usage_section', 'Hush!')
620 || $cust_pkg->part_pkg->option('usage_section', 'Hush!');
622 # whether to show a usage summary line (total usage charges, no details)
623 my $summary = $part_pkg->option('summarize_usage', 'Hush!')
624 || $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
627 # create lines for setup and (non-usage) recur, in the main section
628 push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
629 push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
631 # display everything in a single line
632 push @display, new FS::cust_bill_pkg_display
635 # and if usage_mandate is enabled, hide details
636 # (this only works on multisection invoices...)
637 ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
641 if ($separate && $usage_section && $summary) {
642 # create a line for the usage summary in the main section
643 push @display, new FS::cust_bill_pkg_display { type => 'U',
649 if ($usage_mandate || ($usage_section && $summary) ) {
650 $hash{post_total} = 'Y';
653 if ($separate || $usage_mandate) {
654 # show call details for this line item in the usage section.
655 # if usage_mandate is on, this will display below the section subtotal.
656 # this also happens if usage is in a separate section and there's a
657 # summary in the main section, though I'm not sure why.
658 $hash{section} = $usage_section if $usage_section;
659 push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
662 $self->set('display', \@display);
668 Returns a hash: keys are "setup", "recur" or usage classnum, values are
669 FS::cust_bill_pkg objects, each with no more than a single class (setup or
676 # XXX this goes away with cust_bill_pkg refactor
678 my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash };
679 my %cust_bill_pkg = ();
681 $cust_bill_pkg{setup} = $cust_bill_pkg if $cust_bill_pkg->setup;
682 $cust_bill_pkg{recur} = $cust_bill_pkg if $cust_bill_pkg->recur;
685 #split setup and recur
686 if ($cust_bill_pkg->setup && $cust_bill_pkg->recur) {
687 my $cust_bill_pkg_recur = new FS::cust_bill_pkg { $cust_bill_pkg->hash };
688 $cust_bill_pkg->set('details', []);
689 $cust_bill_pkg->recur(0);
690 $cust_bill_pkg->unitrecur(0);
691 $cust_bill_pkg->type('');
692 $cust_bill_pkg_recur->setup(0);
693 $cust_bill_pkg_recur->unitsetup(0);
694 $cust_bill_pkg{recur} = $cust_bill_pkg_recur;
698 #split usage from recur
699 my $usage = sprintf( "%.2f", $cust_bill_pkg{recur}->usage )
700 if exists($cust_bill_pkg{recur});
701 warn "usage is $usage\n" if $DEBUG > 1;
703 my $cust_bill_pkg_usage =
704 new FS::cust_bill_pkg { $cust_bill_pkg{recur}->hash };
705 $cust_bill_pkg_usage->recur( $usage );
706 $cust_bill_pkg_usage->type( 'U' );
707 my $recur = sprintf( "%.2f", $cust_bill_pkg{recur}->recur - $usage );
708 $cust_bill_pkg{recur}->recur( $recur );
709 $cust_bill_pkg{recur}->type( '' );
710 $cust_bill_pkg{recur}->set('details', []);
711 $cust_bill_pkg{''} = $cust_bill_pkg_usage;
714 #subdivide usage by usage_class
715 if (exists($cust_bill_pkg{''})) {
716 foreach my $class (grep { $_ } $self->usage_classes) {
717 my $usage = sprintf( "%.2f", $cust_bill_pkg{''}->usage($class) );
718 my $cust_bill_pkg_usage =
719 new FS::cust_bill_pkg { $cust_bill_pkg{''}->hash };
720 $cust_bill_pkg_usage->recur( $usage );
721 $cust_bill_pkg_usage->set('details', []);
722 my $classless = sprintf( "%.2f", $cust_bill_pkg{''}->recur - $usage );
723 $cust_bill_pkg{''}->recur( $classless );
724 $cust_bill_pkg{$class} = $cust_bill_pkg_usage;
726 warn "Unexpected classless usage value: ". $cust_bill_pkg{''}->recur
727 if ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur < 0);
728 delete $cust_bill_pkg{''}
729 unless ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur > 0);
732 # # sort setup,recur,'', and the rest numeric && return
733 # my @result = map { $cust_bill_pkg{$_} }
734 # sort { my $ad = ($a=~/^\d+$/); my $bd = ($b=~/^\d+$/);
735 # ( $ad cmp $bd ) || ( $ad ? $a<=>$b : $b cmp $a )
737 # keys %cust_bill_pkg;
746 Returns the amount of the charge associated with usage class CLASSNUM if
747 CLASSNUM is defined. Otherwise returns the total charge associated with
753 my( $self, $classnum ) = @_;
754 $self->regularize_details;
756 if ( $self->get('details') ) {
759 map { $_->amount || 0 }
760 grep { !defined($classnum) or $classnum eq $_->classnum }
761 @{ $self->get('details') }
766 my $sql = 'SELECT SUM(COALESCE(amount,0)) FROM cust_bill_pkg_detail '.
767 ' WHERE billpkgnum = '. $self->billpkgnum;
768 $sql .= " AND classnum = $classnum" if defined($classnum);
770 my $sth = dbh->prepare($sql) or die dbh->errstr;
771 $sth->execute or die $sth->errstr;
773 return $sth->fetchrow_arrayref->[0] || 0;
781 Returns a list of usage classnums associated with this invoice line's
788 $self->regularize_details;
790 if ( $self->get('details') ) {
792 my %seen = ( map { $_->classnum => 1 } @{ $self->get('details') } );
798 qsearch({ table => 'cust_bill_pkg_detail',
799 hashref => { billpkgnum => $self->billpkgnum },
800 select => 'DISTINCT classnum',
807 sub cust_tax_exempt_pkg {
810 $self->{Hash}->{cust_tax_exempt_pkg} ||= [];
813 =item cust_bill_pkg_tax_Xlocation
815 Returns the list of associated cust_bill_pkg_tax_location and/or
816 cust_bill_pkg_tax_rate_location objects
820 sub cust_bill_pkg_tax_Xlocation {
823 my %hash = ( 'billpkgnum' => $self->billpkgnum );
826 qsearch ( 'cust_bill_pkg_tax_location', { %hash } ),
827 qsearch ( 'cust_bill_pkg_tax_rate_location', { %hash } )
832 =item recur_show_zero
836 sub recur_show_zero { shift->_X_show_zero('recur'); }
837 sub setup_show_zero { shift->_X_show_zero('setup'); }
840 my( $self, $what ) = @_;
842 return 0 unless $self->$what() == 0 && $self->pkgnum;
844 $self->cust_pkg->_X_show_zero($what);
847 =item credited [ BEFORE, AFTER, OPTIONS ]
849 Returns the sum of credits applied to this item. Arguments are the same as
850 owed_sql/paid_sql/credited_sql.
856 $self->scalar_sql('SELECT '. $self->credited_sql(@_).' FROM cust_bill_pkg WHERE billpkgnum = ?', $self->billpkgnum);
867 Returns an SQL expression for the total usage charges in details on
873 '(SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0)
874 FROM cust_bill_pkg_detail
875 WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum)';
877 sub usage_sql { $usage_sql }
879 # this makes owed_sql, etc. much more concise
881 my ($class, $start, $end, %opt) = @_;
883 $opt{setuprecur} =~ /^s/ ? 'cust_bill_pkg.setup' :
884 $opt{setuprecur} =~ /^r/ ? 'cust_bill_pkg.recur' :
885 'cust_bill_pkg.setup + cust_bill_pkg.recur';
887 if ($opt{no_usage} and $charged =~ /recur/) {
888 $charged = "$charged - $usage_sql"
895 =item owed_sql [ BEFORE, AFTER, OPTIONS ]
897 Returns an SQL expression for the amount owed. BEFORE and AFTER specify
898 a date window. OPTIONS may include 'no_usage' (excludes usage charges)
899 and 'setuprecur' (set to "setup" or "recur" to limit to one or the other).
905 '(' . $class->charged_sql(@_) .
906 ' - ' . $class->paid_sql(@_) .
907 ' - ' . $class->credited_sql(@_) . ')'
910 =item paid_sql [ BEFORE, AFTER, OPTIONS ]
912 Returns an SQL expression for the sum of payments applied to this item.
917 my ($class, $start, $end, %opt) = @_;
918 my $s = $start ? "AND cust_bill_pay._date <= $start" : '';
919 my $e = $end ? "AND cust_bill_pay._date > $end" : '';
921 $opt{setuprecur} =~ /^s/ ? 'setup' :
922 $opt{setuprecur} =~ /^r/ ? 'recur' :
924 $setuprecur &&= "AND setuprecur = '$setuprecur'";
926 my $paid = "( SELECT COALESCE(SUM(cust_bill_pay_pkg.amount),0)
927 FROM cust_bill_pay_pkg JOIN cust_bill_pay USING (billpaynum)
928 WHERE cust_bill_pay_pkg.billpkgnum = cust_bill_pkg.billpkgnum
929 $s $e $setuprecur )";
931 if ( $opt{no_usage} ) {
932 # cap the amount paid at the sum of non-usage charges,
933 # minus the amount credited against non-usage charges
935 $class->charged_sql($start, $end, %opt) . ' - ' .
936 $class->credited_sql($start, $end, %opt).')';
945 my ($class, $start, $end, %opt) = @_;
946 my $s = $start ? "AND cust_credit_bill._date <= $start" : '';
947 my $e = $end ? "AND cust_credit_bill._date > $end" : '';
949 $opt{setuprecur} =~ /^s/ ? 'setup' :
950 $opt{setuprecur} =~ /^r/ ? 'recur' :
952 $setuprecur &&= "AND setuprecur = '$setuprecur'";
954 my $credited = "( SELECT COALESCE(SUM(cust_credit_bill_pkg.amount),0)
955 FROM cust_credit_bill_pkg JOIN cust_credit_bill USING (creditbillnum)
956 WHERE cust_credit_bill_pkg.billpkgnum = cust_bill_pkg.billpkgnum
957 $s $e $setuprecur )";
959 if ( $opt{no_usage} ) {
960 # cap the amount credited at the sum of non-usage charges
961 "LEAST($credited, ". $class->charged_sql($start, $end, %opt).')';
969 sub upgrade_tax_location {
970 # For taxes that were calculated/invoiced before cust_location refactoring
971 # (May-June 2012), there are no cust_bill_pkg_tax_location records unless
972 # they were calculated on a package-location basis. Create them here,
973 # along with any necessary cust_location records and any tax exemption
976 my ($class, %opt) = @_;
977 # %opt may include 's' and 'e': start and end date ranges
978 # and 'X': abort on any error, instead of just rolling back changes to
981 my $oldAutoCommit = $FS::UID::AutoCommit;
982 local $FS::UID::AutoCommit = 0;
988 use FS::h_cust_main_exemption;
991 local $FS::cust_location::import = 1;
993 my $conf = FS::Conf->new; # h_conf?
994 return if $conf->exists('enable_taxproducts'); #don't touch this case
995 my $use_ship = $conf->exists('tax-ship_address');
999 $date_where .= " AND cust_bill._date >= $opt{s}";
1002 $date_where .= " AND cust_bill._date < $opt{e}";
1005 my $commit_each_invoice = 1 unless $opt{X};
1007 # if an invoice has either of these kinds of objects, then it doesn't
1008 # need to be upgraded...probably
1009 my $sub_has_tax_link = 'SELECT 1 FROM cust_bill_pkg_tax_location'.
1010 ' JOIN cust_bill_pkg USING (billpkgnum)'.
1011 ' WHERE cust_bill_pkg.invnum = cust_bill.invnum';
1012 my $sub_has_exempt = 'SELECT 1 FROM cust_tax_exempt_pkg'.
1013 ' JOIN cust_bill_pkg USING (billpkgnum)'.
1014 ' WHERE cust_bill_pkg.invnum = cust_bill.invnum'.
1015 ' AND exempt_monthly IS NULL';
1017 my @invnums = map { $_->invnum } qsearch({
1018 select => 'cust_bill.invnum',
1019 table => 'cust_bill',
1021 extra_sql => "WHERE NOT EXISTS($sub_has_tax_link) ".
1022 "AND NOT EXISTS($sub_has_exempt) ".
1026 print "Processing ".scalar(@invnums)." invoices...\n";
1030 foreach my $invnum (@invnums) {
1032 print STDERR "Invoice #$invnum\n";
1034 my %pkgpart_taxclass; # pkgpart => taxclass
1035 my %pkgpart_exempt_setup;
1036 my %pkgpart_exempt_recur;
1037 my $h_cust_bill = qsearchs('h_cust_bill',
1038 { invnum => $invnum,
1039 history_action => 'insert' });
1040 if (!$h_cust_bill) {
1041 warn "no insert record for invoice $invnum; skipped\n";
1042 #$date = $cust_bill->_date as a fallback?
1043 # We're trying to avoid using non-real dates (-d/-y invoice dates)
1044 # when looking up history records in other tables.
1047 my $custnum = $h_cust_bill->custnum;
1049 # Determine the address corresponding to this tax region.
1050 # It's either the bill or ship address of the customer as of the
1051 # invoice date-of-insertion. (Not necessarily the invoice date.)
1052 my $date = $h_cust_bill->history_date;
1053 my $h_cust_main = qsearchs('h_cust_main',
1054 { custnum => $custnum },
1055 FS::h_cust_main->sql_h_searchs($date)
1057 if (!$h_cust_main ) {
1058 warn "no historical address for cust#".$h_cust_bill->custnum."; skipped\n";
1060 # fallback to current $cust_main? sounds dangerous.
1063 # This is a historical customer record, so it has a historical address.
1064 # If there's no cust_location matching this custnum and address (there
1065 # probably isn't), create one.
1066 $pre = 'ship_' if $use_ship and length($h_cust_main->get('ship_last'));
1067 my %hash = map { $_ => $h_cust_main->get($pre.$_) }
1068 FS::cust_main->location_fields;
1069 # not really needed for this, and often result in duplicate locations
1070 delete @hash{qw(censustract censusyear latitude longitude coord_auto)};
1072 $hash{custnum} = $h_cust_main->custnum;
1073 my $tax_loc = qsearchs('cust_location', \%hash) # unlikely
1074 || FS::cust_location->new({ %hash });
1075 if ( !$tax_loc->locationnum ) {
1076 $tax_loc->disabled('Y');
1077 my $error = $tax_loc->insert;
1079 warn "couldn't create historical location record for cust#".
1080 $h_cust_main->custnum.": $error\n";
1084 my $exempt_cust = 1 if $h_cust_main->tax;
1086 # Get any per-customer taxname exemptions that were in effect.
1087 my %exempt_cust_taxname = map {
1089 } qsearch('h_cust_main_exemption', { 'custnum' => $custnum },
1090 FS::h_cust_main_exemption->sql_h_searchs($date)
1093 # classify line items
1095 my %nontax_items; # taxclass => array of cust_bill_pkg
1096 foreach my $item ($h_cust_bill->cust_bill_pkg) {
1097 my $pkgnum = $item->pkgnum;
1099 if ( $pkgnum == 0 ) {
1101 push @tax_items, $item;
1104 # (pkgparts really shouldn't change, right?)
1105 my $h_cust_pkg = qsearchs('h_cust_pkg', { pkgnum => $pkgnum },
1106 FS::h_cust_pkg->sql_h_searchs($date)
1108 if ( !$h_cust_pkg ) {
1109 warn "no historical package #".$item->pkgpart."; skipped\n";
1112 my $pkgpart = $h_cust_pkg->pkgpart;
1114 if (!exists $pkgpart_taxclass{$pkgpart}) {
1115 my $h_part_pkg = qsearchs('h_part_pkg', { pkgpart => $pkgpart },
1116 FS::h_part_pkg->sql_h_searchs($date)
1118 if ( !$h_part_pkg ) {
1119 warn "no historical package def #$pkgpart; skipped\n";
1122 $pkgpart_taxclass{$pkgpart} = $h_part_pkg->taxclass || '';
1123 $pkgpart_exempt_setup{$pkgpart} = 1 if $h_part_pkg->setuptax;
1124 $pkgpart_exempt_recur{$pkgpart} = 1 if $h_part_pkg->recurtax;
1127 # mark any exemptions that apply
1128 if ( $pkgpart_exempt_setup{$pkgpart} ) {
1129 $item->set('exempt_setup' => 1);
1132 if ( $pkgpart_exempt_recur{$pkgpart} ) {
1133 $item->set('exempt_recur' => 1);
1136 my $taxclass = $pkgpart_taxclass{ $pkgpart };
1138 $nontax_items{$taxclass} ||= [];
1139 push @{ $nontax_items{$taxclass} }, $item;
1142 printf("%d tax items: \$%.2f\n", scalar(@tax_items), map {$_->setup} @tax_items)
1145 # Use a variation on the procedure in
1146 # FS::cust_main::Billing::_handle_taxes to identify taxes that apply
1148 my @loc_keys = qw( district city county state country );
1149 my %taxhash = map { $_ => $h_cust_main->get($pre.$_) } @loc_keys;
1150 my %taxdef_by_name; # by name, and then by taxclass
1151 my %est_tax; # by name, and then by taxclass
1152 my %taxable_items; # by taxnum, and then an array
1154 foreach my $taxclass (keys %nontax_items) {
1155 my %myhash = %taxhash;
1156 my @elim = qw( district city county state );
1157 my @taxdefs; # because there may be several with different taxnames
1159 $myhash{taxclass} = $taxclass;
1160 @taxdefs = qsearch('cust_main_county', \%myhash);
1162 $myhash{taxclass} = '';
1163 @taxdefs = qsearch('cust_main_county', \%myhash);
1165 $myhash{ shift @elim } = '';
1166 } while scalar(@elim) and !@taxdefs;
1168 print "Class '$taxclass': ". scalar(@{ $nontax_items{$taxclass} }).
1169 " items, ". scalar(@taxdefs)." tax defs found.\n";
1170 foreach my $taxdef (@taxdefs) {
1171 next if $taxdef->tax == 0;
1172 $taxdef_by_name{$taxdef->taxname}{$taxdef->taxclass} = $taxdef;
1174 $taxable_items{$taxdef->taxnum} ||= [];
1175 foreach my $orig_item (@{ $nontax_items{$taxclass} }) {
1176 # clone the item so that taxdef-dependent changes don't
1177 # change it for other taxdefs
1178 my $item = FS::cust_bill_pkg->new({ $orig_item->hash });
1180 # these flags are already set if the part_pkg declares itself exempt
1181 $item->set('exempt_setup' => 1) if $taxdef->setuptax;
1182 $item->set('exempt_recur' => 1) if $taxdef->recurtax;
1185 my $taxable = $item->setup + $item->recur;
1187 # h_cust_credit_bill_pkg?
1188 # NO. Because if these exemptions HAD been created at the time of
1189 # billing, and then a credit applied later, the exemption would
1190 # have been adjusted by the amount of the credit. So we adjust
1191 # the taxable amount before creating the exemption.
1192 # But don't deduct the credit from taxable, because the tax was
1193 # calculated before the credit was applied.
1194 foreach my $f (qw(setup recur)) {
1195 my $credited = FS::Record->scalar_sql(
1196 "SELECT SUM(amount) FROM cust_credit_bill_pkg ".
1197 "WHERE billpkgnum = ? AND setuprecur = ?",
1201 $item->set($f, $item->get($f) - $credited) if $credited;
1203 my $existing_exempt = FS::Record->scalar_sql(
1204 "SELECT SUM(amount) FROM cust_tax_exempt_pkg WHERE ".
1205 "billpkgnum = ? AND taxnum = ?",
1206 $item->billpkgnum, $taxdef->taxnum
1208 $taxable -= $existing_exempt;
1210 if ( $taxable and $exempt_cust ) {
1211 push @new_exempt, { exempt_cust => 'Y', amount => $taxable };
1214 if ( $taxable and $exempt_cust_taxname{$taxdef->taxname} ){
1215 push @new_exempt, { exempt_cust_taxname => 'Y', amount => $taxable };
1218 if ( $taxable and $item->exempt_setup ) {
1219 push @new_exempt, { exempt_setup => 'Y', amount => $item->setup };
1220 $taxable -= $item->setup;
1222 if ( $taxable and $item->exempt_recur ) {
1223 push @new_exempt, { exempt_recur => 'Y', amount => $item->recur };
1224 $taxable -= $item->recur;
1227 $item->set('taxable' => $taxable);
1228 push @{ $taxable_items{$taxdef->taxnum} }, $item
1231 # estimate the amount of tax (this is necessary because different
1232 # taxdefs with the same taxname may have different tax rates)
1233 # and sum that for each taxname/taxclass combination
1235 $est_tax{$taxdef->taxname} ||= {};
1236 $est_tax{$taxdef->taxname}{$taxdef->taxclass} ||= 0;
1237 $est_tax{$taxdef->taxname}{$taxdef->taxclass} +=
1238 $taxable * $taxdef->tax;
1240 foreach (@new_exempt) {
1241 next if $_->{amount} == 0;
1242 my $cust_tax_exempt_pkg = FS::cust_tax_exempt_pkg->new({
1244 billpkgnum => $item->billpkgnum,
1245 taxnum => $taxdef->taxnum,
1247 my $error = $cust_tax_exempt_pkg->insert;
1249 my $pkgnum = $item->pkgnum;
1250 warn "error creating tax exemption for inv$invnum pkg$pkgnum:".
1254 } #foreach @new_exempt
1257 } #foreach $taxclass
1259 # Now go through the billed taxes and match them up with the line items.
1260 TAX_ITEM: foreach my $tax_item ( @tax_items )
1262 my $taxname = $tax_item->itemdesc;
1263 $taxname = '' if $taxname eq 'Tax';
1265 if ( !exists( $taxdef_by_name{$taxname} ) ) {
1266 # then we didn't find any applicable taxes with this name
1267 warn "no definition found for tax item '$taxname'.\n".
1268 '('.join(' ', @hash{qw(country state county city district)}).")\n";
1269 # possibly all of these should be "next TAX_ITEM", but whole invoices
1270 # are transaction protected and we can go back and retry them.
1273 # classname => cust_main_county
1274 my %taxdef_by_class = %{ $taxdef_by_name{$taxname} };
1276 # Divide the tax item among taxclasses, if necessary
1277 # classname => estimated tax amount
1278 my $this_est_tax = $est_tax{$taxname};
1279 if (!defined $this_est_tax) {
1280 warn "no taxable sales found for inv#$invnum, tax item '$taxname'.\n";
1283 my $est_total = sum(values %$this_est_tax);
1284 if ( $est_total == 0 ) {
1286 warn "estimated tax on invoice #$invnum is zero.\n";
1290 my $real_tax = $tax_item->setup;
1291 printf ("Distributing \$%.2f tax:\n", $real_tax);
1292 my $cents_remaining = $real_tax * 100; # for rounding error
1293 my @tax_links; # partial CBPTL hashrefs
1294 foreach my $taxclass (keys %taxdef_by_class) {
1295 my $taxdef = $taxdef_by_class{$taxclass};
1296 # these items already have "taxable" set to their charge amount
1297 # after applying any credits or exemptions
1298 my @items = @{ $taxable_items{$taxdef->taxnum} };
1299 my $subtotal = sum(map {$_->get('taxable')} @items);
1300 printf("\t$taxclass: %.2f\n", $this_est_tax->{$taxclass}/$est_total);
1302 foreach my $nontax (@items) {
1303 my $part = int($real_tax
1305 * ($this_est_tax->{$taxclass}/$est_total)
1307 * ($nontax->get('taxable'))/$subtotal
1311 $cents_remaining -= $part;
1313 taxnum => $taxdef->taxnum,
1314 pkgnum => $nontax->pkgnum,
1318 } #foreach $taxclass
1319 # Distribute any leftover tax round-robin style, one cent at a time.
1321 my $nlinks = scalar(@tax_links);
1323 while (int($cents_remaining) > 0) {
1324 $tax_links[$i % $nlinks]->{cents} += 1;
1329 warn "Can't create tax links--no taxable items found.\n";
1333 # Gather credit/payment applications so that we can link them
1336 qsearch( 'cust_credit_bill_pkg',
1337 { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1339 qsearch( 'cust_bill_pay_pkg',
1340 { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1344 # grab the first one
1345 my $this_unlinked = shift @unlinked;
1346 my $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1348 # Create tax links (yay!)
1349 printf("Creating %d tax links.\n",scalar(@tax_links));
1350 foreach (@tax_links) {
1351 my $link = FS::cust_bill_pkg_tax_location->new({
1352 billpkgnum => $tax_item->billpkgnum,
1353 taxtype => 'FS::cust_main_county',
1354 locationnum => $tax_loc->locationnum,
1355 taxnum => $_->{taxnum},
1356 pkgnum => $_->{pkgnum},
1357 amount => sprintf('%.2f', $_->{cents} / 100),
1359 my $error = $link->insert;
1361 warn "Can't create tax link for inv#$invnum: $error\n";
1365 my $link_cents = $_->{cents};
1366 # update/create subitem links
1368 # If $this_unlinked is undef, then we've allocated all of the
1369 # credit/payment applications to the tax item. If $link_cents is 0,
1370 # then we've applied credits/payments to all of this package fraction,
1371 # so go on to the next.
1372 while ($this_unlinked and $link_cents) {
1373 # apply as much as possible of $link_amount to this credit/payment
1375 my $apply_cents = min($link_cents, $unlinked_cents);
1376 $link_cents -= $apply_cents;
1377 $unlinked_cents -= $apply_cents;
1378 # $link_cents or $unlinked_cents or both are now zero
1379 $this_unlinked->set('amount' => sprintf('%.2f',$apply_cents/100));
1380 $this_unlinked->set('billpkgtaxlocationnum' => $link->billpkgtaxlocationnum);
1381 my $pkey = $this_unlinked->primary_key; #creditbillpkgnum or billpaypkgnum
1382 if ( $this_unlinked->$pkey ) {
1383 # then it's an existing link--replace it
1384 $error = $this_unlinked->replace;
1386 $this_unlinked->insert;
1388 # what do we do with errors at this stage?
1390 warn "Error creating tax application link: $error\n";
1391 next INVOICE; # for lack of a better idea
1394 if ( $unlinked_cents == 0 ) {
1395 # then we've allocated all of this payment/credit application,
1396 # so grab the next one
1397 $this_unlinked = shift @unlinked;
1398 $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1399 } elsif ( $link_cents == 0 ) {
1400 # then we've covered all of this package tax fraction, so split
1401 # off a new application from this one
1402 $this_unlinked = $this_unlinked->new({
1403 $this_unlinked->hash,
1406 # $unlinked_cents is still what it is
1409 } #while $this_unlinked and $link_cents
1410 } #foreach (@tax_links)
1411 } #foreach $tax_item
1413 $dbh->commit if $commit_each_invoice and $oldAutoCommit;
1419 $dbh->rollback if $oldAutoCommit;
1420 die "Upgrade halted.\n" unless $commit_each_invoice;
1424 $dbh->commit if $oldAutoCommit and !$commit_each_invoice;
1429 # Create a queue job to run upgrade_tax_location from January 1, 2012 to
1433 use Date::Parse 'str2time';
1436 my $upgrade = 'tax_location_2012';
1437 return if FS::upgrade_journal->is_done($upgrade);
1438 my $job = FS::queue->new({
1439 'job' => 'FS::cust_bill_pkg::upgrade_tax_location'
1441 # call it kind of like a class method, not that it matters much
1442 $job->insert($class, 's' => str2time('2012-01-01'));
1443 # Then mark the upgrade as done, so that we don't queue the job twice
1444 # and somehow run two of them concurrently.
1445 FS::upgrade_journal->set_done($upgrade);
1452 setup and recur shouldn't be separate fields. There should be one "amount"
1453 field and a flag to tell you if it is a setup/one-time fee or a recurring fee.
1455 A line item with both should really be two separate records (preserving
1456 sdate and edate for setup fees for recurring packages - that information may
1457 be valuable later). Invoice generation (cust_main::bill), invoice printing
1458 (cust_bill), tax reports (report_tax.cgi) and line item reports
1459 (cust_bill_pkg.cgi) would need to be updated.
1461 owed_setup and owed_recur could then be repaced by just owed, and
1462 cust_bill::open_cust_bill_pkg and
1463 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
1465 The upgrade procedure is pretty sketchy.
1469 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
1470 from the base documentation.