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 fields );
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_pkg_fee;
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;
28 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";
243 my $other; # the as yet uninserted cust_bill_pkg
244 $other = $link->billpkgnum ? $link->get('taxable_cust_bill_pkg')
245 : $link->get('tax_cust_bill_pkg');
246 my $link_array = $other->get( $tax_link_table ) || [];
247 push @$link_array, $link;
248 $other->set( $tax_link_table => $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;
327 =item void [ REASON [ , REPROCESS_CDRS ] ]
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 unless (ref($reason) || !$reason) {
340 $reason = FS::reason->new_or_existing(
342 'type' => 'Invoice void',
347 local $SIG{HUP} = 'IGNORE';
348 local $SIG{INT} = 'IGNORE';
349 local $SIG{QUIT} = 'IGNORE';
350 local $SIG{TERM} = 'IGNORE';
351 local $SIG{TSTP} = 'IGNORE';
352 local $SIG{PIPE} = 'IGNORE';
354 my $oldAutoCommit = $FS::UID::AutoCommit;
355 local $FS::UID::AutoCommit = 0;
358 my $cust_bill_pkg_void = new FS::cust_bill_pkg_void ( {
359 map { $_ => $self->get($_) } $self->fields
361 $cust_bill_pkg_void->reasonnum($reason->reasonnum) if $reason;
362 my $error = $cust_bill_pkg_void->insert;
364 $dbh->rollback if $oldAutoCommit;
368 #more efficiently than below, because there could be lots
369 $self->void_cust_bill_pkg_detail($reprocess_cdrs);
371 foreach my $table (qw(
372 cust_bill_pkg_display
373 cust_bill_pkg_discount
374 cust_bill_pkg_tax_location
375 cust_bill_pkg_tax_rate_location
379 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
381 my $vclass = 'FS::'.$table.'_void';
382 my $void = $vclass->new( {
383 map { $_ => $linked->get($_) } $linked->fields
385 my $error = $void->insert || $linked->delete;
387 $dbh->rollback if $oldAutoCommit;
395 $error = $self->delete( skip_update_cust_bill_charged=>1 );
397 $dbh->rollback if $oldAutoCommit;
401 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
407 sub void_cust_bill_pkg_detail {
408 my( $self, $reprocess_cdrs ) = @_;
410 my $from_cust_bill_pkg_detail =
411 'FROM cust_bill_pkg_detail WHERE billpkgnum = ?';
412 my $where_detailnum =
413 "WHERE detailnum IN ( SELECT detailnum $from_cust_bill_pkg_detail )";
415 if ( $reprocess_cdrs ) {
416 #well, technically this could have been on other invoices / termination
417 # partners... separate flag?
419 "DELETE FROM cdr_termination
420 WHERE acctid IN ( SELECT acctid FROM cdr $where_detailnum )
426 my $setstatus = $reprocess_cdrs ? ', freesidestatus = NULL' : '';
428 "UPDATE cdr SET detailnum = NULL $setstatus $where_detailnum",
432 my $fields = join(', ', fields('cust_bill_pkg_detail_void') );
434 $self->scalar_sql("INSERT INTO cust_bill_pkg_detail_void ($fields)
435 SELECT $fields $from_cust_bill_pkg_detail",
439 $self->scalar_sql("DELETE $from_cust_bill_pkg_detail", $self->billpkgnum);
453 local $SIG{HUP} = 'IGNORE';
454 local $SIG{INT} = 'IGNORE';
455 local $SIG{QUIT} = 'IGNORE';
456 local $SIG{TERM} = 'IGNORE';
457 local $SIG{TSTP} = 'IGNORE';
458 local $SIG{PIPE} = 'IGNORE';
460 my $oldAutoCommit = $FS::UID::AutoCommit;
461 local $FS::UID::AutoCommit = 0;
464 foreach my $table (qw(
466 cust_bill_pkg_display
467 cust_bill_pkg_discount
468 cust_bill_pkg_tax_location
469 cust_bill_pkg_tax_rate_location
476 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
477 my $error = $linked->delete;
479 $dbh->rollback if $oldAutoCommit;
486 foreach my $cust_tax_adjustment (
487 qsearch('cust_tax_adjustment', { billpkgnum=>$self->billpkgnum })
489 $cust_tax_adjustment->billpkgnum(''); #NULL
490 my $error = $cust_tax_adjustment->replace;
492 $dbh->rollback if $oldAutoCommit;
497 unless ( $opt{skip_update_cust_bill_charged} ) {
499 #fix the invoice amount
501 my $cust_bill = $self->cust_bill;
502 my $charged = $cust_bill->charged - $self->setup - $self->recur;
503 $charged = sprintf('%.2f', $charged + 0.00000001 );
504 $cust_bill->charged( $charged );
506 #not adding a cc surcharge, but this override lets us modify charged
507 $cust_bill->{'Hash'}{'cc_surcharge_replace_hack'} = 1;
509 my $error = $cust_bill->replace;
511 $dbh->rollback if $oldAutoCommit;
517 my $error = $self->SUPER::delete(@_);
519 $dbh->rollback if $oldAutoCommit;
523 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
529 #alas, bin/follow-tax-rename
531 #=item replace OLD_RECORD
533 #Currently unimplemented. This would be even more of an accounting nightmare
534 #than deleteing the items. Just don't do it.
539 # return "Can't modify cust_bill_pkg records!";
544 Checks all fields to make sure this is a valid line item. If there is an
545 error, returns the error, otherwise returns false. Called by the insert
554 $self->ut_numbern('billpkgnum')
555 || $self->ut_snumber('pkgnum')
556 || $self->ut_number('invnum')
557 || $self->ut_money('setup')
558 || $self->ut_moneyn('unitsetup')
559 || $self->ut_currencyn('setup_billed_currency')
560 || $self->ut_moneyn('setup_billed_amount')
561 || $self->ut_money('recur')
562 || $self->ut_moneyn('unitrecur')
563 || $self->ut_currencyn('recur_billed_currency')
564 || $self->ut_moneyn('recur_billed_amount')
565 || $self->ut_numbern('sdate')
566 || $self->ut_numbern('edate')
567 || $self->ut_textn('itemdesc')
568 || $self->ut_textn('itemcomment')
569 || $self->ut_enum('hidden', [ '', 'Y' ])
571 return $error if $error;
573 $self->regularize_details;
575 #if ( $self->pkgnum != 0 ) { #allow unchecked pkgnum 0 for tax! (add to part_pkg?)
576 if ( $self->pkgnum > 0 ) { #allow -1 for non-pkg line items and 0 for tax (add to part_pkg?)
577 return "Unknown pkgnum ". $self->pkgnum
578 unless qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
581 return "Unknown invnum"
582 unless qsearchs( 'cust_bill' ,{ 'invnum' => $self->invnum } );
587 =item regularize_details
589 Converts the contents of the 'details' pseudo-field to
590 L<FS::cust_bill_pkg_detail> objects, if they aren't already.
594 sub regularize_details {
596 if ( $self->get('details') ) {
597 foreach my $detail ( @{$self->get('details')} ) {
598 if ( ref($detail) ne 'FS::cust_bill_pkg_detail' ) {
599 # then turn it into one
601 if ( ! ref($detail) ) {
602 $hash{'detail'} = $detail;
604 elsif ( ref($detail) eq 'HASH' ) {
607 elsif ( ref($detail) eq 'ARRAY' ) {
608 carp "passing invoice details as arrays is deprecated";
609 #carp "this way sucks, use a hash"; #but more useful/friendly
610 $hash{'format'} = $detail->[0];
611 $hash{'detail'} = $detail->[1];
612 $hash{'amount'} = $detail->[2];
613 $hash{'classnum'} = $detail->[3];
614 $hash{'phonenum'} = $detail->[4];
615 $hash{'accountcode'} = $detail->[5];
616 $hash{'startdate'} = $detail->[6];
617 $hash{'duration'} = $detail->[7];
618 $hash{'regionname'} = $detail->[8];
621 die "unknown detail type ". ref($detail);
623 $detail = new FS::cust_bill_pkg_detail \%hash;
625 $detail->billpkgnum($self->billpkgnum) if $self->billpkgnum;
631 =item set_exemptions TAXOBJECT, OPTIONS
633 Sets up tax exemptions. TAXOBJECT is the L<FS::cust_main_county> or
634 L<FS::tax_rate> record for the tax.
636 This will deal with the following cases:
640 =item Fully exempt customers (cust_main.tax flag) or customer classes
643 =item Customers exempt from specific named taxes (cust_main_exemption
646 =item Taxes that don't apply to setup or recurring fees
647 (cust_main_county.setuptax and recurtax, tax_rate.setuptax and recurtax).
649 =item Packages that are marked as tax-exempt (part_pkg.setuptax,
652 =item Fees that aren't marked as taxable (part_fee.taxable).
656 It does NOT deal with monthly tax exemptions, which need more context
657 than this humble little method cares to deal with.
659 OPTIONS should include "custnum" => the customer number if this tax line
660 hasn't been inserted (which it probably hasn't).
662 Returns a list of exemption objects, which will also be attached to the
663 line item as the 'cust_tax_exempt_pkg' pseudo-field. Inserting the line
664 item will insert these records as well.
673 my $part_pkg = $self->part_pkg;
674 my $part_fee = $self->part_fee;
677 my $custnum = $opt{custnum};
678 $custnum ||= $self->cust_bill->custnum if $self->cust_bill;
680 $cust_main = FS::cust_main->by_key( $custnum )
681 or die "set_exemptions can't identify customer (pass custnum option)\n";
684 my $taxable_charged = $self->setup + $self->recur;
685 return unless $taxable_charged > 0;
687 ### Fully exempt customer ###
689 my $conf = FS::Conf->new;
690 if ( $conf->exists('cust_class-tax_exempt') ) {
691 my $cust_class = $cust_main->cust_class;
692 $exempt_cust = $cust_class->tax if $cust_class;
694 $exempt_cust = $cust_main->tax;
697 ### Exemption from named tax ###
698 my $exempt_cust_taxname;
699 if ( !$exempt_cust and $tax->taxname ) {
700 $exempt_cust_taxname = $cust_main->tax_exemption($tax->taxname);
703 if ( $exempt_cust ) {
705 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
706 amount => $taxable_charged,
709 $taxable_charged = 0;
711 } elsif ( $exempt_cust_taxname ) {
713 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
714 amount => $taxable_charged,
715 exempt_cust_taxname => 'Y',
717 $taxable_charged = 0;
721 my $exempt_setup = ( ($part_fee and not $part_fee->taxable)
722 or ($part_pkg and $part_pkg->setuptax)
727 and $taxable_charged > 0 ) {
729 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
730 amount => $self->setup,
733 $taxable_charged -= $self->setup;
737 my $exempt_recur = ( ($part_fee and not $part_fee->taxable)
738 or ($part_pkg and $part_pkg->recurtax)
743 and $taxable_charged > 0 ) {
745 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
746 amount => $self->recur,
749 $taxable_charged -= $self->recur;
753 foreach (@new_exemptions) {
754 $_->set('taxnum', $tax->taxnum);
755 $_->set('taxtype', ref($tax));
758 push @{ $self->cust_tax_exempt_pkg }, @new_exemptions;
759 return @new_exemptions;
765 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
769 Returns the customer (L<FS::cust_main> object) for this line item.
774 carp "->cust_main called" if $DEBUG;
775 # required for cust_main_Mixin equivalence
776 # and use cust_bill instead of cust_pkg because this might not have a
779 my $cust_bill = $self->cust_bill or return '';
780 $cust_bill->cust_main;
783 =item previous_cust_bill_pkg
785 Returns the previous cust_bill_pkg for this package, if any.
789 sub previous_cust_bill_pkg {
791 return unless $self->sdate;
793 'table' => 'cust_bill_pkg',
794 'hashref' => { 'pkgnum' => $self->pkgnum,
795 'sdate' => { op=>'<', value=>$self->sdate },
797 'order_by' => 'ORDER BY sdate DESC LIMIT 1',
803 Returns the amount owed (still outstanding) on this line item's setup fee,
804 which is the amount of the line item minus all payment applications (see
805 L<FS::cust_bill_pay_pkg> and credit applications (see
806 L<FS::cust_credit_bill_pkg>).
812 $self->owed('setup', @_);
817 Returns the amount owed (still outstanding) on this line item's recurring fee,
818 which is the amount of the line item minus all payment applications (see
819 L<FS::cust_bill_pay_pkg> and credit applications (see
820 L<FS::cust_credit_bill_pkg>).
826 $self->owed('recur', @_);
829 # modeled after cust_bill::owed...
831 my( $self, $field ) = @_;
832 my $balance = $self->$field();
833 $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg($field) );
834 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
835 $balance = sprintf( '%.2f', $balance );
836 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
842 my( $self, $field ) = @_;
843 my $balance = $self->$field();
844 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
845 $balance = sprintf( '%.2f', $balance );
846 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
850 sub cust_bill_pay_pkg {
851 my( $self, $field ) = @_;
852 qsearch( 'cust_bill_pay_pkg', { 'billpkgnum' => $self->billpkgnum,
853 'setuprecur' => $field,
858 sub cust_credit_bill_pkg {
859 my( $self, $field ) = @_;
860 qsearch( 'cust_credit_bill_pkg', { 'billpkgnum' => $self->billpkgnum,
861 'setuprecur' => $field,
868 Returns the number of billing units (for tax purposes) represented by this,
875 $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
880 If this item has any discounts, returns a hashref in the format used
881 by L<FS::Template_Mixin/_items_cust_bill_pkg> to describe the discount(s)
882 on an invoice. This will contain the keys 'description', 'amount',
883 'ext_description' (an arrayref of text lines describing the discounts),
884 and '_is_discount' (a flag).
886 The value for 'amount' will be negative, and will be scaled for the package
895 my $d; # this will be returned.
897 my @pkg_discounts = $self->pkg_discount;
898 if (@pkg_discounts) {
899 # special case: if there are old "discount details" on this line item,
900 # don't show discount line items
901 if ( FS::cust_bill_pkg_detail->count("detail LIKE 'Includes discount%' AND billpkgnum = ?", $self->billpkgnum || 0) > 0 ) {
908 description => $self->mt('Discount'),
911 ext_description => \@ext,
912 pkgpart => $self->pkgpart,
913 feepart => $self->feepart,
914 # maybe should show quantity/unit discount?
916 foreach my $pkg_discount (@pkg_discounts) {
917 push @ext, $pkg_discount->description;
918 my $setuprecur = $pkg_discount->cust_pkg_discount->setuprecur;
919 $d->{$setuprecur.'_amount'} -= $pkg_discount->amount;
923 # show introductory rate as a pseudo-discount
924 if (!$d) { # this will conflict with showing real discounts
925 my $part_pkg = $self->part_pkg;
926 if ( $part_pkg and $part_pkg->option('show_as_discount',1) ) {
927 my $cust_pkg = $self->cust_pkg;
928 my $intro_end = $part_pkg->intro_end($cust_pkg);
929 my $_date = $self->cust_bill->_date;
930 if ( $intro_end > $_date ) {
931 $d = $part_pkg->item_discount($cust_pkg);
937 $d->{setup_amount} *= $self->quantity || 1; # ??
938 $d->{recur_amount} *= $self->quantity || 1; # ??
944 =item set_display OPTION => VALUE ...
946 A helper method for I<insert>, populates the pseudo-field B<display> with
947 appropriate FS::cust_bill_pkg_display objects.
949 Options are passed as a list of name/value pairs. Options are:
951 part_pkg: FS::part_pkg object from this line item's package.
953 real_pkgpart: if this line item comes from a bundled package, the pkgpart
954 of the owning package. Otherwise the same as the part_pkg's pkgpart above.
959 my( $self, %opt ) = @_;
960 my $part_pkg = $opt{'part_pkg'};
961 my $cust_pkg = new FS::cust_pkg { pkgpart => $opt{real_pkgpart} };
963 my $conf = new FS::Conf;
965 # whether to break this down into setup/recur/usage
966 my $separate = $conf->exists('separate_usage');
968 my $usage_mandate = $part_pkg->option('usage_mandate', 'Hush!')
969 || $cust_pkg->part_pkg->option('usage_mandate', 'Hush!');
971 # or use the category from $opt{'part_pkg'} if its not bundled?
972 my $categoryname = $cust_pkg->part_pkg->categoryname;
974 # if we don't have to separate setup/recur/usage, or put this in a
975 # package-specific section, or display a usage summary, then don't
976 # even create one of these. The item will just display in the unnamed
977 # section as a single line plus details.
978 return $self->set('display', [])
979 unless $separate || $categoryname || $usage_mandate;
983 my %hash = ( 'section' => $categoryname );
985 # whether to put usage details in a separate section, and if so, which one
986 my $usage_section = $part_pkg->option('usage_section', 'Hush!')
987 || $cust_pkg->part_pkg->option('usage_section', 'Hush!');
989 # whether to show a usage summary line (total usage charges, no details)
990 my $summary = $part_pkg->option('summarize_usage', 'Hush!')
991 || $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
994 # create lines for setup and (non-usage) recur, in the main section
995 push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
996 push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
998 # display everything in a single line
999 push @display, new FS::cust_bill_pkg_display
1002 # and if usage_mandate is enabled, hide details
1003 # (this only works on multisection invoices...)
1004 ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
1008 if ($separate && $usage_section && $summary) {
1009 # create a line for the usage summary in the main section
1010 push @display, new FS::cust_bill_pkg_display { type => 'U',
1016 if ($usage_mandate || ($usage_section && $summary) ) {
1017 $hash{post_total} = 'Y';
1020 if ($separate || $usage_mandate) {
1021 # show call details for this line item in the usage section.
1022 # if usage_mandate is on, this will display below the section subtotal.
1023 # this also happens if usage is in a separate section and there's a
1024 # summary in the main section, though I'm not sure why.
1025 $hash{section} = $usage_section if $usage_section;
1026 push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
1029 $self->set('display', \@display);
1035 Returns a hash: keys are "setup", "recur" or usage classnum, values are
1036 FS::cust_bill_pkg objects, each with no more than a single class (setup or
1043 # XXX this goes away with cust_bill_pkg refactor
1044 # or at least I wish it would, but it turns out to be harder than
1047 #my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash }; # wha huh?
1048 my %cust_bill_pkg = ();
1051 foreach my $classnum ($self->usage_classes) {
1052 my $amount = $self->usage($classnum);
1053 next if $amount == 0; # though if so we shouldn't be here
1054 my $usage_item = FS::cust_bill_pkg->new({
1058 'taxclass' => $classnum,
1061 $cust_bill_pkg{$classnum} = $usage_item;
1062 $usage_total += $amount;
1065 foreach (qw(setup recur)) {
1066 next if ($self->get($_) == 0);
1067 my $item = FS::cust_bill_pkg->new({
1074 $item->set($_, $self->get($_));
1075 $cust_bill_pkg{$_} = $item;
1079 $cust_bill_pkg{recur}->set('recur',
1080 sprintf('%.2f', $cust_bill_pkg{recur}->get('recur') - $usage_total)
1087 =item usage CLASSNUM
1089 Returns the amount of the charge associated with usage class CLASSNUM if
1090 CLASSNUM is defined. Otherwise returns the total charge associated with
1096 my( $self, $classnum ) = @_;
1097 $self->regularize_details;
1099 if ( $self->get('details') ) {
1102 map { $_->amount || 0 }
1103 grep { !defined($classnum) or $classnum eq $_->classnum }
1104 @{ $self->get('details') }
1109 my $sql = 'SELECT SUM(COALESCE(amount,0)) FROM cust_bill_pkg_detail '.
1110 ' WHERE billpkgnum = '. $self->billpkgnum;
1111 if (defined $classnum) {
1112 if ($classnum =~ /^(\d+)$/) {
1113 $sql .= " AND classnum = $1";
1114 } elsif ($classnum eq '') {
1115 $sql .= " AND classnum IS NULL";
1119 my $sth = dbh->prepare($sql) or die dbh->errstr;
1120 $sth->execute or die $sth->errstr;
1122 return $sth->fetchrow_arrayref->[0] || 0;
1130 Returns a list of usage classnums associated with this invoice line's
1137 $self->regularize_details;
1139 if ( $self->get('details') ) {
1141 my %seen = ( map { $_->classnum => 1 } @{ $self->get('details') } );
1146 map { $_->classnum }
1147 qsearch({ table => 'cust_bill_pkg_detail',
1148 hashref => { billpkgnum => $self->billpkgnum },
1149 select => 'DISTINCT classnum',
1156 sub cust_tax_exempt_pkg {
1159 my $array = $self->{Hash}->{cust_tax_exempt_pkg} ||= [];
1162 =item cust_bill_pkg_tax_Xlocation
1164 Returns the list of associated cust_bill_pkg_tax_location and/or
1165 cust_bill_pkg_tax_rate_location objects
1169 sub cust_bill_pkg_tax_Xlocation {
1172 my %hash = ( 'billpkgnum' => $self->billpkgnum );
1175 qsearch ( 'cust_bill_pkg_tax_location', { %hash } ),
1176 qsearch ( 'cust_bill_pkg_tax_rate_location', { %hash } )
1181 =item recur_show_zero
1183 Whether to show a zero recurring amount. This is true if the package or its
1184 definition has the recur_show_zero flag, and the recurring fee is actually
1185 zero for this period.
1189 sub recur_show_zero {
1190 my( $self, $what ) = @_;
1192 return 0 unless $self->get('recur') == 0 && $self->pkgnum;
1194 $self->cust_pkg->_X_show_zero('recur');
1197 =item setup_show_zero
1199 Whether to show a zero setup charge. This requires the package or its
1200 definition to have the setup_show_zero flag, but it also returns false if
1201 the package's setup date is before this line item's start date.
1205 sub setup_show_zero {
1207 return 0 unless $self->get('setup') == 0 && $self->pkgnum;
1208 my $cust_pkg = $self->cust_pkg;
1209 return 0 if ( $self->sdate || 0 ) > ( $cust_pkg->setup || 0 );
1210 return $cust_pkg->_X_show_zero('setup');
1213 =item credited [ BEFORE, AFTER, OPTIONS ]
1215 Returns the sum of credits applied to this item. Arguments are the same as
1216 owed_sql/paid_sql/credited_sql.
1222 $self->scalar_sql('SELECT '. $self->credited_sql(@_).' FROM cust_bill_pkg WHERE billpkgnum = ?', $self->billpkgnum);
1225 =item tax_locationnum
1227 Returns the L<FS::cust_location> number that this line item is in for tax
1228 purposes. For package sales, it's the package tax location; for fees,
1229 it's the customer's default service location.
1233 sub tax_locationnum {
1235 if ( $self->pkgnum ) { # normal sales
1236 return $self->cust_pkg->tax_locationnum;
1237 } elsif ( $self->feepart ) { # fees
1238 my $custnum = $self->fee_origin->custnum;
1240 return FS::cust_main->by_key($custnum)->ship_locationnum;
1249 if ( $self->pkgnum ) { # normal sales
1250 return $self->cust_pkg->tax_location;
1251 } elsif ( $self->feepart ) { # fees
1252 my $fee_origin = $self->fee_origin;
1253 if ( $fee_origin ) {
1254 my $custnum = $fee_origin->custnum;
1256 return FS::cust_main->by_key($custnum)->ship_location;
1266 =head1 CLASS METHODS
1272 Returns an SQL expression for the total usage charges in details on
1278 '(SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0)
1279 FROM cust_bill_pkg_detail
1280 WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum)';
1282 sub usage_sql { $usage_sql }
1284 # this makes owed_sql, etc. much more concise
1286 my ($class, $start, $end, %opt) = @_;
1287 my $setuprecur = $opt{setuprecur} || '';
1289 $setuprecur =~ /^s/ ? 'cust_bill_pkg.setup' :
1290 $setuprecur =~ /^r/ ? 'cust_bill_pkg.recur' :
1291 'cust_bill_pkg.setup + cust_bill_pkg.recur';
1293 if ($opt{no_usage} and $charged =~ /recur/) {
1294 $charged = "$charged - $usage_sql"
1301 =item owed_sql [ BEFORE, AFTER, OPTIONS ]
1303 Returns an SQL expression for the amount owed. BEFORE and AFTER specify
1304 a date window. OPTIONS may include 'no_usage' (excludes usage charges)
1305 and 'setuprecur' (set to "setup" or "recur" to limit to one or the other).
1311 '(' . $class->charged_sql(@_) .
1312 ' - ' . $class->paid_sql(@_) .
1313 ' - ' . $class->credited_sql(@_) . ')'
1316 =item paid_sql [ BEFORE, AFTER, OPTIONS ]
1318 Returns an SQL expression for the sum of payments applied to this item.
1323 my ($class, $start, $end, %opt) = @_;
1324 my $s = $start ? "AND cust_pay._date <= $start" : '';
1325 my $e = $end ? "AND cust_pay._date > $end" : '';
1326 my $setuprecur = $opt{setuprecur} || '';
1327 $setuprecur = 'setup' if $setuprecur =~ /^s/;
1328 $setuprecur = 'recur' if $setuprecur =~ /^r/;
1329 $setuprecur &&= "AND setuprecur = '$setuprecur'";
1331 my $paid = "( SELECT COALESCE(SUM(cust_bill_pay_pkg.amount),0)
1332 FROM cust_bill_pay_pkg JOIN cust_bill_pay USING (billpaynum)
1333 JOIN cust_pay USING (paynum)
1334 WHERE cust_bill_pay_pkg.billpkgnum = cust_bill_pkg.billpkgnum
1335 $s $e $setuprecur )";
1337 if ( $opt{no_usage} ) {
1338 # cap the amount paid at the sum of non-usage charges,
1339 # minus the amount credited against non-usage charges
1341 $class->charged_sql($start, $end, %opt) . ' - ' .
1342 $class->credited_sql($start, $end, %opt).')';
1351 my ($class, $start, $end, %opt) = @_;
1352 my $s = $start ? "AND cust_credit._date <= $start" : '';
1353 my $e = $end ? "AND cust_credit._date > $end" : '';
1354 my $setuprecur = $opt{setuprecur} || '';
1355 $setuprecur = 'setup' if $setuprecur =~ /^s/;
1356 $setuprecur = 'recur' if $setuprecur =~ /^r/;
1357 $setuprecur &&= "AND setuprecur = '$setuprecur'";
1359 my $credited = "( SELECT COALESCE(SUM(cust_credit_bill_pkg.amount),0)
1360 FROM cust_credit_bill_pkg JOIN cust_credit_bill USING (creditbillnum)
1361 JOIN cust_credit USING (crednum)
1362 WHERE cust_credit_bill_pkg.billpkgnum = cust_bill_pkg.billpkgnum
1363 $s $e $setuprecur )";
1365 if ( $opt{no_usage} ) {
1366 # cap the amount credited at the sum of non-usage charges
1367 "LEAST($credited, ". $class->charged_sql($start, $end, %opt).')';
1375 sub upgrade_tax_location {
1376 # For taxes that were calculated/invoiced before cust_location refactoring
1377 # (May-June 2012), there are no cust_bill_pkg_tax_location records unless
1378 # they were calculated on a package-location basis. Create them here,
1379 # along with any necessary cust_location records and any tax exemption
1382 my ($class, %opt) = @_;
1383 # %opt may include 's' and 'e': start and end date ranges
1384 # and 'X': abort on any error, instead of just rolling back changes to
1387 my $oldAutoCommit = $FS::UID::AutoCommit;
1388 local $FS::UID::AutoCommit = 0;
1391 use FS::h_cust_main;
1392 use FS::h_cust_bill;
1394 use FS::h_cust_main_exemption;
1397 local $FS::cust_location::import = 1;
1399 my $conf = FS::Conf->new; # h_conf?
1400 return if $conf->config('tax_data_vendor'); #don't touch this case
1401 my $use_ship = $conf->exists('tax-ship_address');
1402 my $use_pkgloc = $conf->exists('tax-pkg_address');
1404 my $date_where = '';
1406 $date_where .= " AND cust_bill._date >= $opt{s}";
1409 $date_where .= " AND cust_bill._date < $opt{e}";
1412 my $commit_each_invoice = 1 unless $opt{X};
1414 # if an invoice has either of these kinds of objects, then it doesn't
1415 # need to be upgraded...probably
1416 my $sub_has_tax_link = 'SELECT 1 FROM cust_bill_pkg_tax_location'.
1417 ' JOIN cust_bill_pkg USING (billpkgnum)'.
1418 ' WHERE cust_bill_pkg.invnum = cust_bill.invnum';
1419 my $sub_has_exempt = 'SELECT 1 FROM cust_tax_exempt_pkg'.
1420 ' JOIN cust_bill_pkg USING (billpkgnum)'.
1421 ' WHERE cust_bill_pkg.invnum = cust_bill.invnum'.
1422 ' AND exempt_monthly IS NULL';
1424 my %all_tax_names = (
1427 map { $_->taxname => 1 }
1428 qsearch('h_cust_main_county', { taxname => { op => '!=', value => '' }})
1431 my $search = FS::Cursor->new({
1432 table => 'cust_bill',
1434 extra_sql => "WHERE NOT EXISTS($sub_has_tax_link) ".
1435 "AND NOT EXISTS($sub_has_exempt) ".
1439 #print "Processing ".scalar(@invnums)." invoices...\n";
1443 while (my $cust_bill = $search->fetch) {
1444 my $invnum = $cust_bill->invnum;
1446 print STDERR "Invoice #$invnum\n";
1448 my %pkgpart_taxclass; # pkgpart => taxclass
1449 my %pkgpart_exempt_setup;
1450 my %pkgpart_exempt_recur;
1451 my $h_cust_bill = qsearchs('h_cust_bill',
1452 { invnum => $invnum,
1453 history_action => 'insert' });
1454 if (!$h_cust_bill) {
1455 warn "no insert record for invoice $invnum; skipped\n";
1456 #$date = $cust_bill->_date as a fallback?
1457 # We're trying to avoid using non-real dates (-d/-y invoice dates)
1458 # when looking up history records in other tables.
1461 my $custnum = $h_cust_bill->custnum;
1463 # Determine the address corresponding to this tax region.
1464 # It's either the bill or ship address of the customer as of the
1465 # invoice date-of-insertion. (Not necessarily the invoice date.)
1466 my $date = $h_cust_bill->history_date;
1467 local($FS::Record::qsearch_qualify_columns) = 0;
1468 my $h_cust_main = qsearchs('h_cust_main',
1469 { custnum => $custnum },
1470 FS::h_cust_main->sql_h_searchs($date)
1472 if (!$h_cust_main ) {
1473 warn "no historical address for cust#".$h_cust_bill->custnum."; skipped\n";
1475 # fallback to current $cust_main? sounds dangerous.
1478 # This is a historical customer record, so it has a historical address.
1479 # If there's no cust_location matching this custnum and address (there
1480 # probably isn't), create one.
1481 my %tax_loc; # keys are pkgnums, values are cust_location objects
1482 my $default_tax_loc;
1483 if ( $h_cust_main->bill_locationnum ) {
1484 # the location has already been upgraded
1486 $default_tax_loc = $h_cust_main->ship_location;
1488 $default_tax_loc = $h_cust_main->bill_location;
1491 $pre = 'ship_' if $use_ship and length($h_cust_main->get('ship_last'));
1492 my %hash = map { $_ => $h_cust_main->get($pre.$_) }
1493 FS::cust_main->location_fields;
1494 # not really needed for this, and often result in duplicate locations
1495 delete @hash{qw(censustract censusyear latitude longitude coord_auto)};
1497 $hash{custnum} = $h_cust_main->custnum;
1498 $default_tax_loc = FS::cust_location->new(\%hash);
1499 my $error = $default_tax_loc->find_or_insert || $default_tax_loc->disable_if_unused;
1501 warn "couldn't create historical location record for cust#".
1502 $h_cust_main->custnum.": $error\n";
1507 $exempt_cust = 1 if $h_cust_main->tax;
1509 # classify line items
1511 my %nontax_items; # taxclass => array of cust_bill_pkg
1512 foreach my $item ($h_cust_bill->cust_bill_pkg) {
1513 my $pkgnum = $item->pkgnum;
1515 if ( $pkgnum == 0 ) {
1517 push @tax_items, $item;
1520 # (pkgparts really shouldn't change, right?)
1521 local($FS::Record::qsearch_qualify_columns) = 0;
1522 my $h_cust_pkg = qsearchs('h_cust_pkg', { pkgnum => $pkgnum },
1523 FS::h_cust_pkg->sql_h_searchs($date)
1525 if ( !$h_cust_pkg ) {
1526 warn "no historical package #".$item->pkgpart."; skipped\n";
1529 my $pkgpart = $h_cust_pkg->pkgpart;
1531 if ( $use_pkgloc and $h_cust_pkg->locationnum ) {
1532 # then this package already had a locationnum assigned, and that's
1533 # the one to use for tax calculation
1534 $tax_loc{$pkgnum} = FS::cust_location->by_key($h_cust_pkg->locationnum);
1536 # use the customer's bill or ship loc, which was inserted earlier
1537 $tax_loc{$pkgnum} = $default_tax_loc;
1540 if (!exists $pkgpart_taxclass{$pkgpart}) {
1541 local($FS::Record::qsearch_qualify_columns) = 0;
1542 my $h_part_pkg = qsearchs('h_part_pkg', { pkgpart => $pkgpart },
1543 FS::h_part_pkg->sql_h_searchs($date)
1545 if ( !$h_part_pkg ) {
1546 warn "no historical package def #$pkgpart; skipped\n";
1549 $pkgpart_taxclass{$pkgpart} = $h_part_pkg->taxclass || '';
1550 $pkgpart_exempt_setup{$pkgpart} = 1 if $h_part_pkg->setuptax;
1551 $pkgpart_exempt_recur{$pkgpart} = 1 if $h_part_pkg->recurtax;
1554 # mark any exemptions that apply
1555 if ( $pkgpart_exempt_setup{$pkgpart} ) {
1556 $item->set('exempt_setup' => 1);
1559 if ( $pkgpart_exempt_recur{$pkgpart} ) {
1560 $item->set('exempt_recur' => 1);
1563 my $taxclass = $pkgpart_taxclass{ $pkgpart };
1565 $nontax_items{$taxclass} ||= [];
1566 push @{ $nontax_items{$taxclass} }, $item;
1570 printf("%d tax items: \$%.2f\n", scalar(@tax_items), map {$_->setup} @tax_items)
1573 # Get any per-customer taxname exemptions that were in effect.
1574 my %exempt_cust_taxname;
1575 foreach (keys %all_tax_names) {
1576 local($FS::Record::qsearch_qualify_columns) = 0;
1577 my $h_exemption = qsearchs('h_cust_main_exemption', {
1578 'custnum' => $custnum,
1581 FS::h_cust_main_exemption->sql_h_searchs($date, $date)
1584 $exempt_cust_taxname{ $_ } = 1;
1588 # Use a variation on the procedure in
1589 # FS::cust_main::Billing::_handle_taxes to identify taxes that apply
1591 my @loc_keys = qw( district city county state country );
1592 my %taxdef_by_name; # by name, and then by taxclass
1593 my %est_tax; # by name, and then by taxclass
1594 my %taxable_items; # by taxnum, and then an array
1596 foreach my $taxclass (keys %nontax_items) {
1597 foreach my $orig_item (@{ $nontax_items{$taxclass} }) {
1598 my $my_tax_loc = $tax_loc{ $orig_item->pkgnum };
1599 my %myhash = map { $_ => $my_tax_loc->get($pre.$_) } @loc_keys;
1600 my @elim = qw( district city county state );
1601 my @taxdefs; # because there may be several with different taxnames
1603 $myhash{taxclass} = $taxclass;
1604 @taxdefs = qsearch('cust_main_county', \%myhash);
1606 $myhash{taxclass} = '';
1607 @taxdefs = qsearch('cust_main_county', \%myhash);
1609 $myhash{ shift @elim } = '';
1610 } while scalar(@elim) and !@taxdefs;
1612 foreach my $taxdef (@taxdefs) {
1613 next if $taxdef->tax == 0;
1614 $taxdef_by_name{$taxdef->taxname}{$taxdef->taxclass} = $taxdef;
1616 $taxable_items{$taxdef->taxnum} ||= [];
1617 # clone the item so that taxdef-dependent changes don't
1618 # change it for other taxdefs
1619 my $item = FS::cust_bill_pkg->new({ $orig_item->hash });
1621 # these flags are already set if the part_pkg declares itself exempt
1622 $item->set('exempt_setup' => 1) if $taxdef->setuptax;
1623 $item->set('exempt_recur' => 1) if $taxdef->recurtax;
1626 my $taxable = $item->setup + $item->recur;
1628 # h_cust_credit_bill_pkg?
1629 # NO. Because if these exemptions HAD been created at the time of
1630 # billing, and then a credit applied later, the exemption would
1631 # have been adjusted by the amount of the credit. So we adjust
1632 # the taxable amount before creating the exemption.
1633 # But don't deduct the credit from taxable, because the tax was
1634 # calculated before the credit was applied.
1635 foreach my $f (qw(setup recur)) {
1636 my $credited = FS::Record->scalar_sql(
1637 "SELECT SUM(amount) FROM cust_credit_bill_pkg ".
1638 "WHERE billpkgnum = ? AND setuprecur = ?",
1642 $item->set($f, $item->get($f) - $credited) if $credited;
1644 my $existing_exempt = FS::Record->scalar_sql(
1645 "SELECT SUM(amount) FROM cust_tax_exempt_pkg WHERE ".
1646 "billpkgnum = ? AND taxnum = ?",
1647 $item->billpkgnum, $taxdef->taxnum
1649 $taxable -= $existing_exempt;
1651 if ( $taxable and $exempt_cust ) {
1652 push @new_exempt, { exempt_cust => 'Y', amount => $taxable };
1655 if ( $taxable and $exempt_cust_taxname{$taxdef->taxname} ){
1656 push @new_exempt, { exempt_cust_taxname => 'Y', amount => $taxable };
1659 if ( $taxable and $item->exempt_setup ) {
1660 push @new_exempt, { exempt_setup => 'Y', amount => $item->setup };
1661 $taxable -= $item->setup;
1663 if ( $taxable and $item->exempt_recur ) {
1664 push @new_exempt, { exempt_recur => 'Y', amount => $item->recur };
1665 $taxable -= $item->recur;
1668 $item->set('taxable' => $taxable);
1669 push @{ $taxable_items{$taxdef->taxnum} }, $item
1672 # estimate the amount of tax (this is necessary because different
1673 # taxdefs with the same taxname may have different tax rates)
1674 # and sum that for each taxname/taxclass combination
1676 $est_tax{$taxdef->taxname} ||= {};
1677 $est_tax{$taxdef->taxname}{$taxdef->taxclass} ||= 0;
1678 $est_tax{$taxdef->taxname}{$taxdef->taxclass} +=
1679 $taxable * $taxdef->tax;
1681 foreach (@new_exempt) {
1682 next if $_->{amount} == 0;
1683 my $cust_tax_exempt_pkg = FS::cust_tax_exempt_pkg->new({
1685 billpkgnum => $item->billpkgnum,
1686 taxnum => $taxdef->taxnum,
1688 my $error = $cust_tax_exempt_pkg->insert;
1690 my $pkgnum = $item->pkgnum;
1691 warn "error creating tax exemption for inv$invnum pkg$pkgnum:".
1695 } #foreach @new_exempt
1698 } #foreach $taxclass
1700 # Now go through the billed taxes and match them up with the line items.
1701 TAX_ITEM: foreach my $tax_item ( @tax_items )
1703 my $taxname = $tax_item->itemdesc;
1704 $taxname = '' if $taxname eq 'Tax';
1706 if ( !exists( $taxdef_by_name{$taxname} ) ) {
1707 # then we didn't find any applicable taxes with this name
1708 warn "no definition found for tax item '$taxname', custnum $custnum\n";
1709 # possibly all of these should be "next TAX_ITEM", but whole invoices
1710 # are transaction protected and we can go back and retry them.
1713 # classname => cust_main_county
1714 my %taxdef_by_class = %{ $taxdef_by_name{$taxname} };
1716 # Divide the tax item among taxclasses, if necessary
1717 # classname => estimated tax amount
1718 my $this_est_tax = $est_tax{$taxname};
1719 if (!defined $this_est_tax) {
1720 warn "no taxable sales found for inv#$invnum, tax item '$taxname'.\n";
1723 my $est_total = sum(values %$this_est_tax);
1724 if ( $est_total == 0 ) {
1726 warn "estimated tax on invoice #$invnum is zero.\n";
1730 my $real_tax = $tax_item->setup;
1731 printf ("Distributing \$%.2f tax:\n", $real_tax);
1732 my $cents_remaining = $real_tax * 100; # for rounding error
1733 my @tax_links; # partial CBPTL hashrefs
1734 foreach my $taxclass (keys %taxdef_by_class) {
1735 my $taxdef = $taxdef_by_class{$taxclass};
1736 # these items already have "taxable" set to their charge amount
1737 # after applying any credits or exemptions
1738 my @items = @{ $taxable_items{$taxdef->taxnum} };
1739 my $subtotal = sum(map {$_->get('taxable')} @items);
1740 printf("\t$taxclass: %.2f\n", $this_est_tax->{$taxclass}/$est_total);
1742 foreach my $nontax (@items) {
1743 my $my_tax_loc = $tax_loc{ $nontax->pkgnum };
1744 my $part = int($real_tax
1746 * ($this_est_tax->{$taxclass}/$est_total)
1748 * ($nontax->get('taxable'))/$subtotal
1752 $cents_remaining -= $part;
1754 taxnum => $taxdef->taxnum,
1755 pkgnum => $nontax->pkgnum,
1756 locationnum => $my_tax_loc->locationnum,
1757 billpkgnum => $nontax->billpkgnum,
1761 } #foreach $taxclass
1762 # Distribute any leftover tax round-robin style, one cent at a time.
1764 my $nlinks = scalar(@tax_links);
1766 # ensure that it really is an integer
1767 $cents_remaining = sprintf('%.0f', $cents_remaining);
1768 while ($cents_remaining > 0) {
1769 $tax_links[$i % $nlinks]->{cents} += 1;
1774 warn "Can't create tax links--no taxable items found.\n";
1778 # Gather credit/payment applications so that we can link them
1781 qsearch( 'cust_credit_bill_pkg',
1782 { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1784 qsearch( 'cust_bill_pay_pkg',
1785 { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1789 # grab the first one
1790 my $this_unlinked = shift @unlinked;
1791 my $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1793 # Create tax links (yay!)
1794 printf("Creating %d tax links.\n",scalar(@tax_links));
1795 foreach (@tax_links) {
1796 my $link = FS::cust_bill_pkg_tax_location->new({
1797 billpkgnum => $tax_item->billpkgnum,
1798 taxtype => 'FS::cust_main_county',
1799 locationnum => $_->{locationnum},
1800 taxnum => $_->{taxnum},
1801 pkgnum => $_->{pkgnum},
1802 amount => sprintf('%.2f', $_->{cents} / 100),
1803 taxable_billpkgnum => $_->{billpkgnum},
1805 my $error = $link->insert;
1807 warn "Can't create tax link for inv#$invnum: $error\n";
1811 my $link_cents = $_->{cents};
1812 # update/create subitem links
1814 # If $this_unlinked is undef, then we've allocated all of the
1815 # credit/payment applications to the tax item. If $link_cents is 0,
1816 # then we've applied credits/payments to all of this package fraction,
1817 # so go on to the next.
1818 while ($this_unlinked and $link_cents) {
1819 # apply as much as possible of $link_amount to this credit/payment
1821 my $apply_cents = min($link_cents, $unlinked_cents);
1822 $link_cents -= $apply_cents;
1823 $unlinked_cents -= $apply_cents;
1824 # $link_cents or $unlinked_cents or both are now zero
1825 $this_unlinked->set('amount' => sprintf('%.2f',$apply_cents/100));
1826 $this_unlinked->set('billpkgtaxlocationnum' => $link->billpkgtaxlocationnum);
1827 my $pkey = $this_unlinked->primary_key; #creditbillpkgnum or billpaypkgnum
1828 if ( $this_unlinked->$pkey ) {
1829 # then it's an existing link--replace it
1830 $error = $this_unlinked->replace;
1832 $this_unlinked->insert;
1834 # what do we do with errors at this stage?
1836 warn "Error creating tax application link: $error\n";
1837 next INVOICE; # for lack of a better idea
1840 if ( $unlinked_cents == 0 ) {
1841 # then we've allocated all of this payment/credit application,
1842 # so grab the next one
1843 $this_unlinked = shift @unlinked;
1844 $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1845 } elsif ( $link_cents == 0 ) {
1846 # then we've covered all of this package tax fraction, so split
1847 # off a new application from this one
1848 $this_unlinked = $this_unlinked->new({
1849 $this_unlinked->hash,
1852 # $unlinked_cents is still what it is
1855 } #while $this_unlinked and $link_cents
1856 } #foreach (@tax_links)
1857 } #foreach $tax_item
1859 $dbh->commit if $commit_each_invoice and $oldAutoCommit;
1865 $dbh->rollback if $oldAutoCommit;
1866 die "Upgrade halted.\n" unless $commit_each_invoice;
1870 $dbh->commit if $oldAutoCommit and !$commit_each_invoice;
1875 # Return an array of hashrefs for each cust_bill_pkg_tax_location
1876 # applied to this bill for this cust_bill_pkg.pkgnum.
1879 # In some situations, this list will contain more tax records than the
1880 # ones directly related to $self->billpkgnum. The returned list contains
1881 # all records, for this bill, charged against this billpkgnum's pkgnum.
1883 # One must keep this in mind when using data returned by this method.
1885 # An unaddressed deficiency in the cust_bill_pkg_tax_location model makes
1886 # this necessary: When a linked-hidden package generates a tax/fee as a row
1887 # in cust_bill_pkg_tax_location, there is not enough information to surmise
1888 # with specificity which billpkgnum row represents the direct parent of the
1889 # the linked-hidden package's tax row. The closest we can get to this
1890 # backwards reassociation is to use the pkgnum. Therefore, when multiple
1891 # billpkgnum's appear with the same pkgnum, this method is going to return
1892 # the tax records for ALL of those billpkgnum's, not just $self->billpkgnum.
1894 # This could be addressed with an update to the model, and to the billing
1895 # routine that generates rows into cust_bill_pkg_tax_location. Perhaps a
1896 # column, link_billpkgnum or parent_billpkgnum, recording the link. I'm not
1897 # doing that now, because there would be no possible repair of data stored
1898 # historically prior to such a fix. I need _pkg_tax_list() to not be
1899 # broken for already-generated bills.
1901 # Any code you write relying on _pkg_tax_list() MUST be aware of, and
1902 # account for, the possible return of duplicated tax records returned
1903 # when method is called on multiple cust_bill_pkg_tax_location rows.
1904 # Duplicates can be identified by billpkgtaxlocationnum column.
1908 my $search_selector;
1909 if ( $self->pkgnum ) {
1911 # For taxes applied to normal billing items
1913 ' cust_bill_pkg_tax_location.pkgnum = '
1914 . dbh->quote( $self->pkgnum );
1916 } elsif ( $self->feepart ) {
1918 # For taxes applied to fees, when the fee is not attached to a package
1919 # i.e. late fees, billing events fees
1921 ' cust_bill_pkg_tax_location.taxable_billpkgnum = '
1922 . dbh->quote( $self->billpkgnum );
1925 warn "_pkg_tax_list() unhandled case breaking taxes into sections";
1926 warn "_pkg_tax_list() $_: ".$self->$_
1927 for qw(pkgnum billpkgnum feepart);
1932 billpkgtaxlocationnum => $_->billpkgtaxlocationnum,
1933 billpkgnum => $_->billpkgnum,
1934 taxnum => $_->taxnum,
1935 amount => $_->amount,
1936 taxname => $_->taxname,
1939 table => 'cust_bill_pkg_tax_location',
1941 LEFT JOIN cust_bill_pkg
1942 ON cust_bill_pkg.billpkgnum
1943 = cust_bill_pkg_tax_location.taxable_billpkgnum
1945 select => join( ', ', (qw|
1946 cust_bill_pkg.billpkgnum
1947 cust_bill_pkg_tax_location.billpkgtaxlocationnum
1948 cust_bill_pkg_tax_location.taxnum
1949 cust_bill_pkg_tax_location.amount
1953 ' cust_bill_pkg.invnum = ' . dbh->quote( $self->invnum ) .
1961 # Create a queue job to run upgrade_tax_location from January 1, 2012 to
1965 use Date::Parse 'str2time';
1968 my $upgrade = 'tax_location_2012';
1969 return if FS::upgrade_journal->is_done($upgrade);
1970 my $job = FS::queue->new({
1971 'job' => 'FS::cust_bill_pkg::upgrade_tax_location'
1973 # call it kind of like a class method, not that it matters much
1974 $job->insert($class, 's' => str2time('2012-01-01'));
1975 # if there's a customer location upgrade queued also, wait for it to
1977 my $location_job = qsearchs('queue', {
1978 job => 'FS::cust_main::Location::process_upgrade_location'
1980 if ( $location_job ) {
1981 $job->depend_insert($location_job->jobnum);
1983 # Then mark the upgrade as done, so that we don't queue the job twice
1984 # and somehow run two of them concurrently.
1985 FS::upgrade_journal->set_done($upgrade);
1986 # This upgrade now does the job of assigning taxable_billpkgnums to
1987 # cust_bill_pkg_tax_location, so set that task done also.
1988 FS::upgrade_journal->set_done('tax_location_taxable_billpkgnum');
1995 setup and recur shouldn't be separate fields. There should be one "amount"
1996 field and a flag to tell you if it is a setup/one-time fee or a recurring fee.
1998 A line item with both should really be two separate records (preserving
1999 sdate and edate for setup fees for recurring packages - that information may
2000 be valuable later). Invoice generation (cust_main::bill), invoice printing
2001 (cust_bill), tax reports (report_tax.cgi) and line item reports
2002 (cust_bill_pkg.cgi) would need to be updated.
2004 owed_setup and owed_recur could then be repaced by just owed, and
2005 cust_bill::open_cust_bill_pkg and
2006 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
2008 The upgrade procedure is pretty sketchy.
2012 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
2013 from the base documentation.