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_pkg_fee;
16 use FS::cust_bill_pay_pkg;
17 use FS::cust_credit_bill_pkg;
18 use FS::cust_tax_exempt_pkg;
19 use FS::cust_bill_pkg_tax_location;
20 use FS::cust_bill_pkg_tax_rate_location;
21 use FS::cust_tax_adjustment;
22 use FS::cust_bill_pkg_void;
23 use FS::cust_bill_pkg_detail_void;
24 use FS::cust_bill_pkg_display_void;
25 use FS::cust_bill_pkg_discount_void;
26 use FS::cust_bill_pkg_tax_location_void;
27 use FS::cust_bill_pkg_tax_rate_location_void;
28 use FS::cust_tax_exempt_pkg_void;
29 use FS::cust_bill_pkg_fee_void;
35 $me = '[FS::cust_bill_pkg]';
39 FS::cust_bill_pkg - Object methods for cust_bill_pkg records
43 use FS::cust_bill_pkg;
45 $record = new FS::cust_bill_pkg \%hash;
46 $record = new FS::cust_bill_pkg { 'column' => 'value' };
48 $error = $record->insert;
50 $error = $record->check;
54 An FS::cust_bill_pkg object represents an invoice line item.
55 FS::cust_bill_pkg inherits from FS::Record. The following fields are
66 invoice (see L<FS::cust_bill>)
70 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)
72 =item pkgpart_override
74 optional package definition (see L<FS::part_pkg>) override
86 starting date of recurring fee
90 ending date of recurring fee
94 Line item description (overrides normal package description)
98 If not set, defaults to 1
102 If not set, defaults to setup
106 If not set, defaults to recur
110 If set to Y, indicates data should not appear as separate line item on invoice
114 sdate and edate are specified as UNIX timestamps; see L<perlfunc/"time">. Also
115 see L<Time::Local> and L<Date::Parse> for conversion functions.
123 Creates a new line item. To add the line item to the database, see
124 L<"insert">. Line items are normally created by calling the bill method of a
125 customer object (see L<FS::cust_main>).
129 sub table { 'cust_bill_pkg'; }
131 sub detail_table { 'cust_bill_pkg_detail'; }
132 sub display_table { 'cust_bill_pkg_display'; }
133 sub discount_table { 'cust_bill_pkg_discount'; }
134 #sub tax_location_table { 'cust_bill_pkg_tax_location'; }
135 #sub tax_rate_location_table { 'cust_bill_pkg_tax_rate_location'; }
136 #sub tax_exempt_pkg_table { 'cust_tax_exempt_pkg'; }
140 Adds this line item to the database. If there is an error, returns the error,
141 otherwise returns false.
148 local $SIG{HUP} = 'IGNORE';
149 local $SIG{INT} = 'IGNORE';
150 local $SIG{QUIT} = 'IGNORE';
151 local $SIG{TERM} = 'IGNORE';
152 local $SIG{TSTP} = 'IGNORE';
153 local $SIG{PIPE} = 'IGNORE';
155 my $oldAutoCommit = $FS::UID::AutoCommit;
156 local $FS::UID::AutoCommit = 0;
159 my $error = $self->SUPER::insert;
161 $dbh->rollback if $oldAutoCommit;
165 if ( $self->get('details') ) {
166 foreach my $detail ( @{$self->get('details')} ) {
167 $detail->billpkgnum($self->billpkgnum);
168 $error = $detail->insert;
170 $dbh->rollback if $oldAutoCommit;
171 return "error inserting cust_bill_pkg_detail: $error";
176 if ( $self->get('display') ) {
177 foreach my $cust_bill_pkg_display ( @{ $self->get('display') } ) {
178 $cust_bill_pkg_display->billpkgnum($self->billpkgnum);
179 $error = $cust_bill_pkg_display->insert;
181 $dbh->rollback if $oldAutoCommit;
182 return "error inserting cust_bill_pkg_display: $error";
187 if ( $self->get('discounts') ) {
188 foreach my $cust_bill_pkg_discount ( @{$self->get('discounts')} ) {
189 $cust_bill_pkg_discount->billpkgnum($self->billpkgnum);
190 $error = $cust_bill_pkg_discount->insert;
192 $dbh->rollback if $oldAutoCommit;
193 return "error inserting cust_bill_pkg_discount: $error";
198 foreach my $cust_tax_exempt_pkg ( @{$self->cust_tax_exempt_pkg} ) {
199 $cust_tax_exempt_pkg->billpkgnum($self->billpkgnum);
200 $error = $cust_tax_exempt_pkg->insert;
202 $dbh->rollback if $oldAutoCommit;
203 return "error inserting cust_tax_exempt_pkg: $error";
207 foreach my $tax_link_table (qw(cust_bill_pkg_tax_location
208 cust_bill_pkg_tax_rate_location))
210 my $tax_location = $self->get($tax_link_table) || [];
211 foreach my $link ( @$tax_location ) {
212 my $pkey = $link->primary_key;
213 next if $link->get($pkey); # don't try to double-insert
214 # This cust_bill_pkg can be linked on either side (i.e. it can be the
215 # tax or the taxed item). If the other side is already inserted,
216 # then set billpkgnum to ours, and insert the link. Otherwise,
217 # set billpkgnum to ours and pass the link off to the cust_bill_pkg
218 # on the other side, to be inserted later.
220 my $tax_cust_bill_pkg = $link->get('tax_cust_bill_pkg');
221 if ( $tax_cust_bill_pkg && $tax_cust_bill_pkg->billpkgnum ) {
222 $link->set('billpkgnum', $tax_cust_bill_pkg->billpkgnum);
223 # break circular links when doing this
224 $link->set('tax_cust_bill_pkg', '');
226 my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg');
227 if ( $taxable_cust_bill_pkg && $taxable_cust_bill_pkg->billpkgnum ) {
228 $link->set('taxable_billpkgnum', $taxable_cust_bill_pkg->billpkgnum);
229 # XXX pkgnum is zero for tax on tax; it might be better to use
230 # the underlying package?
231 $link->set('pkgnum', $taxable_cust_bill_pkg->pkgnum);
232 $link->set('locationnum', $taxable_cust_bill_pkg->tax_locationnum);
233 $link->set('taxable_cust_bill_pkg', '');
236 if ( $link->billpkgnum and $link->taxable_billpkgnum ) {
237 $error = $link->insert;
239 $dbh->rollback if $oldAutoCommit;
240 return "error inserting cust_bill_pkg_tax_location: $error";
244 $other = $link->billpkgnum ? $link->get('taxable_cust_bill_pkg')
245 : $link->get('tax_cust_bill_pkg');
246 my $link_array = $other->get('cust_bill_pkg_tax_location') || [];
247 push @$link_array, $link;
248 $other->set('cust_bill_pkg_tax_location' => $link_array);
253 # someday you will be as awesome as cust_bill_pkg_tax_location...
254 # and today is that day
255 #my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location');
256 #if ( $tax_rate_location ) {
257 # foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) {
258 # $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum);
259 # $error = $cust_bill_pkg_tax_rate_location->insert;
261 # $dbh->rollback if $oldAutoCommit;
262 # return "error inserting cust_bill_pkg_tax_rate_location: $error";
267 my $fee_links = $self->get('cust_bill_pkg_fee');
269 foreach my $link ( @$fee_links ) {
270 # very similar to cust_bill_pkg_tax_location, for obvious reasons
271 next if $link->billpkgfeenum; # don't try to double-insert
273 my $target = $link->get('cust_bill_pkg'); # the line item of the fee
274 my $base = $link->get('base_cust_bill_pkg'); # line item it was based on
276 if ( $target and $target->billpkgnum ) {
277 $link->set('billpkgnum', $target->billpkgnum);
278 # base_invnum => null indicates that the fee is based on its own
280 $link->set('base_invnum', $target->invnum) unless $link->base_invnum;
281 $link->set('cust_bill_pkg', '');
284 if ( $base and $base->billpkgnum ) {
285 $link->set('base_billpkgnum', $base->billpkgnum);
286 $link->set('base_cust_bill_pkg', '');
288 # it's based on a line item that's not yet inserted
289 my $link_array = $base->get('cust_bill_pkg_fee') || [];
290 push @$link_array, $link;
291 $base->set('cust_bill_pkg_fee' => $link_array);
292 next; # don't insert the link yet
295 $error = $link->insert;
297 $dbh->rollback if $oldAutoCommit;
298 return "error inserting cust_bill_pkg_fee: $error";
303 if ( my $fee_origin = $self->get('fee_origin') ) {
304 $fee_origin->set('billpkgnum' => $self->billpkgnum);
305 $error = $fee_origin->replace;
307 $dbh->rollback if $oldAutoCommit;
308 return "error updating fee origin record: $error";
312 my $cust_tax_adjustment = $self->get('cust_tax_adjustment');
313 if ( $cust_tax_adjustment ) {
314 $cust_tax_adjustment->billpkgnum($self->billpkgnum);
315 $error = $cust_tax_adjustment->replace;
317 $dbh->rollback if $oldAutoCommit;
318 return "error replacing cust_tax_adjustment: $error";
322 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
329 Voids this line item: deletes the line item and adds a record of the voided
330 line item to the FS::cust_bill_pkg_void table (and related tables).
336 my $reason = scalar(@_) ? shift : '';
337 my $reprocess_cdrs = scalar(@_) ? shift : '';
339 local $SIG{HUP} = 'IGNORE';
340 local $SIG{INT} = 'IGNORE';
341 local $SIG{QUIT} = 'IGNORE';
342 local $SIG{TERM} = 'IGNORE';
343 local $SIG{TSTP} = 'IGNORE';
344 local $SIG{PIPE} = 'IGNORE';
346 my $oldAutoCommit = $FS::UID::AutoCommit;
347 local $FS::UID::AutoCommit = 0;
350 my $cust_bill_pkg_void = new FS::cust_bill_pkg_void ( {
351 map { $_ => $self->get($_) } $self->fields
353 $cust_bill_pkg_void->reason($reason);
354 my $error = $cust_bill_pkg_void->insert;
356 $dbh->rollback if $oldAutoCommit;
360 foreach my $table (qw(
362 cust_bill_pkg_display
363 cust_bill_pkg_discount
364 cust_bill_pkg_tax_location
365 cust_bill_pkg_tax_rate_location
369 my %delete_args = ();
370 $delete_args{'reprocess_cdrs'} = $reprocess_cdrs
371 if $table eq 'cust_bill_pkg_detail';
373 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
375 my $vclass = 'FS::'.$table.'_void';
376 my $void = $vclass->new( {
377 map { $_ => $linked->get($_) } $linked->fields
379 my $error = $void->insert || $linked->delete(%delete_args);
381 $dbh->rollback if $oldAutoCommit;
389 $error = $self->delete;
391 $dbh->rollback if $oldAutoCommit;
395 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
410 local $SIG{HUP} = 'IGNORE';
411 local $SIG{INT} = 'IGNORE';
412 local $SIG{QUIT} = 'IGNORE';
413 local $SIG{TERM} = 'IGNORE';
414 local $SIG{TSTP} = 'IGNORE';
415 local $SIG{PIPE} = 'IGNORE';
417 my $oldAutoCommit = $FS::UID::AutoCommit;
418 local $FS::UID::AutoCommit = 0;
421 foreach my $table (qw(
423 cust_bill_pkg_display
424 cust_bill_pkg_discount
425 cust_bill_pkg_tax_location
426 cust_bill_pkg_tax_rate_location
433 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
434 my $error = $linked->delete;
436 $dbh->rollback if $oldAutoCommit;
443 foreach my $cust_tax_adjustment (
444 qsearch('cust_tax_adjustment', { billpkgnum=>$self->billpkgnum })
446 $cust_tax_adjustment->billpkgnum(''); #NULL
447 my $error = $cust_tax_adjustment->replace;
449 $dbh->rollback if $oldAutoCommit;
454 #fix the invoice amount
456 my $cust_bill = $self->cust_bill;
457 $cust_bill->charged( $cust_bill->charged - $self->setup - $self->recur );
459 #not adding a cc surcharge, but this override lets us modify charged
460 $cust_bill->{'Hash'}{'cc_surcharge_replace_hack'} = 1;
462 my $error = $cust_bill->replace
463 || $self->SUPER::delete(@_);
465 $dbh->rollback if $oldAutoCommit;
469 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
475 #alas, bin/follow-tax-rename
477 #=item replace OLD_RECORD
479 #Currently unimplemented. This would be even more of an accounting nightmare
480 #than deleteing the items. Just don't do it.
485 # return "Can't modify cust_bill_pkg records!";
490 Checks all fields to make sure this is a valid line item. If there is an
491 error, returns the error, otherwise returns false. Called by the insert
500 $self->ut_numbern('billpkgnum')
501 || $self->ut_snumber('pkgnum')
502 || $self->ut_number('invnum')
503 || $self->ut_money('setup')
504 || $self->ut_money('recur')
505 || $self->ut_numbern('sdate')
506 || $self->ut_numbern('edate')
507 || $self->ut_textn('itemdesc')
508 || $self->ut_textn('itemcomment')
509 || $self->ut_enum('hidden', [ '', 'Y' ])
511 return $error if $error;
513 $self->regularize_details;
515 #if ( $self->pkgnum != 0 ) { #allow unchecked pkgnum 0 for tax! (add to part_pkg?)
516 if ( $self->pkgnum > 0 ) { #allow -1 for non-pkg line items and 0 for tax (add to part_pkg?)
517 return "Unknown pkgnum ". $self->pkgnum
518 unless qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
521 return "Unknown invnum"
522 unless qsearchs( 'cust_bill' ,{ 'invnum' => $self->invnum } );
527 =item regularize_details
529 Converts the contents of the 'details' pseudo-field to
530 L<FS::cust_bill_pkg_detail> objects, if they aren't already.
534 sub regularize_details {
536 if ( $self->get('details') ) {
537 foreach my $detail ( @{$self->get('details')} ) {
538 if ( ref($detail) ne 'FS::cust_bill_pkg_detail' ) {
539 # then turn it into one
541 if ( ! ref($detail) ) {
542 $hash{'detail'} = $detail;
544 elsif ( ref($detail) eq 'HASH' ) {
547 elsif ( ref($detail) eq 'ARRAY' ) {
548 carp "passing invoice details as arrays is deprecated";
549 #carp "this way sucks, use a hash"; #but more useful/friendly
550 $hash{'format'} = $detail->[0];
551 $hash{'detail'} = $detail->[1];
552 $hash{'amount'} = $detail->[2];
553 $hash{'classnum'} = $detail->[3];
554 $hash{'phonenum'} = $detail->[4];
555 $hash{'accountcode'} = $detail->[5];
556 $hash{'startdate'} = $detail->[6];
557 $hash{'duration'} = $detail->[7];
558 $hash{'regionname'} = $detail->[8];
561 die "unknown detail type ". ref($detail);
563 $detail = new FS::cust_bill_pkg_detail \%hash;
565 $detail->billpkgnum($self->billpkgnum) if $self->billpkgnum;
571 =item set_exemptions TAXOBJECT, OPTIONS
573 Sets up tax exemptions. TAXOBJECT is the L<FS::cust_main_county> or
574 L<FS::tax_rate> record for the tax.
576 This will deal with the following cases:
580 =item Fully exempt customers (cust_main.tax flag) or customer classes
583 =item Customers exempt from specific named taxes (cust_main_exemption
586 =item Taxes that don't apply to setup or recurring fees
587 (cust_main_county.setuptax and recurtax, tax_rate.setuptax and recurtax).
589 =item Packages that are marked as tax-exempt (part_pkg.setuptax,
592 =item Fees that aren't marked as taxable (part_fee.taxable).
596 It does NOT deal with monthly tax exemptions, which need more context
597 than this humble little method cares to deal with.
599 OPTIONS should include "custnum" => the customer number if this tax line
600 hasn't been inserted (which it probably hasn't).
602 Returns a list of exemption objects, which will also be attached to the
603 line item as the 'cust_tax_exempt_pkg' pseudo-field. Inserting the line
604 item will insert these records as well.
613 my $part_pkg = $self->part_pkg;
614 my $part_fee = $self->part_fee;
617 my $custnum = $opt{custnum};
618 $custnum ||= $self->cust_bill->custnum if $self->cust_bill;
620 $cust_main = FS::cust_main->by_key( $custnum )
621 or die "set_exemptions can't identify customer (pass custnum option)\n";
624 my $taxable_charged = $self->setup + $self->recur;
625 return unless $taxable_charged > 0;
627 ### Fully exempt customer ###
629 my $conf = FS::Conf->new;
630 if ( $conf->exists('cust_class-tax_exempt') ) {
631 my $cust_class = $cust_main->cust_class;
632 $exempt_cust = $cust_class->tax if $cust_class;
634 $exempt_cust = $cust_main->tax;
637 ### Exemption from named tax ###
638 my $exempt_cust_taxname;
639 if ( !$exempt_cust and $tax->taxname ) {
640 $exempt_cust_taxname = $cust_main->tax_exemption($tax->taxname);
643 if ( $exempt_cust ) {
645 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
646 amount => $taxable_charged,
649 $taxable_charged = 0;
651 } elsif ( $exempt_cust_taxname ) {
653 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
654 amount => $taxable_charged,
655 exempt_cust_taxname => 'Y',
657 $taxable_charged = 0;
661 my $exempt_setup = ( ($part_fee and not $part_fee->taxable)
662 or ($part_pkg and $part_pkg->setuptax)
667 and $taxable_charged > 0 ) {
669 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
670 amount => $self->setup,
673 $taxable_charged -= $self->setup;
677 my $exempt_recur = ( ($part_fee and not $part_fee->taxable)
678 or ($part_pkg and $part_pkg->recurtax)
683 and $taxable_charged > 0 ) {
685 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
686 amount => $self->recur,
689 $taxable_charged -= $self->recur;
693 foreach (@new_exemptions) {
694 $_->set('taxnum', $tax->taxnum);
695 $_->set('taxtype', ref($tax));
698 push @{ $self->cust_tax_exempt_pkg }, @new_exemptions;
699 return @new_exemptions;
705 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
711 qsearchs( 'cust_bill', { 'invnum' => $self->invnum } );
716 Returns the customer (L<FS::cust_main> object) for this line item.
721 # required for cust_main_Mixin equivalence
722 # and use cust_bill instead of cust_pkg because this might not have a
725 my $cust_bill = $self->cust_bill or return '';
726 $cust_bill->cust_main;
729 =item previous_cust_bill_pkg
731 Returns the previous cust_bill_pkg for this package, if any.
735 sub previous_cust_bill_pkg {
737 return unless $self->sdate;
739 'table' => 'cust_bill_pkg',
740 'hashref' => { 'pkgnum' => $self->pkgnum,
741 'sdate' => { op=>'<', value=>$self->sdate },
743 'order_by' => 'ORDER BY sdate DESC LIMIT 1',
749 Returns the amount owed (still outstanding) on this line item's setup fee,
750 which is the amount of the line item minus all payment applications (see
751 L<FS::cust_bill_pay_pkg> and credit applications (see
752 L<FS::cust_credit_bill_pkg>).
758 $self->owed('setup', @_);
763 Returns the amount owed (still outstanding) on this line item's recurring fee,
764 which is the amount of the line item minus all payment applications (see
765 L<FS::cust_bill_pay_pkg> and credit applications (see
766 L<FS::cust_credit_bill_pkg>).
772 $self->owed('recur', @_);
775 # modeled after cust_bill::owed...
777 my( $self, $field ) = @_;
778 my $balance = $self->$field();
779 $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg($field) );
780 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
781 $balance = sprintf( '%.2f', $balance );
782 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
788 my( $self, $field ) = @_;
789 my $balance = $self->$field();
790 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
791 $balance = sprintf( '%.2f', $balance );
792 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
796 sub cust_bill_pay_pkg {
797 my( $self, $field ) = @_;
798 qsearch( 'cust_bill_pay_pkg', { 'billpkgnum' => $self->billpkgnum,
799 'setuprecur' => $field,
804 sub cust_credit_bill_pkg {
805 my( $self, $field ) = @_;
806 qsearch( 'cust_credit_bill_pkg', { 'billpkgnum' => $self->billpkgnum,
807 'setuprecur' => $field,
814 Returns the number of billing units (for tax purposes) represented by this,
821 $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
826 If this item has any discounts, returns a hashref in the format used
827 by L<FS::Template_Mixin/_items_cust_bill_pkg> to describe the discount(s)
828 on an invoice. This will contain the keys 'description', 'amount',
829 'ext_description' (an arrayref of text lines describing the discounts),
830 and '_is_discount' (a flag).
832 The value for 'amount' will be negative, and will be scaled for the package
840 my $d; # this will be returned.
842 my @pkg_discounts = $self->pkg_discount;
843 if (@pkg_discounts) {
844 # special case: if there are old "discount details" on this line item,
845 # don't show discount line items
846 if ( FS::cust_bill_pkg_detail->count("detail LIKE 'Includes discount%' AND billpkgnum = ?", $self->billpkgnum || 0) > 0 ) {
853 description => $self->mt('Discount'),
855 ext_description => \@ext,
856 pkgpart => $self->pkgpart,
857 feepart => $self->feepart,
858 # maybe should show quantity/unit discount?
860 foreach my $pkg_discount (@pkg_discounts) {
861 push @ext, $pkg_discount->description;
862 $d->{'amount'} -= $pkg_discount->amount;
866 # show introductory rate as a pseudo-discount
867 if (!$d) { # this will conflict with showing real discounts
868 my $part_pkg = $self->part_pkg;
869 if ( $part_pkg and $part_pkg->option('show_as_discount',1) ) {
870 my $cust_pkg = $self->cust_pkg;
871 my $intro_end = $part_pkg->intro_end($cust_pkg);
872 my $_date = $self->cust_bill->_date;
873 if ( $intro_end > $_date ) {
874 $d = $part_pkg->item_discount($cust_pkg);
880 $d->{amount} *= $self->quantity || 1;
886 =item set_display OPTION => VALUE ...
888 A helper method for I<insert>, populates the pseudo-field B<display> with
889 appropriate FS::cust_bill_pkg_display objects.
891 Options are passed as a list of name/value pairs. Options are:
893 part_pkg: FS::part_pkg object from this line item's package.
895 real_pkgpart: if this line item comes from a bundled package, the pkgpart
896 of the owning package. Otherwise the same as the part_pkg's pkgpart above.
901 my( $self, %opt ) = @_;
902 my $part_pkg = $opt{'part_pkg'};
903 my $cust_pkg = new FS::cust_pkg { pkgpart => $opt{real_pkgpart} };
905 my $conf = new FS::Conf;
907 # whether to break this down into setup/recur/usage
908 my $separate = $conf->exists('separate_usage');
910 my $usage_mandate = $part_pkg->option('usage_mandate', 'Hush!')
911 || $cust_pkg->part_pkg->option('usage_mandate', 'Hush!');
913 # or use the category from $opt{'part_pkg'} if its not bundled?
914 my $categoryname = $cust_pkg->part_pkg->categoryname;
916 # if we don't have to separate setup/recur/usage, or put this in a
917 # package-specific section, or display a usage summary, then don't
918 # even create one of these. The item will just display in the unnamed
919 # section as a single line plus details.
920 return $self->set('display', [])
921 unless $separate || $categoryname || $usage_mandate;
925 my %hash = ( 'section' => $categoryname );
927 # whether to put usage details in a separate section, and if so, which one
928 my $usage_section = $part_pkg->option('usage_section', 'Hush!')
929 || $cust_pkg->part_pkg->option('usage_section', 'Hush!');
931 # whether to show a usage summary line (total usage charges, no details)
932 my $summary = $part_pkg->option('summarize_usage', 'Hush!')
933 || $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
936 # create lines for setup and (non-usage) recur, in the main section
937 push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
938 push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
940 # display everything in a single line
941 push @display, new FS::cust_bill_pkg_display
944 # and if usage_mandate is enabled, hide details
945 # (this only works on multisection invoices...)
946 ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
950 if ($separate && $usage_section && $summary) {
951 # create a line for the usage summary in the main section
952 push @display, new FS::cust_bill_pkg_display { type => 'U',
958 if ($usage_mandate || ($usage_section && $summary) ) {
959 $hash{post_total} = 'Y';
962 if ($separate || $usage_mandate) {
963 # show call details for this line item in the usage section.
964 # if usage_mandate is on, this will display below the section subtotal.
965 # this also happens if usage is in a separate section and there's a
966 # summary in the main section, though I'm not sure why.
967 $hash{section} = $usage_section if $usage_section;
968 push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
971 $self->set('display', \@display);
977 Returns a hash: keys are "setup", "recur" or usage classnum, values are
978 FS::cust_bill_pkg objects, each with no more than a single class (setup or
985 # XXX this goes away with cust_bill_pkg refactor
986 # or at least I wish it would, but it turns out to be harder than
989 #my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash }; # wha huh?
990 my %cust_bill_pkg = ();
993 foreach my $classnum ($self->usage_classes) {
994 next if $classnum eq ''; # null-class usage is included in 'recur'
995 my $amount = $self->usage($classnum);
996 next if $amount == 0; # though if so we shouldn't be here
997 my $usage_item = FS::cust_bill_pkg->new({
1001 'taxclass' => $classnum,
1004 $cust_bill_pkg{$classnum} = $usage_item;
1005 $usage_total += $amount;
1008 foreach (qw(setup recur)) {
1009 next if ($self->get($_) == 0);
1010 my $item = FS::cust_bill_pkg->new({
1017 $item->set($_, $self->get($_));
1018 $cust_bill_pkg{$_} = $item;
1022 $cust_bill_pkg{recur}->set('recur',
1023 sprintf('%.2f', $cust_bill_pkg{recur}->get('recur') - $usage_total)
1030 =item usage CLASSNUM
1032 Returns the amount of the charge associated with usage class CLASSNUM if
1033 CLASSNUM is defined. Otherwise returns the total charge associated with
1039 my( $self, $classnum ) = @_;
1040 $self->regularize_details;
1042 if ( $self->get('details') ) {
1045 map { $_->amount || 0 }
1046 grep { !defined($classnum) or $classnum eq $_->classnum }
1047 @{ $self->get('details') }
1052 my $sql = 'SELECT SUM(COALESCE(amount,0)) FROM cust_bill_pkg_detail '.
1053 ' WHERE billpkgnum = '. $self->billpkgnum;
1054 if (defined $classnum) {
1055 if ($classnum =~ /^(\d+)$/) {
1056 $sql .= " AND classnum = $1";
1057 } elsif (defined($classnum) and $classnum eq '') {
1058 $sql .= " AND classnum IS NULL";
1062 my $sth = dbh->prepare($sql) or die dbh->errstr;
1063 $sth->execute or die $sth->errstr;
1065 return $sth->fetchrow_arrayref->[0] || 0;
1073 Returns a list of usage classnums associated with this invoice line's
1080 $self->regularize_details;
1082 if ( $self->get('details') ) {
1084 my %seen = ( map { $_->classnum => 1 } @{ $self->get('details') } );
1089 map { $_->classnum }
1090 qsearch({ table => 'cust_bill_pkg_detail',
1091 hashref => { billpkgnum => $self->billpkgnum },
1092 select => 'DISTINCT classnum',
1099 sub cust_tax_exempt_pkg {
1102 my $array = $self->{Hash}->{cust_tax_exempt_pkg} ||= [];
1105 =item cust_bill_pkg_fee
1107 Returns the list of associated cust_bill_pkg_fee objects, if this is
1112 sub cust_bill_pkg_fee {
1114 qsearch('cust_bill_pkg_fee', { billpkgnum => $self->billpkgnum });
1117 =item cust_bill_pkg_tax_Xlocation
1119 Returns the list of associated cust_bill_pkg_tax_location and/or
1120 cust_bill_pkg_tax_rate_location objects
1124 sub cust_bill_pkg_tax_Xlocation {
1127 my %hash = ( 'billpkgnum' => $self->billpkgnum );
1130 qsearch ( 'cust_bill_pkg_tax_location', { %hash } ),
1131 qsearch ( 'cust_bill_pkg_tax_rate_location', { %hash } )
1136 =item recur_show_zero
1138 Whether to show a zero recurring amount. This is true if the package or its
1139 definition has the recur_show_zero flag, and the recurring fee is actually
1140 zero for this period.
1144 sub recur_show_zero {
1145 my( $self, $what ) = @_;
1147 return 0 unless $self->get('recur') == 0 && $self->pkgnum;
1149 $self->cust_pkg->_X_show_zero('recur');
1152 =item setup_show_zero
1154 Whether to show a zero setup charge. This requires the package or its
1155 definition to have the setup_show_zero flag, but it also returns false if
1156 the package's setup date is before this line item's start date.
1160 sub setup_show_zero {
1162 return 0 unless $self->get('setup') == 0 && $self->pkgnum;
1163 my $cust_pkg = $self->cust_pkg;
1164 return 0 if ( $self->sdate || 0 ) > ( $cust_pkg->setup || 0 );
1165 return $cust_pkg->_X_show_zero('setup');
1168 =item credited [ BEFORE, AFTER, OPTIONS ]
1170 Returns the sum of credits applied to this item. Arguments are the same as
1171 owed_sql/paid_sql/credited_sql.
1177 $self->scalar_sql('SELECT '. $self->credited_sql(@_).' FROM cust_bill_pkg WHERE billpkgnum = ?', $self->billpkgnum);
1180 =item tax_locationnum
1182 Returns the L<FS::cust_location> number that this line item is in for tax
1183 purposes. For package sales, it's the package tax location; for fees,
1184 it's the customer's default service location.
1188 sub tax_locationnum {
1190 if ( $self->pkgnum ) { # normal sales
1191 return $self->cust_pkg->tax_locationnum;
1192 } elsif ( $self->feepart and $self->invnum ) { # fees
1193 return $self->cust_bill->cust_main->ship_locationnum;
1201 if ( $self->pkgnum ) { # normal sales
1202 return $self->cust_pkg->tax_location;
1203 } elsif ( $self->feepart and $self->invnum ) { # fees
1204 return $self->cust_bill->cust_main->ship_location;
1212 =head1 CLASS METHODS
1218 Returns an SQL expression for the total usage charges in details on
1224 '(SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0)
1225 FROM cust_bill_pkg_detail
1226 WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum)';
1228 sub usage_sql { $usage_sql }
1230 # this makes owed_sql, etc. much more concise
1232 my ($class, $start, $end, %opt) = @_;
1233 my $setuprecur = $opt{setuprecur} || '';
1235 $setuprecur =~ /^s/ ? 'cust_bill_pkg.setup' :
1236 $setuprecur =~ /^r/ ? 'cust_bill_pkg.recur' :
1237 'cust_bill_pkg.setup + cust_bill_pkg.recur';
1239 if ($opt{no_usage} and $charged =~ /recur/) {
1240 $charged = "$charged - $usage_sql"
1247 =item owed_sql [ BEFORE, AFTER, OPTIONS ]
1249 Returns an SQL expression for the amount owed. BEFORE and AFTER specify
1250 a date window. OPTIONS may include 'no_usage' (excludes usage charges)
1251 and 'setuprecur' (set to "setup" or "recur" to limit to one or the other).
1257 '(' . $class->charged_sql(@_) .
1258 ' - ' . $class->paid_sql(@_) .
1259 ' - ' . $class->credited_sql(@_) . ')'
1262 =item paid_sql [ BEFORE, AFTER, OPTIONS ]
1264 Returns an SQL expression for the sum of payments applied to this item.
1269 my ($class, $start, $end, %opt) = @_;
1270 my $s = $start ? "AND cust_pay._date <= $start" : '';
1271 my $e = $end ? "AND cust_pay._date > $end" : '';
1272 my $setuprecur = $opt{setuprecur} || '';
1273 $setuprecur = 'setup' if $setuprecur =~ /^s/;
1274 $setuprecur = 'recur' if $setuprecur =~ /^r/;
1275 $setuprecur &&= "AND setuprecur = '$setuprecur'";
1277 my $paid = "( SELECT COALESCE(SUM(cust_bill_pay_pkg.amount),0)
1278 FROM cust_bill_pay_pkg JOIN cust_bill_pay USING (billpaynum)
1279 JOIN cust_pay USING (paynum)
1280 WHERE cust_bill_pay_pkg.billpkgnum = cust_bill_pkg.billpkgnum
1281 $s $e $setuprecur )";
1283 if ( $opt{no_usage} ) {
1284 # cap the amount paid at the sum of non-usage charges,
1285 # minus the amount credited against non-usage charges
1287 $class->charged_sql($start, $end, %opt) . ' - ' .
1288 $class->credited_sql($start, $end, %opt).')';
1297 my ($class, $start, $end, %opt) = @_;
1298 my $s = $start ? "AND cust_credit._date <= $start" : '';
1299 my $e = $end ? "AND cust_credit._date > $end" : '';
1300 my $setuprecur = $opt{setuprecur} || '';
1301 $setuprecur = 'setup' if $setuprecur =~ /^s/;
1302 $setuprecur = 'recur' if $setuprecur =~ /^r/;
1303 $setuprecur &&= "AND setuprecur = '$setuprecur'";
1305 my $credited = "( SELECT COALESCE(SUM(cust_credit_bill_pkg.amount),0)
1306 FROM cust_credit_bill_pkg JOIN cust_credit_bill USING (creditbillnum)
1307 JOIN cust_credit USING (crednum)
1308 WHERE cust_credit_bill_pkg.billpkgnum = cust_bill_pkg.billpkgnum
1309 $s $e $setuprecur )";
1311 if ( $opt{no_usage} ) {
1312 # cap the amount credited at the sum of non-usage charges
1313 "LEAST($credited, ". $class->charged_sql($start, $end, %opt).')';
1321 sub upgrade_tax_location {
1322 # For taxes that were calculated/invoiced before cust_location refactoring
1323 # (May-June 2012), there are no cust_bill_pkg_tax_location records unless
1324 # they were calculated on a package-location basis. Create them here,
1325 # along with any necessary cust_location records and any tax exemption
1328 my ($class, %opt) = @_;
1329 # %opt may include 's' and 'e': start and end date ranges
1330 # and 'X': abort on any error, instead of just rolling back changes to
1333 my $oldAutoCommit = $FS::UID::AutoCommit;
1334 local $FS::UID::AutoCommit = 0;
1337 use FS::h_cust_main;
1338 use FS::h_cust_bill;
1340 use FS::h_cust_main_exemption;
1343 local $FS::cust_location::import = 1;
1345 my $conf = FS::Conf->new; # h_conf?
1346 return if $conf->exists('enable_taxproducts'); #don't touch this case
1347 my $use_ship = $conf->exists('tax-ship_address');
1348 my $use_pkgloc = $conf->exists('tax-pkg_address');
1350 my $date_where = '';
1352 $date_where .= " AND cust_bill._date >= $opt{s}";
1355 $date_where .= " AND cust_bill._date < $opt{e}";
1358 my $commit_each_invoice = 1 unless $opt{X};
1360 # if an invoice has either of these kinds of objects, then it doesn't
1361 # need to be upgraded...probably
1362 my $sub_has_tax_link = 'SELECT 1 FROM cust_bill_pkg_tax_location'.
1363 ' JOIN cust_bill_pkg USING (billpkgnum)'.
1364 ' WHERE cust_bill_pkg.invnum = cust_bill.invnum';
1365 my $sub_has_exempt = 'SELECT 1 FROM cust_tax_exempt_pkg'.
1366 ' JOIN cust_bill_pkg USING (billpkgnum)'.
1367 ' WHERE cust_bill_pkg.invnum = cust_bill.invnum'.
1368 ' AND exempt_monthly IS NULL';
1370 my %all_tax_names = (
1373 map { $_->taxname => 1 }
1374 qsearch('h_cust_main_county', { taxname => { op => '!=', value => '' }})
1377 my $search = FS::Cursor->new({
1378 table => 'cust_bill',
1380 extra_sql => "WHERE NOT EXISTS($sub_has_tax_link) ".
1381 "AND NOT EXISTS($sub_has_exempt) ".
1385 #print "Processing ".scalar(@invnums)." invoices...\n";
1389 while (my $cust_bill = $search->fetch) {
1390 my $invnum = $cust_bill->invnum;
1392 print STDERR "Invoice #$invnum\n";
1394 my %pkgpart_taxclass; # pkgpart => taxclass
1395 my %pkgpart_exempt_setup;
1396 my %pkgpart_exempt_recur;
1397 my $h_cust_bill = qsearchs('h_cust_bill',
1398 { invnum => $invnum,
1399 history_action => 'insert' });
1400 if (!$h_cust_bill) {
1401 warn "no insert record for invoice $invnum; skipped\n";
1402 #$date = $cust_bill->_date as a fallback?
1403 # We're trying to avoid using non-real dates (-d/-y invoice dates)
1404 # when looking up history records in other tables.
1407 my $custnum = $h_cust_bill->custnum;
1409 # Determine the address corresponding to this tax region.
1410 # It's either the bill or ship address of the customer as of the
1411 # invoice date-of-insertion. (Not necessarily the invoice date.)
1412 my $date = $h_cust_bill->history_date;
1413 my $h_cust_main = qsearchs('h_cust_main',
1414 { custnum => $custnum },
1415 FS::h_cust_main->sql_h_searchs($date)
1417 if (!$h_cust_main ) {
1418 warn "no historical address for cust#".$h_cust_bill->custnum."; skipped\n";
1420 # fallback to current $cust_main? sounds dangerous.
1423 # This is a historical customer record, so it has a historical address.
1424 # If there's no cust_location matching this custnum and address (there
1425 # probably isn't), create one.
1426 my %tax_loc; # keys are pkgnums, values are cust_location objects
1427 my $default_tax_loc;
1428 if ( $h_cust_main->bill_locationnum ) {
1429 # the location has already been upgraded
1431 $default_tax_loc = $h_cust_main->ship_location;
1433 $default_tax_loc = $h_cust_main->bill_location;
1436 $pre = 'ship_' if $use_ship and length($h_cust_main->get('ship_last'));
1437 my %hash = map { $_ => $h_cust_main->get($pre.$_) }
1438 FS::cust_main->location_fields;
1439 # not really needed for this, and often result in duplicate locations
1440 delete @hash{qw(censustract censusyear latitude longitude coord_auto)};
1442 $hash{custnum} = $h_cust_main->custnum;
1443 $default_tax_loc = FS::cust_location->new(\%hash);
1444 my $error = $default_tax_loc->find_or_insert || $default_tax_loc->disable_if_unused;
1446 warn "couldn't create historical location record for cust#".
1447 $h_cust_main->custnum.": $error\n";
1452 $exempt_cust = 1 if $h_cust_main->tax;
1454 # classify line items
1456 my %nontax_items; # taxclass => array of cust_bill_pkg
1457 foreach my $item ($h_cust_bill->cust_bill_pkg) {
1458 my $pkgnum = $item->pkgnum;
1460 if ( $pkgnum == 0 ) {
1462 push @tax_items, $item;
1465 # (pkgparts really shouldn't change, right?)
1466 my $h_cust_pkg = qsearchs('h_cust_pkg', { pkgnum => $pkgnum },
1467 FS::h_cust_pkg->sql_h_searchs($date)
1469 if ( !$h_cust_pkg ) {
1470 warn "no historical package #".$item->pkgpart."; skipped\n";
1473 my $pkgpart = $h_cust_pkg->pkgpart;
1475 if ( $use_pkgloc and $h_cust_pkg->locationnum ) {
1476 # then this package already had a locationnum assigned, and that's
1477 # the one to use for tax calculation
1478 $tax_loc{$pkgnum} = FS::cust_location->by_key($h_cust_pkg->locationnum);
1480 # use the customer's bill or ship loc, which was inserted earlier
1481 $tax_loc{$pkgnum} = $default_tax_loc;
1484 if (!exists $pkgpart_taxclass{$pkgpart}) {
1485 my $h_part_pkg = qsearchs('h_part_pkg', { pkgpart => $pkgpart },
1486 FS::h_part_pkg->sql_h_searchs($date)
1488 if ( !$h_part_pkg ) {
1489 warn "no historical package def #$pkgpart; skipped\n";
1492 $pkgpart_taxclass{$pkgpart} = $h_part_pkg->taxclass || '';
1493 $pkgpart_exempt_setup{$pkgpart} = 1 if $h_part_pkg->setuptax;
1494 $pkgpart_exempt_recur{$pkgpart} = 1 if $h_part_pkg->recurtax;
1497 # mark any exemptions that apply
1498 if ( $pkgpart_exempt_setup{$pkgpart} ) {
1499 $item->set('exempt_setup' => 1);
1502 if ( $pkgpart_exempt_recur{$pkgpart} ) {
1503 $item->set('exempt_recur' => 1);
1506 my $taxclass = $pkgpart_taxclass{ $pkgpart };
1508 $nontax_items{$taxclass} ||= [];
1509 push @{ $nontax_items{$taxclass} }, $item;
1513 printf("%d tax items: \$%.2f\n", scalar(@tax_items), map {$_->setup} @tax_items)
1516 # Get any per-customer taxname exemptions that were in effect.
1517 my %exempt_cust_taxname;
1518 foreach (keys %all_tax_names) {
1519 my $h_exemption = qsearchs('h_cust_main_exemption', {
1520 'custnum' => $custnum,
1523 FS::h_cust_main_exemption->sql_h_searchs($date, $date)
1526 $exempt_cust_taxname{ $_ } = 1;
1530 # Use a variation on the procedure in
1531 # FS::cust_main::Billing::_handle_taxes to identify taxes that apply
1533 my @loc_keys = qw( district city county state country );
1534 my %taxdef_by_name; # by name, and then by taxclass
1535 my %est_tax; # by name, and then by taxclass
1536 my %taxable_items; # by taxnum, and then an array
1538 foreach my $taxclass (keys %nontax_items) {
1539 foreach my $orig_item (@{ $nontax_items{$taxclass} }) {
1540 my $my_tax_loc = $tax_loc{ $orig_item->pkgnum };
1541 my %myhash = map { $_ => $my_tax_loc->get($pre.$_) } @loc_keys;
1542 my @elim = qw( district city county state );
1543 my @taxdefs; # because there may be several with different taxnames
1545 $myhash{taxclass} = $taxclass;
1546 @taxdefs = qsearch('cust_main_county', \%myhash);
1548 $myhash{taxclass} = '';
1549 @taxdefs = qsearch('cust_main_county', \%myhash);
1551 $myhash{ shift @elim } = '';
1552 } while scalar(@elim) and !@taxdefs;
1554 foreach my $taxdef (@taxdefs) {
1555 next if $taxdef->tax == 0;
1556 $taxdef_by_name{$taxdef->taxname}{$taxdef->taxclass} = $taxdef;
1558 $taxable_items{$taxdef->taxnum} ||= [];
1559 # clone the item so that taxdef-dependent changes don't
1560 # change it for other taxdefs
1561 my $item = FS::cust_bill_pkg->new({ $orig_item->hash });
1563 # these flags are already set if the part_pkg declares itself exempt
1564 $item->set('exempt_setup' => 1) if $taxdef->setuptax;
1565 $item->set('exempt_recur' => 1) if $taxdef->recurtax;
1568 my $taxable = $item->setup + $item->recur;
1570 # h_cust_credit_bill_pkg?
1571 # NO. Because if these exemptions HAD been created at the time of
1572 # billing, and then a credit applied later, the exemption would
1573 # have been adjusted by the amount of the credit. So we adjust
1574 # the taxable amount before creating the exemption.
1575 # But don't deduct the credit from taxable, because the tax was
1576 # calculated before the credit was applied.
1577 foreach my $f (qw(setup recur)) {
1578 my $credited = FS::Record->scalar_sql(
1579 "SELECT SUM(amount) FROM cust_credit_bill_pkg ".
1580 "WHERE billpkgnum = ? AND setuprecur = ?",
1584 $item->set($f, $item->get($f) - $credited) if $credited;
1586 my $existing_exempt = FS::Record->scalar_sql(
1587 "SELECT SUM(amount) FROM cust_tax_exempt_pkg WHERE ".
1588 "billpkgnum = ? AND taxnum = ?",
1589 $item->billpkgnum, $taxdef->taxnum
1591 $taxable -= $existing_exempt;
1593 if ( $taxable and $exempt_cust ) {
1594 push @new_exempt, { exempt_cust => 'Y', amount => $taxable };
1597 if ( $taxable and $exempt_cust_taxname{$taxdef->taxname} ){
1598 push @new_exempt, { exempt_cust_taxname => 'Y', amount => $taxable };
1601 if ( $taxable and $item->exempt_setup ) {
1602 push @new_exempt, { exempt_setup => 'Y', amount => $item->setup };
1603 $taxable -= $item->setup;
1605 if ( $taxable and $item->exempt_recur ) {
1606 push @new_exempt, { exempt_recur => 'Y', amount => $item->recur };
1607 $taxable -= $item->recur;
1610 $item->set('taxable' => $taxable);
1611 push @{ $taxable_items{$taxdef->taxnum} }, $item
1614 # estimate the amount of tax (this is necessary because different
1615 # taxdefs with the same taxname may have different tax rates)
1616 # and sum that for each taxname/taxclass combination
1618 $est_tax{$taxdef->taxname} ||= {};
1619 $est_tax{$taxdef->taxname}{$taxdef->taxclass} ||= 0;
1620 $est_tax{$taxdef->taxname}{$taxdef->taxclass} +=
1621 $taxable * $taxdef->tax;
1623 foreach (@new_exempt) {
1624 next if $_->{amount} == 0;
1625 my $cust_tax_exempt_pkg = FS::cust_tax_exempt_pkg->new({
1627 billpkgnum => $item->billpkgnum,
1628 taxnum => $taxdef->taxnum,
1630 my $error = $cust_tax_exempt_pkg->insert;
1632 my $pkgnum = $item->pkgnum;
1633 warn "error creating tax exemption for inv$invnum pkg$pkgnum:".
1637 } #foreach @new_exempt
1640 } #foreach $taxclass
1642 # Now go through the billed taxes and match them up with the line items.
1643 TAX_ITEM: foreach my $tax_item ( @tax_items )
1645 my $taxname = $tax_item->itemdesc;
1646 $taxname = '' if $taxname eq 'Tax';
1648 if ( !exists( $taxdef_by_name{$taxname} ) ) {
1649 # then we didn't find any applicable taxes with this name
1650 warn "no definition found for tax item '$taxname', custnum $custnum\n";
1651 # possibly all of these should be "next TAX_ITEM", but whole invoices
1652 # are transaction protected and we can go back and retry them.
1655 # classname => cust_main_county
1656 my %taxdef_by_class = %{ $taxdef_by_name{$taxname} };
1658 # Divide the tax item among taxclasses, if necessary
1659 # classname => estimated tax amount
1660 my $this_est_tax = $est_tax{$taxname};
1661 if (!defined $this_est_tax) {
1662 warn "no taxable sales found for inv#$invnum, tax item '$taxname'.\n";
1665 my $est_total = sum(values %$this_est_tax);
1666 if ( $est_total == 0 ) {
1668 warn "estimated tax on invoice #$invnum is zero.\n";
1672 my $real_tax = $tax_item->setup;
1673 printf ("Distributing \$%.2f tax:\n", $real_tax);
1674 my $cents_remaining = $real_tax * 100; # for rounding error
1675 my @tax_links; # partial CBPTL hashrefs
1676 foreach my $taxclass (keys %taxdef_by_class) {
1677 my $taxdef = $taxdef_by_class{$taxclass};
1678 # these items already have "taxable" set to their charge amount
1679 # after applying any credits or exemptions
1680 my @items = @{ $taxable_items{$taxdef->taxnum} };
1681 my $subtotal = sum(map {$_->get('taxable')} @items);
1682 printf("\t$taxclass: %.2f\n", $this_est_tax->{$taxclass}/$est_total);
1684 foreach my $nontax (@items) {
1685 my $my_tax_loc = $tax_loc{ $nontax->pkgnum };
1686 my $part = int($real_tax
1688 * ($this_est_tax->{$taxclass}/$est_total)
1690 * ($nontax->get('taxable'))/$subtotal
1694 $cents_remaining -= $part;
1696 taxnum => $taxdef->taxnum,
1697 pkgnum => $nontax->pkgnum,
1698 locationnum => $my_tax_loc->locationnum,
1699 billpkgnum => $nontax->billpkgnum,
1703 } #foreach $taxclass
1704 # Distribute any leftover tax round-robin style, one cent at a time.
1706 my $nlinks = scalar(@tax_links);
1708 # ensure that it really is an integer
1709 $cents_remaining = sprintf('%.0f', $cents_remaining);
1710 while ($cents_remaining > 0) {
1711 $tax_links[$i % $nlinks]->{cents} += 1;
1716 warn "Can't create tax links--no taxable items found.\n";
1720 # Gather credit/payment applications so that we can link them
1723 qsearch( 'cust_credit_bill_pkg',
1724 { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1726 qsearch( 'cust_bill_pay_pkg',
1727 { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1731 # grab the first one
1732 my $this_unlinked = shift @unlinked;
1733 my $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1735 # Create tax links (yay!)
1736 printf("Creating %d tax links.\n",scalar(@tax_links));
1737 foreach (@tax_links) {
1738 my $link = FS::cust_bill_pkg_tax_location->new({
1739 billpkgnum => $tax_item->billpkgnum,
1740 taxtype => 'FS::cust_main_county',
1741 locationnum => $_->{locationnum},
1742 taxnum => $_->{taxnum},
1743 pkgnum => $_->{pkgnum},
1744 amount => sprintf('%.2f', $_->{cents} / 100),
1745 taxable_billpkgnum => $_->{billpkgnum},
1747 my $error = $link->insert;
1749 warn "Can't create tax link for inv#$invnum: $error\n";
1753 my $link_cents = $_->{cents};
1754 # update/create subitem links
1756 # If $this_unlinked is undef, then we've allocated all of the
1757 # credit/payment applications to the tax item. If $link_cents is 0,
1758 # then we've applied credits/payments to all of this package fraction,
1759 # so go on to the next.
1760 while ($this_unlinked and $link_cents) {
1761 # apply as much as possible of $link_amount to this credit/payment
1763 my $apply_cents = min($link_cents, $unlinked_cents);
1764 $link_cents -= $apply_cents;
1765 $unlinked_cents -= $apply_cents;
1766 # $link_cents or $unlinked_cents or both are now zero
1767 $this_unlinked->set('amount' => sprintf('%.2f',$apply_cents/100));
1768 $this_unlinked->set('billpkgtaxlocationnum' => $link->billpkgtaxlocationnum);
1769 my $pkey = $this_unlinked->primary_key; #creditbillpkgnum or billpaypkgnum
1770 if ( $this_unlinked->$pkey ) {
1771 # then it's an existing link--replace it
1772 $error = $this_unlinked->replace;
1774 $this_unlinked->insert;
1776 # what do we do with errors at this stage?
1778 warn "Error creating tax application link: $error\n";
1779 next INVOICE; # for lack of a better idea
1782 if ( $unlinked_cents == 0 ) {
1783 # then we've allocated all of this payment/credit application,
1784 # so grab the next one
1785 $this_unlinked = shift @unlinked;
1786 $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1787 } elsif ( $link_cents == 0 ) {
1788 # then we've covered all of this package tax fraction, so split
1789 # off a new application from this one
1790 $this_unlinked = $this_unlinked->new({
1791 $this_unlinked->hash,
1794 # $unlinked_cents is still what it is
1797 } #while $this_unlinked and $link_cents
1798 } #foreach (@tax_links)
1799 } #foreach $tax_item
1801 $dbh->commit if $commit_each_invoice and $oldAutoCommit;
1807 $dbh->rollback if $oldAutoCommit;
1808 die "Upgrade halted.\n" unless $commit_each_invoice;
1812 $dbh->commit if $oldAutoCommit and !$commit_each_invoice;
1817 # Return an array of hashrefs for each cust_bill_pkg_tax_location
1818 # applied to this bill for this cust_bill_pkg.pkgnum.
1821 # In some situations, this list will contain more tax records than the
1822 # ones directly related to $self->billpkgnum. The returned list contains
1823 # all records, for this bill, charged against this billpkgnum's pkgnum.
1825 # One must keep this in mind when using data returned by this method.
1827 # An unaddressed deficiency in the cust_bill_pkg_tax_location model makes
1828 # this necessary: When a linked-hidden package generates a tax/fee as a row
1829 # in cust_bill_pkg_tax_location, there is not enough information to surmise
1830 # with specificity which billpkgnum row represents the direct parent of the
1831 # the linked-hidden package's tax row. The closest we can get to this
1832 # backwards reassociation is to use the pkgnum. Therefore, when multiple
1833 # billpkgnum's appear with the same pkgnum, this method is going to return
1834 # the tax records for ALL of those billpkgnum's, not just $self->billpkgnum.
1836 # This could be addressed with an update to the model, and to the billing
1837 # routine that generates rows into cust_bill_pkg_tax_location. Perhaps a
1838 # column, link_billpkgnum or parent_billpkgnum, recording the link. I'm not
1839 # doing that now, because there would be no possible repair of data stored
1840 # historically prior to such a fix. I need _pkg_tax_list() to not be
1841 # broken for already-generated bills.
1843 # Any code you write relying on _pkg_tax_list() MUST be aware of, and
1844 # account for, the possible return of duplicated tax records returned
1845 # when method is called on multiple cust_bill_pkg_tax_location rows.
1846 # Duplicates can be identified by billpkgtaxlocationnum column.
1849 return unless $self->pkgnum;
1852 billpkgtaxlocationnum => $_->billpkgtaxlocationnum,
1853 billpkgnum => $_->billpkgnum,
1854 taxnum => $_->taxnum,
1855 amount => $_->amount,
1856 taxname => $_->taxname,
1859 table => 'cust_bill_pkg_tax_location',
1861 LEFT JOIN cust_bill_pkg
1862 ON cust_bill_pkg.billpkgnum
1863 = cust_bill_pkg_tax_location.taxable_billpkgnum
1865 select => join( ', ', (qw|
1866 cust_bill_pkg.billpkgnum
1867 cust_bill_pkg_tax_location.billpkgtaxlocationnum
1868 cust_bill_pkg_tax_location.taxnum
1869 cust_bill_pkg_tax_location.amount
1873 ' cust_bill_pkg.invnum = ' . dbh->quote( $self->invnum ) .
1875 ' cust_bill_pkg_tax_location.pkgnum = ' . dbh->quote( $self->pkgnum ),
1881 # Create a queue job to run upgrade_tax_location from January 1, 2012 to
1885 use Date::Parse 'str2time';
1888 my $upgrade = 'tax_location_2012';
1889 return if FS::upgrade_journal->is_done($upgrade);
1890 my $job = FS::queue->new({
1891 'job' => 'FS::cust_bill_pkg::upgrade_tax_location'
1893 # call it kind of like a class method, not that it matters much
1894 $job->insert($class, 's' => str2time('2012-01-01'));
1895 # if there's a customer location upgrade queued also, wait for it to
1897 my $location_job = qsearchs('queue', {
1898 job => 'FS::cust_main::Location::process_upgrade_location'
1900 if ( $location_job ) {
1901 $job->depend_insert($location_job->jobnum);
1903 # Then mark the upgrade as done, so that we don't queue the job twice
1904 # and somehow run two of them concurrently.
1905 FS::upgrade_journal->set_done($upgrade);
1906 # This upgrade now does the job of assigning taxable_billpkgnums to
1907 # cust_bill_pkg_tax_location, so set that task done also.
1908 FS::upgrade_journal->set_done('tax_location_taxable_billpkgnum');
1915 setup and recur shouldn't be separate fields. There should be one "amount"
1916 field and a flag to tell you if it is a setup/one-time fee or a recurring fee.
1918 A line item with both should really be two separate records (preserving
1919 sdate and edate for setup fees for recurring packages - that information may
1920 be valuable later). Invoice generation (cust_main::bill), invoice printing
1921 (cust_bill), tax reports (report_tax.cgi) and line item reports
1922 (cust_bill_pkg.cgi) would need to be updated.
1924 owed_setup and owed_recur could then be repaced by just owed, and
1925 cust_bill::open_cust_bill_pkg and
1926 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
1928 The upgrade procedure is pretty sketchy.
1932 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
1933 from the base documentation.