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 );
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;
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 $self->scalar_sql("INSERT INTO cust_bill_pkg_detail_void
433 SELECT * $from_cust_bill_pkg_detail",
437 $self->scalar_sql("DELETE $from_cust_bill_pkg_detail", $self->billpkgnum);
450 local $SIG{HUP} = 'IGNORE';
451 local $SIG{INT} = 'IGNORE';
452 local $SIG{QUIT} = 'IGNORE';
453 local $SIG{TERM} = 'IGNORE';
454 local $SIG{TSTP} = 'IGNORE';
455 local $SIG{PIPE} = 'IGNORE';
457 my $oldAutoCommit = $FS::UID::AutoCommit;
458 local $FS::UID::AutoCommit = 0;
461 foreach my $table (qw(
463 cust_bill_pkg_display
464 cust_bill_pkg_discount
465 cust_bill_pkg_tax_location
466 cust_bill_pkg_tax_rate_location
473 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
474 my $error = $linked->delete;
476 $dbh->rollback if $oldAutoCommit;
483 foreach my $cust_tax_adjustment (
484 qsearch('cust_tax_adjustment', { billpkgnum=>$self->billpkgnum })
486 $cust_tax_adjustment->billpkgnum(''); #NULL
487 my $error = $cust_tax_adjustment->replace;
489 $dbh->rollback if $oldAutoCommit;
494 #fix the invoice amount
496 my $cust_bill = $self->cust_bill;
497 $cust_bill->charged( $cust_bill->charged - $self->setup - $self->recur );
499 #not adding a cc surcharge, but this override lets us modify charged
500 $cust_bill->{'Hash'}{'cc_surcharge_replace_hack'} = 1;
502 my $error = $cust_bill->replace;
504 $dbh->rollback if $oldAutoCommit;
508 my $error = $self->SUPER::delete(@_);
510 $dbh->rollback if $oldAutoCommit;
514 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
520 #alas, bin/follow-tax-rename
522 #=item replace OLD_RECORD
524 #Currently unimplemented. This would be even more of an accounting nightmare
525 #than deleteing the items. Just don't do it.
530 # return "Can't modify cust_bill_pkg records!";
535 Checks all fields to make sure this is a valid line item. If there is an
536 error, returns the error, otherwise returns false. Called by the insert
545 $self->ut_numbern('billpkgnum')
546 || $self->ut_snumber('pkgnum')
547 || $self->ut_number('invnum')
548 || $self->ut_money('setup')
549 || $self->ut_moneyn('unitsetup')
550 || $self->ut_currencyn('setup_billed_currency')
551 || $self->ut_moneyn('setup_billed_amount')
552 || $self->ut_money('recur')
553 || $self->ut_moneyn('unitrecur')
554 || $self->ut_currencyn('recur_billed_currency')
555 || $self->ut_moneyn('recur_billed_amount')
556 || $self->ut_numbern('sdate')
557 || $self->ut_numbern('edate')
558 || $self->ut_textn('itemdesc')
559 || $self->ut_textn('itemcomment')
560 || $self->ut_enum('hidden', [ '', 'Y' ])
562 return $error if $error;
564 $self->regularize_details;
566 #if ( $self->pkgnum != 0 ) { #allow unchecked pkgnum 0 for tax! (add to part_pkg?)
567 if ( $self->pkgnum > 0 ) { #allow -1 for non-pkg line items and 0 for tax (add to part_pkg?)
568 return "Unknown pkgnum ". $self->pkgnum
569 unless qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
572 return "Unknown invnum"
573 unless qsearchs( 'cust_bill' ,{ 'invnum' => $self->invnum } );
578 =item regularize_details
580 Converts the contents of the 'details' pseudo-field to
581 L<FS::cust_bill_pkg_detail> objects, if they aren't already.
585 sub regularize_details {
587 if ( $self->get('details') ) {
588 foreach my $detail ( @{$self->get('details')} ) {
589 if ( ref($detail) ne 'FS::cust_bill_pkg_detail' ) {
590 # then turn it into one
592 if ( ! ref($detail) ) {
593 $hash{'detail'} = $detail;
595 elsif ( ref($detail) eq 'HASH' ) {
598 elsif ( ref($detail) eq 'ARRAY' ) {
599 carp "passing invoice details as arrays is deprecated";
600 #carp "this way sucks, use a hash"; #but more useful/friendly
601 $hash{'format'} = $detail->[0];
602 $hash{'detail'} = $detail->[1];
603 $hash{'amount'} = $detail->[2];
604 $hash{'classnum'} = $detail->[3];
605 $hash{'phonenum'} = $detail->[4];
606 $hash{'accountcode'} = $detail->[5];
607 $hash{'startdate'} = $detail->[6];
608 $hash{'duration'} = $detail->[7];
609 $hash{'regionname'} = $detail->[8];
612 die "unknown detail type ". ref($detail);
614 $detail = new FS::cust_bill_pkg_detail \%hash;
616 $detail->billpkgnum($self->billpkgnum) if $self->billpkgnum;
622 =item set_exemptions TAXOBJECT, OPTIONS
624 Sets up tax exemptions. TAXOBJECT is the L<FS::cust_main_county> or
625 L<FS::tax_rate> record for the tax.
627 This will deal with the following cases:
631 =item Fully exempt customers (cust_main.tax flag) or customer classes
634 =item Customers exempt from specific named taxes (cust_main_exemption
637 =item Taxes that don't apply to setup or recurring fees
638 (cust_main_county.setuptax and recurtax, tax_rate.setuptax and recurtax).
640 =item Packages that are marked as tax-exempt (part_pkg.setuptax,
643 =item Fees that aren't marked as taxable (part_fee.taxable).
647 It does NOT deal with monthly tax exemptions, which need more context
648 than this humble little method cares to deal with.
650 OPTIONS should include "custnum" => the customer number if this tax line
651 hasn't been inserted (which it probably hasn't).
653 Returns a list of exemption objects, which will also be attached to the
654 line item as the 'cust_tax_exempt_pkg' pseudo-field. Inserting the line
655 item will insert these records as well.
664 my $part_pkg = $self->part_pkg;
665 my $part_fee = $self->part_fee;
668 my $custnum = $opt{custnum};
669 $custnum ||= $self->cust_bill->custnum if $self->cust_bill;
671 $cust_main = FS::cust_main->by_key( $custnum )
672 or die "set_exemptions can't identify customer (pass custnum option)\n";
675 my $taxable_charged = $self->setup + $self->recur;
676 return unless $taxable_charged > 0;
678 ### Fully exempt customer ###
680 my $conf = FS::Conf->new;
681 if ( $conf->exists('cust_class-tax_exempt') ) {
682 my $cust_class = $cust_main->cust_class;
683 $exempt_cust = $cust_class->tax if $cust_class;
685 $exempt_cust = $cust_main->tax;
688 ### Exemption from named tax ###
689 my $exempt_cust_taxname;
690 if ( !$exempt_cust and $tax->taxname ) {
691 $exempt_cust_taxname = $cust_main->tax_exemption($tax->taxname);
694 if ( $exempt_cust ) {
696 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
697 amount => $taxable_charged,
700 $taxable_charged = 0;
702 } elsif ( $exempt_cust_taxname ) {
704 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
705 amount => $taxable_charged,
706 exempt_cust_taxname => 'Y',
708 $taxable_charged = 0;
712 my $exempt_setup = ( ($part_fee and not $part_fee->taxable)
713 or ($part_pkg and $part_pkg->setuptax)
718 and $taxable_charged > 0 ) {
720 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
721 amount => $self->setup,
724 $taxable_charged -= $self->setup;
728 my $exempt_recur = ( ($part_fee and not $part_fee->taxable)
729 or ($part_pkg and $part_pkg->recurtax)
734 and $taxable_charged > 0 ) {
736 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
737 amount => $self->recur,
740 $taxable_charged -= $self->recur;
744 foreach (@new_exemptions) {
745 $_->set('taxnum', $tax->taxnum);
746 $_->set('taxtype', ref($tax));
749 push @{ $self->cust_tax_exempt_pkg }, @new_exemptions;
750 return @new_exemptions;
756 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
760 Returns the customer (L<FS::cust_main> object) for this line item.
765 carp "->cust_main called" if $DEBUG;
766 # required for cust_main_Mixin equivalence
767 # and use cust_bill instead of cust_pkg because this might not have a
770 my $cust_bill = $self->cust_bill or return '';
771 $cust_bill->cust_main;
774 =item previous_cust_bill_pkg
776 Returns the previous cust_bill_pkg for this package, if any.
780 sub previous_cust_bill_pkg {
782 return unless $self->sdate;
784 'table' => 'cust_bill_pkg',
785 'hashref' => { 'pkgnum' => $self->pkgnum,
786 'sdate' => { op=>'<', value=>$self->sdate },
788 'order_by' => 'ORDER BY sdate DESC LIMIT 1',
794 Returns the amount owed (still outstanding) on this line item's setup fee,
795 which is the amount of the line item minus all payment applications (see
796 L<FS::cust_bill_pay_pkg> and credit applications (see
797 L<FS::cust_credit_bill_pkg>).
803 $self->owed('setup', @_);
808 Returns the amount owed (still outstanding) on this line item's recurring fee,
809 which is the amount of the line item minus all payment applications (see
810 L<FS::cust_bill_pay_pkg> and credit applications (see
811 L<FS::cust_credit_bill_pkg>).
817 $self->owed('recur', @_);
820 # modeled after cust_bill::owed...
822 my( $self, $field ) = @_;
823 my $balance = $self->$field();
824 $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg($field) );
825 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
826 $balance = sprintf( '%.2f', $balance );
827 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
833 my( $self, $field ) = @_;
834 my $balance = $self->$field();
835 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
836 $balance = sprintf( '%.2f', $balance );
837 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
841 sub cust_bill_pay_pkg {
842 my( $self, $field ) = @_;
843 qsearch( 'cust_bill_pay_pkg', { 'billpkgnum' => $self->billpkgnum,
844 'setuprecur' => $field,
849 sub cust_credit_bill_pkg {
850 my( $self, $field ) = @_;
851 qsearch( 'cust_credit_bill_pkg', { 'billpkgnum' => $self->billpkgnum,
852 'setuprecur' => $field,
859 Returns the number of billing units (for tax purposes) represented by this,
866 $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
871 If this item has any discounts, returns a hashref in the format used
872 by L<FS::Template_Mixin/_items_cust_bill_pkg> to describe the discount(s)
873 on an invoice. This will contain the keys 'description', 'amount',
874 'ext_description' (an arrayref of text lines describing the discounts),
875 and '_is_discount' (a flag).
877 The value for 'amount' will be negative, and will be scaled for the package
886 my $d; # this will be returned.
888 my @pkg_discounts = $self->pkg_discount;
889 if (@pkg_discounts) {
890 # special case: if there are old "discount details" on this line item,
891 # don't show discount line items
892 if ( FS::cust_bill_pkg_detail->count("detail LIKE 'Includes discount%' AND billpkgnum = ?", $self->billpkgnum || 0) > 0 ) {
899 description => $self->mt('Discount'),
902 ext_description => \@ext,
903 pkgpart => $self->pkgpart,
904 feepart => $self->feepart,
905 # maybe should show quantity/unit discount?
907 foreach my $pkg_discount (@pkg_discounts) {
908 push @ext, $pkg_discount->description;
909 my $setuprecur = $pkg_discount->cust_pkg_discount->setuprecur;
910 $d->{$setuprecur.'_amount'} -= $pkg_discount->amount;
914 # show introductory rate as a pseudo-discount
915 if (!$d) { # this will conflict with showing real discounts
916 my $part_pkg = $self->part_pkg;
917 if ( $part_pkg and $part_pkg->option('show_as_discount',1) ) {
918 my $cust_pkg = $self->cust_pkg;
919 my $intro_end = $part_pkg->intro_end($cust_pkg);
920 my $_date = $self->cust_bill->_date;
921 if ( $intro_end > $_date ) {
922 $d = $part_pkg->item_discount($cust_pkg);
928 $d->{setup_amount} *= $self->quantity || 1; # ??
929 $d->{recur_amount} *= $self->quantity || 1; # ??
935 =item set_display OPTION => VALUE ...
937 A helper method for I<insert>, populates the pseudo-field B<display> with
938 appropriate FS::cust_bill_pkg_display objects.
940 Options are passed as a list of name/value pairs. Options are:
942 part_pkg: FS::part_pkg object from this line item's package.
944 real_pkgpart: if this line item comes from a bundled package, the pkgpart
945 of the owning package. Otherwise the same as the part_pkg's pkgpart above.
950 my( $self, %opt ) = @_;
951 my $part_pkg = $opt{'part_pkg'};
952 my $cust_pkg = new FS::cust_pkg { pkgpart => $opt{real_pkgpart} };
954 my $conf = new FS::Conf;
956 # whether to break this down into setup/recur/usage
957 my $separate = $conf->exists('separate_usage');
959 my $usage_mandate = $part_pkg->option('usage_mandate', 'Hush!')
960 || $cust_pkg->part_pkg->option('usage_mandate', 'Hush!');
962 # or use the category from $opt{'part_pkg'} if its not bundled?
963 my $categoryname = $cust_pkg->part_pkg->categoryname;
965 # if we don't have to separate setup/recur/usage, or put this in a
966 # package-specific section, or display a usage summary, then don't
967 # even create one of these. The item will just display in the unnamed
968 # section as a single line plus details.
969 return $self->set('display', [])
970 unless $separate || $categoryname || $usage_mandate;
974 my %hash = ( 'section' => $categoryname );
976 # whether to put usage details in a separate section, and if so, which one
977 my $usage_section = $part_pkg->option('usage_section', 'Hush!')
978 || $cust_pkg->part_pkg->option('usage_section', 'Hush!');
980 # whether to show a usage summary line (total usage charges, no details)
981 my $summary = $part_pkg->option('summarize_usage', 'Hush!')
982 || $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
985 # create lines for setup and (non-usage) recur, in the main section
986 push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
987 push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
989 # display everything in a single line
990 push @display, new FS::cust_bill_pkg_display
993 # and if usage_mandate is enabled, hide details
994 # (this only works on multisection invoices...)
995 ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
999 if ($separate && $usage_section && $summary) {
1000 # create a line for the usage summary in the main section
1001 push @display, new FS::cust_bill_pkg_display { type => 'U',
1007 if ($usage_mandate || ($usage_section && $summary) ) {
1008 $hash{post_total} = 'Y';
1011 if ($separate || $usage_mandate) {
1012 # show call details for this line item in the usage section.
1013 # if usage_mandate is on, this will display below the section subtotal.
1014 # this also happens if usage is in a separate section and there's a
1015 # summary in the main section, though I'm not sure why.
1016 $hash{section} = $usage_section if $usage_section;
1017 push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
1020 $self->set('display', \@display);
1026 Returns a hash: keys are "setup", "recur" or usage classnum, values are
1027 FS::cust_bill_pkg objects, each with no more than a single class (setup or
1034 # XXX this goes away with cust_bill_pkg refactor
1035 # or at least I wish it would, but it turns out to be harder than
1038 #my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash }; # wha huh?
1039 my %cust_bill_pkg = ();
1042 foreach my $classnum ($self->usage_classes) {
1043 my $amount = $self->usage($classnum);
1044 next if $amount == 0; # though if so we shouldn't be here
1045 my $usage_item = FS::cust_bill_pkg->new({
1049 'taxclass' => $classnum,
1052 $cust_bill_pkg{$classnum} = $usage_item;
1053 $usage_total += $amount;
1056 foreach (qw(setup recur)) {
1057 next if ($self->get($_) == 0);
1058 my $item = FS::cust_bill_pkg->new({
1065 $item->set($_, $self->get($_));
1066 $cust_bill_pkg{$_} = $item;
1070 $cust_bill_pkg{recur}->set('recur',
1071 sprintf('%.2f', $cust_bill_pkg{recur}->get('recur') - $usage_total)
1078 =item usage CLASSNUM
1080 Returns the amount of the charge associated with usage class CLASSNUM if
1081 CLASSNUM is defined. Otherwise returns the total charge associated with
1087 my( $self, $classnum ) = @_;
1088 $self->regularize_details;
1090 if ( $self->get('details') ) {
1093 map { $_->amount || 0 }
1094 grep { !defined($classnum) or $classnum eq $_->classnum }
1095 @{ $self->get('details') }
1100 my $sql = 'SELECT SUM(COALESCE(amount,0)) FROM cust_bill_pkg_detail '.
1101 ' WHERE billpkgnum = '. $self->billpkgnum;
1102 if (defined $classnum) {
1103 if ($classnum =~ /^(\d+)$/) {
1104 $sql .= " AND classnum = $1";
1105 } elsif ($classnum eq '') {
1106 $sql .= " AND classnum IS NULL";
1110 my $sth = dbh->prepare($sql) or die dbh->errstr;
1111 $sth->execute or die $sth->errstr;
1113 return $sth->fetchrow_arrayref->[0] || 0;
1121 Returns a list of usage classnums associated with this invoice line's
1128 $self->regularize_details;
1130 if ( $self->get('details') ) {
1132 my %seen = ( map { $_->classnum => 1 } @{ $self->get('details') } );
1137 map { $_->classnum }
1138 qsearch({ table => 'cust_bill_pkg_detail',
1139 hashref => { billpkgnum => $self->billpkgnum },
1140 select => 'DISTINCT classnum',
1147 sub cust_tax_exempt_pkg {
1150 my $array = $self->{Hash}->{cust_tax_exempt_pkg} ||= [];
1153 =item cust_bill_pkg_tax_Xlocation
1155 Returns the list of associated cust_bill_pkg_tax_location and/or
1156 cust_bill_pkg_tax_rate_location objects
1160 sub cust_bill_pkg_tax_Xlocation {
1163 my %hash = ( 'billpkgnum' => $self->billpkgnum );
1166 qsearch ( 'cust_bill_pkg_tax_location', { %hash } ),
1167 qsearch ( 'cust_bill_pkg_tax_rate_location', { %hash } )
1172 =item recur_show_zero
1174 Whether to show a zero recurring amount. This is true if the package or its
1175 definition has the recur_show_zero flag, and the recurring fee is actually
1176 zero for this period.
1180 sub recur_show_zero {
1181 my( $self, $what ) = @_;
1183 return 0 unless $self->get('recur') == 0 && $self->pkgnum;
1185 $self->cust_pkg->_X_show_zero('recur');
1188 =item setup_show_zero
1190 Whether to show a zero setup charge. This requires the package or its
1191 definition to have the setup_show_zero flag, but it also returns false if
1192 the package's setup date is before this line item's start date.
1196 sub setup_show_zero {
1198 return 0 unless $self->get('setup') == 0 && $self->pkgnum;
1199 my $cust_pkg = $self->cust_pkg;
1200 return 0 if ( $self->sdate || 0 ) > ( $cust_pkg->setup || 0 );
1201 return $cust_pkg->_X_show_zero('setup');
1204 =item credited [ BEFORE, AFTER, OPTIONS ]
1206 Returns the sum of credits applied to this item. Arguments are the same as
1207 owed_sql/paid_sql/credited_sql.
1213 $self->scalar_sql('SELECT '. $self->credited_sql(@_).' FROM cust_bill_pkg WHERE billpkgnum = ?', $self->billpkgnum);
1216 =item tax_locationnum
1218 Returns the L<FS::cust_location> number that this line item is in for tax
1219 purposes. For package sales, it's the package tax location; for fees,
1220 it's the customer's default service location.
1224 sub tax_locationnum {
1226 if ( $self->pkgnum ) { # normal sales
1227 return $self->cust_pkg->tax_locationnum;
1228 } elsif ( $self->feepart ) { # fees
1229 my $custnum = $self->fee_origin->custnum;
1231 return FS::cust_main->by_key($custnum)->ship_locationnum;
1240 if ( $self->pkgnum ) { # normal sales
1241 return $self->cust_pkg->tax_location;
1242 } elsif ( $self->feepart ) { # fees
1243 my $fee_origin = $self->fee_origin;
1244 if ( $fee_origin ) {
1245 my $custnum = $fee_origin->custnum;
1247 return FS::cust_main->by_key($custnum)->ship_location;
1257 =head1 CLASS METHODS
1263 Returns an SQL expression for the total usage charges in details on
1269 '(SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0)
1270 FROM cust_bill_pkg_detail
1271 WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum)';
1273 sub usage_sql { $usage_sql }
1275 # this makes owed_sql, etc. much more concise
1277 my ($class, $start, $end, %opt) = @_;
1278 my $setuprecur = $opt{setuprecur} || '';
1280 $setuprecur =~ /^s/ ? 'cust_bill_pkg.setup' :
1281 $setuprecur =~ /^r/ ? 'cust_bill_pkg.recur' :
1282 'cust_bill_pkg.setup + cust_bill_pkg.recur';
1284 if ($opt{no_usage} and $charged =~ /recur/) {
1285 $charged = "$charged - $usage_sql"
1292 =item owed_sql [ BEFORE, AFTER, OPTIONS ]
1294 Returns an SQL expression for the amount owed. BEFORE and AFTER specify
1295 a date window. OPTIONS may include 'no_usage' (excludes usage charges)
1296 and 'setuprecur' (set to "setup" or "recur" to limit to one or the other).
1302 '(' . $class->charged_sql(@_) .
1303 ' - ' . $class->paid_sql(@_) .
1304 ' - ' . $class->credited_sql(@_) . ')'
1307 =item paid_sql [ BEFORE, AFTER, OPTIONS ]
1309 Returns an SQL expression for the sum of payments applied to this item.
1314 my ($class, $start, $end, %opt) = @_;
1315 my $s = $start ? "AND cust_pay._date <= $start" : '';
1316 my $e = $end ? "AND cust_pay._date > $end" : '';
1317 my $setuprecur = $opt{setuprecur} || '';
1318 $setuprecur = 'setup' if $setuprecur =~ /^s/;
1319 $setuprecur = 'recur' if $setuprecur =~ /^r/;
1320 $setuprecur &&= "AND setuprecur = '$setuprecur'";
1322 my $paid = "( SELECT COALESCE(SUM(cust_bill_pay_pkg.amount),0)
1323 FROM cust_bill_pay_pkg JOIN cust_bill_pay USING (billpaynum)
1324 JOIN cust_pay USING (paynum)
1325 WHERE cust_bill_pay_pkg.billpkgnum = cust_bill_pkg.billpkgnum
1326 $s $e $setuprecur )";
1328 if ( $opt{no_usage} ) {
1329 # cap the amount paid at the sum of non-usage charges,
1330 # minus the amount credited against non-usage charges
1332 $class->charged_sql($start, $end, %opt) . ' - ' .
1333 $class->credited_sql($start, $end, %opt).')';
1342 my ($class, $start, $end, %opt) = @_;
1343 my $s = $start ? "AND cust_credit._date <= $start" : '';
1344 my $e = $end ? "AND cust_credit._date > $end" : '';
1345 my $setuprecur = $opt{setuprecur} || '';
1346 $setuprecur = 'setup' if $setuprecur =~ /^s/;
1347 $setuprecur = 'recur' if $setuprecur =~ /^r/;
1348 $setuprecur &&= "AND setuprecur = '$setuprecur'";
1350 my $credited = "( SELECT COALESCE(SUM(cust_credit_bill_pkg.amount),0)
1351 FROM cust_credit_bill_pkg JOIN cust_credit_bill USING (creditbillnum)
1352 JOIN cust_credit USING (crednum)
1353 WHERE cust_credit_bill_pkg.billpkgnum = cust_bill_pkg.billpkgnum
1354 $s $e $setuprecur )";
1356 if ( $opt{no_usage} ) {
1357 # cap the amount credited at the sum of non-usage charges
1358 "LEAST($credited, ". $class->charged_sql($start, $end, %opt).')';
1366 sub upgrade_tax_location {
1367 # For taxes that were calculated/invoiced before cust_location refactoring
1368 # (May-June 2012), there are no cust_bill_pkg_tax_location records unless
1369 # they were calculated on a package-location basis. Create them here,
1370 # along with any necessary cust_location records and any tax exemption
1373 my ($class, %opt) = @_;
1374 # %opt may include 's' and 'e': start and end date ranges
1375 # and 'X': abort on any error, instead of just rolling back changes to
1378 my $oldAutoCommit = $FS::UID::AutoCommit;
1379 local $FS::UID::AutoCommit = 0;
1382 use FS::h_cust_main;
1383 use FS::h_cust_bill;
1385 use FS::h_cust_main_exemption;
1388 local $FS::cust_location::import = 1;
1390 my $conf = FS::Conf->new; # h_conf?
1391 return if $conf->config('tax_data_vendor'); #don't touch this case
1392 my $use_ship = $conf->exists('tax-ship_address');
1393 my $use_pkgloc = $conf->exists('tax-pkg_address');
1395 my $date_where = '';
1397 $date_where .= " AND cust_bill._date >= $opt{s}";
1400 $date_where .= " AND cust_bill._date < $opt{e}";
1403 my $commit_each_invoice = 1 unless $opt{X};
1405 # if an invoice has either of these kinds of objects, then it doesn't
1406 # need to be upgraded...probably
1407 my $sub_has_tax_link = 'SELECT 1 FROM cust_bill_pkg_tax_location'.
1408 ' JOIN cust_bill_pkg USING (billpkgnum)'.
1409 ' WHERE cust_bill_pkg.invnum = cust_bill.invnum';
1410 my $sub_has_exempt = 'SELECT 1 FROM cust_tax_exempt_pkg'.
1411 ' JOIN cust_bill_pkg USING (billpkgnum)'.
1412 ' WHERE cust_bill_pkg.invnum = cust_bill.invnum'.
1413 ' AND exempt_monthly IS NULL';
1415 my %all_tax_names = (
1418 map { $_->taxname => 1 }
1419 qsearch('h_cust_main_county', { taxname => { op => '!=', value => '' }})
1422 my $search = FS::Cursor->new({
1423 table => 'cust_bill',
1425 extra_sql => "WHERE NOT EXISTS($sub_has_tax_link) ".
1426 "AND NOT EXISTS($sub_has_exempt) ".
1430 #print "Processing ".scalar(@invnums)." invoices...\n";
1434 while (my $cust_bill = $search->fetch) {
1435 my $invnum = $cust_bill->invnum;
1437 print STDERR "Invoice #$invnum\n";
1439 my %pkgpart_taxclass; # pkgpart => taxclass
1440 my %pkgpart_exempt_setup;
1441 my %pkgpart_exempt_recur;
1442 my $h_cust_bill = qsearchs('h_cust_bill',
1443 { invnum => $invnum,
1444 history_action => 'insert' });
1445 if (!$h_cust_bill) {
1446 warn "no insert record for invoice $invnum; skipped\n";
1447 #$date = $cust_bill->_date as a fallback?
1448 # We're trying to avoid using non-real dates (-d/-y invoice dates)
1449 # when looking up history records in other tables.
1452 my $custnum = $h_cust_bill->custnum;
1454 # Determine the address corresponding to this tax region.
1455 # It's either the bill or ship address of the customer as of the
1456 # invoice date-of-insertion. (Not necessarily the invoice date.)
1457 my $date = $h_cust_bill->history_date;
1458 local($FS::Record::qsearch_qualify_columns) = 0;
1459 my $h_cust_main = qsearchs('h_cust_main',
1460 { custnum => $custnum },
1461 FS::h_cust_main->sql_h_searchs($date)
1463 if (!$h_cust_main ) {
1464 warn "no historical address for cust#".$h_cust_bill->custnum."; skipped\n";
1466 # fallback to current $cust_main? sounds dangerous.
1469 # This is a historical customer record, so it has a historical address.
1470 # If there's no cust_location matching this custnum and address (there
1471 # probably isn't), create one.
1472 my %tax_loc; # keys are pkgnums, values are cust_location objects
1473 my $default_tax_loc;
1474 if ( $h_cust_main->bill_locationnum ) {
1475 # the location has already been upgraded
1477 $default_tax_loc = $h_cust_main->ship_location;
1479 $default_tax_loc = $h_cust_main->bill_location;
1482 $pre = 'ship_' if $use_ship and length($h_cust_main->get('ship_last'));
1483 my %hash = map { $_ => $h_cust_main->get($pre.$_) }
1484 FS::cust_main->location_fields;
1485 # not really needed for this, and often result in duplicate locations
1486 delete @hash{qw(censustract censusyear latitude longitude coord_auto)};
1488 $hash{custnum} = $h_cust_main->custnum;
1489 $default_tax_loc = FS::cust_location->new(\%hash);
1490 my $error = $default_tax_loc->find_or_insert || $default_tax_loc->disable_if_unused;
1492 warn "couldn't create historical location record for cust#".
1493 $h_cust_main->custnum.": $error\n";
1498 $exempt_cust = 1 if $h_cust_main->tax;
1500 # classify line items
1502 my %nontax_items; # taxclass => array of cust_bill_pkg
1503 foreach my $item ($h_cust_bill->cust_bill_pkg) {
1504 my $pkgnum = $item->pkgnum;
1506 if ( $pkgnum == 0 ) {
1508 push @tax_items, $item;
1511 # (pkgparts really shouldn't change, right?)
1512 local($FS::Record::qsearch_qualify_columns) = 0;
1513 my $h_cust_pkg = qsearchs('h_cust_pkg', { pkgnum => $pkgnum },
1514 FS::h_cust_pkg->sql_h_searchs($date)
1516 if ( !$h_cust_pkg ) {
1517 warn "no historical package #".$item->pkgpart."; skipped\n";
1520 my $pkgpart = $h_cust_pkg->pkgpart;
1522 if ( $use_pkgloc and $h_cust_pkg->locationnum ) {
1523 # then this package already had a locationnum assigned, and that's
1524 # the one to use for tax calculation
1525 $tax_loc{$pkgnum} = FS::cust_location->by_key($h_cust_pkg->locationnum);
1527 # use the customer's bill or ship loc, which was inserted earlier
1528 $tax_loc{$pkgnum} = $default_tax_loc;
1531 if (!exists $pkgpart_taxclass{$pkgpart}) {
1532 local($FS::Record::qsearch_qualify_columns) = 0;
1533 my $h_part_pkg = qsearchs('h_part_pkg', { pkgpart => $pkgpart },
1534 FS::h_part_pkg->sql_h_searchs($date)
1536 if ( !$h_part_pkg ) {
1537 warn "no historical package def #$pkgpart; skipped\n";
1540 $pkgpart_taxclass{$pkgpart} = $h_part_pkg->taxclass || '';
1541 $pkgpart_exempt_setup{$pkgpart} = 1 if $h_part_pkg->setuptax;
1542 $pkgpart_exempt_recur{$pkgpart} = 1 if $h_part_pkg->recurtax;
1545 # mark any exemptions that apply
1546 if ( $pkgpart_exempt_setup{$pkgpart} ) {
1547 $item->set('exempt_setup' => 1);
1550 if ( $pkgpart_exempt_recur{$pkgpart} ) {
1551 $item->set('exempt_recur' => 1);
1554 my $taxclass = $pkgpart_taxclass{ $pkgpart };
1556 $nontax_items{$taxclass} ||= [];
1557 push @{ $nontax_items{$taxclass} }, $item;
1561 printf("%d tax items: \$%.2f\n", scalar(@tax_items), map {$_->setup} @tax_items)
1564 # Get any per-customer taxname exemptions that were in effect.
1565 my %exempt_cust_taxname;
1566 foreach (keys %all_tax_names) {
1567 local($FS::Record::qsearch_qualify_columns) = 0;
1568 my $h_exemption = qsearchs('h_cust_main_exemption', {
1569 'custnum' => $custnum,
1572 FS::h_cust_main_exemption->sql_h_searchs($date, $date)
1575 $exempt_cust_taxname{ $_ } = 1;
1579 # Use a variation on the procedure in
1580 # FS::cust_main::Billing::_handle_taxes to identify taxes that apply
1582 my @loc_keys = qw( district city county state country );
1583 my %taxdef_by_name; # by name, and then by taxclass
1584 my %est_tax; # by name, and then by taxclass
1585 my %taxable_items; # by taxnum, and then an array
1587 foreach my $taxclass (keys %nontax_items) {
1588 foreach my $orig_item (@{ $nontax_items{$taxclass} }) {
1589 my $my_tax_loc = $tax_loc{ $orig_item->pkgnum };
1590 my %myhash = map { $_ => $my_tax_loc->get($pre.$_) } @loc_keys;
1591 my @elim = qw( district city county state );
1592 my @taxdefs; # because there may be several with different taxnames
1594 $myhash{taxclass} = $taxclass;
1595 @taxdefs = qsearch('cust_main_county', \%myhash);
1597 $myhash{taxclass} = '';
1598 @taxdefs = qsearch('cust_main_county', \%myhash);
1600 $myhash{ shift @elim } = '';
1601 } while scalar(@elim) and !@taxdefs;
1603 foreach my $taxdef (@taxdefs) {
1604 next if $taxdef->tax == 0;
1605 $taxdef_by_name{$taxdef->taxname}{$taxdef->taxclass} = $taxdef;
1607 $taxable_items{$taxdef->taxnum} ||= [];
1608 # clone the item so that taxdef-dependent changes don't
1609 # change it for other taxdefs
1610 my $item = FS::cust_bill_pkg->new({ $orig_item->hash });
1612 # these flags are already set if the part_pkg declares itself exempt
1613 $item->set('exempt_setup' => 1) if $taxdef->setuptax;
1614 $item->set('exempt_recur' => 1) if $taxdef->recurtax;
1617 my $taxable = $item->setup + $item->recur;
1619 # h_cust_credit_bill_pkg?
1620 # NO. Because if these exemptions HAD been created at the time of
1621 # billing, and then a credit applied later, the exemption would
1622 # have been adjusted by the amount of the credit. So we adjust
1623 # the taxable amount before creating the exemption.
1624 # But don't deduct the credit from taxable, because the tax was
1625 # calculated before the credit was applied.
1626 foreach my $f (qw(setup recur)) {
1627 my $credited = FS::Record->scalar_sql(
1628 "SELECT SUM(amount) FROM cust_credit_bill_pkg ".
1629 "WHERE billpkgnum = ? AND setuprecur = ?",
1633 $item->set($f, $item->get($f) - $credited) if $credited;
1635 my $existing_exempt = FS::Record->scalar_sql(
1636 "SELECT SUM(amount) FROM cust_tax_exempt_pkg WHERE ".
1637 "billpkgnum = ? AND taxnum = ?",
1638 $item->billpkgnum, $taxdef->taxnum
1640 $taxable -= $existing_exempt;
1642 if ( $taxable and $exempt_cust ) {
1643 push @new_exempt, { exempt_cust => 'Y', amount => $taxable };
1646 if ( $taxable and $exempt_cust_taxname{$taxdef->taxname} ){
1647 push @new_exempt, { exempt_cust_taxname => 'Y', amount => $taxable };
1650 if ( $taxable and $item->exempt_setup ) {
1651 push @new_exempt, { exempt_setup => 'Y', amount => $item->setup };
1652 $taxable -= $item->setup;
1654 if ( $taxable and $item->exempt_recur ) {
1655 push @new_exempt, { exempt_recur => 'Y', amount => $item->recur };
1656 $taxable -= $item->recur;
1659 $item->set('taxable' => $taxable);
1660 push @{ $taxable_items{$taxdef->taxnum} }, $item
1663 # estimate the amount of tax (this is necessary because different
1664 # taxdefs with the same taxname may have different tax rates)
1665 # and sum that for each taxname/taxclass combination
1667 $est_tax{$taxdef->taxname} ||= {};
1668 $est_tax{$taxdef->taxname}{$taxdef->taxclass} ||= 0;
1669 $est_tax{$taxdef->taxname}{$taxdef->taxclass} +=
1670 $taxable * $taxdef->tax;
1672 foreach (@new_exempt) {
1673 next if $_->{amount} == 0;
1674 my $cust_tax_exempt_pkg = FS::cust_tax_exempt_pkg->new({
1676 billpkgnum => $item->billpkgnum,
1677 taxnum => $taxdef->taxnum,
1679 my $error = $cust_tax_exempt_pkg->insert;
1681 my $pkgnum = $item->pkgnum;
1682 warn "error creating tax exemption for inv$invnum pkg$pkgnum:".
1686 } #foreach @new_exempt
1689 } #foreach $taxclass
1691 # Now go through the billed taxes and match them up with the line items.
1692 TAX_ITEM: foreach my $tax_item ( @tax_items )
1694 my $taxname = $tax_item->itemdesc;
1695 $taxname = '' if $taxname eq 'Tax';
1697 if ( !exists( $taxdef_by_name{$taxname} ) ) {
1698 # then we didn't find any applicable taxes with this name
1699 warn "no definition found for tax item '$taxname', custnum $custnum\n";
1700 # possibly all of these should be "next TAX_ITEM", but whole invoices
1701 # are transaction protected and we can go back and retry them.
1704 # classname => cust_main_county
1705 my %taxdef_by_class = %{ $taxdef_by_name{$taxname} };
1707 # Divide the tax item among taxclasses, if necessary
1708 # classname => estimated tax amount
1709 my $this_est_tax = $est_tax{$taxname};
1710 if (!defined $this_est_tax) {
1711 warn "no taxable sales found for inv#$invnum, tax item '$taxname'.\n";
1714 my $est_total = sum(values %$this_est_tax);
1715 if ( $est_total == 0 ) {
1717 warn "estimated tax on invoice #$invnum is zero.\n";
1721 my $real_tax = $tax_item->setup;
1722 printf ("Distributing \$%.2f tax:\n", $real_tax);
1723 my $cents_remaining = $real_tax * 100; # for rounding error
1724 my @tax_links; # partial CBPTL hashrefs
1725 foreach my $taxclass (keys %taxdef_by_class) {
1726 my $taxdef = $taxdef_by_class{$taxclass};
1727 # these items already have "taxable" set to their charge amount
1728 # after applying any credits or exemptions
1729 my @items = @{ $taxable_items{$taxdef->taxnum} };
1730 my $subtotal = sum(map {$_->get('taxable')} @items);
1731 printf("\t$taxclass: %.2f\n", $this_est_tax->{$taxclass}/$est_total);
1733 foreach my $nontax (@items) {
1734 my $my_tax_loc = $tax_loc{ $nontax->pkgnum };
1735 my $part = int($real_tax
1737 * ($this_est_tax->{$taxclass}/$est_total)
1739 * ($nontax->get('taxable'))/$subtotal
1743 $cents_remaining -= $part;
1745 taxnum => $taxdef->taxnum,
1746 pkgnum => $nontax->pkgnum,
1747 locationnum => $my_tax_loc->locationnum,
1748 billpkgnum => $nontax->billpkgnum,
1752 } #foreach $taxclass
1753 # Distribute any leftover tax round-robin style, one cent at a time.
1755 my $nlinks = scalar(@tax_links);
1757 # ensure that it really is an integer
1758 $cents_remaining = sprintf('%.0f', $cents_remaining);
1759 while ($cents_remaining > 0) {
1760 $tax_links[$i % $nlinks]->{cents} += 1;
1765 warn "Can't create tax links--no taxable items found.\n";
1769 # Gather credit/payment applications so that we can link them
1772 qsearch( 'cust_credit_bill_pkg',
1773 { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1775 qsearch( 'cust_bill_pay_pkg',
1776 { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1780 # grab the first one
1781 my $this_unlinked = shift @unlinked;
1782 my $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1784 # Create tax links (yay!)
1785 printf("Creating %d tax links.\n",scalar(@tax_links));
1786 foreach (@tax_links) {
1787 my $link = FS::cust_bill_pkg_tax_location->new({
1788 billpkgnum => $tax_item->billpkgnum,
1789 taxtype => 'FS::cust_main_county',
1790 locationnum => $_->{locationnum},
1791 taxnum => $_->{taxnum},
1792 pkgnum => $_->{pkgnum},
1793 amount => sprintf('%.2f', $_->{cents} / 100),
1794 taxable_billpkgnum => $_->{billpkgnum},
1796 my $error = $link->insert;
1798 warn "Can't create tax link for inv#$invnum: $error\n";
1802 my $link_cents = $_->{cents};
1803 # update/create subitem links
1805 # If $this_unlinked is undef, then we've allocated all of the
1806 # credit/payment applications to the tax item. If $link_cents is 0,
1807 # then we've applied credits/payments to all of this package fraction,
1808 # so go on to the next.
1809 while ($this_unlinked and $link_cents) {
1810 # apply as much as possible of $link_amount to this credit/payment
1812 my $apply_cents = min($link_cents, $unlinked_cents);
1813 $link_cents -= $apply_cents;
1814 $unlinked_cents -= $apply_cents;
1815 # $link_cents or $unlinked_cents or both are now zero
1816 $this_unlinked->set('amount' => sprintf('%.2f',$apply_cents/100));
1817 $this_unlinked->set('billpkgtaxlocationnum' => $link->billpkgtaxlocationnum);
1818 my $pkey = $this_unlinked->primary_key; #creditbillpkgnum or billpaypkgnum
1819 if ( $this_unlinked->$pkey ) {
1820 # then it's an existing link--replace it
1821 $error = $this_unlinked->replace;
1823 $this_unlinked->insert;
1825 # what do we do with errors at this stage?
1827 warn "Error creating tax application link: $error\n";
1828 next INVOICE; # for lack of a better idea
1831 if ( $unlinked_cents == 0 ) {
1832 # then we've allocated all of this payment/credit application,
1833 # so grab the next one
1834 $this_unlinked = shift @unlinked;
1835 $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1836 } elsif ( $link_cents == 0 ) {
1837 # then we've covered all of this package tax fraction, so split
1838 # off a new application from this one
1839 $this_unlinked = $this_unlinked->new({
1840 $this_unlinked->hash,
1843 # $unlinked_cents is still what it is
1846 } #while $this_unlinked and $link_cents
1847 } #foreach (@tax_links)
1848 } #foreach $tax_item
1850 $dbh->commit if $commit_each_invoice and $oldAutoCommit;
1856 $dbh->rollback if $oldAutoCommit;
1857 die "Upgrade halted.\n" unless $commit_each_invoice;
1861 $dbh->commit if $oldAutoCommit and !$commit_each_invoice;
1866 # Return an array of hashrefs for each cust_bill_pkg_tax_location
1867 # applied to this bill for this cust_bill_pkg.pkgnum.
1870 # In some situations, this list will contain more tax records than the
1871 # ones directly related to $self->billpkgnum. The returned list contains
1872 # all records, for this bill, charged against this billpkgnum's pkgnum.
1874 # One must keep this in mind when using data returned by this method.
1876 # An unaddressed deficiency in the cust_bill_pkg_tax_location model makes
1877 # this necessary: When a linked-hidden package generates a tax/fee as a row
1878 # in cust_bill_pkg_tax_location, there is not enough information to surmise
1879 # with specificity which billpkgnum row represents the direct parent of the
1880 # the linked-hidden package's tax row. The closest we can get to this
1881 # backwards reassociation is to use the pkgnum. Therefore, when multiple
1882 # billpkgnum's appear with the same pkgnum, this method is going to return
1883 # the tax records for ALL of those billpkgnum's, not just $self->billpkgnum.
1885 # This could be addressed with an update to the model, and to the billing
1886 # routine that generates rows into cust_bill_pkg_tax_location. Perhaps a
1887 # column, link_billpkgnum or parent_billpkgnum, recording the link. I'm not
1888 # doing that now, because there would be no possible repair of data stored
1889 # historically prior to such a fix. I need _pkg_tax_list() to not be
1890 # broken for already-generated bills.
1892 # Any code you write relying on _pkg_tax_list() MUST be aware of, and
1893 # account for, the possible return of duplicated tax records returned
1894 # when method is called on multiple cust_bill_pkg_tax_location rows.
1895 # Duplicates can be identified by billpkgtaxlocationnum column.
1899 my $search_selector;
1900 if ( $self->pkgnum ) {
1902 # For taxes applied to normal billing items
1904 ' cust_bill_pkg_tax_location.pkgnum = '
1905 . dbh->quote( $self->pkgnum );
1907 } elsif ( $self->feepart ) {
1909 # For taxes applied to fees, when the fee is not attached to a package
1910 # i.e. late fees, billing events fees
1912 ' cust_bill_pkg_tax_location.taxable_billpkgnum = '
1913 . dbh->quote( $self->billpkgnum );
1916 warn "_pkg_tax_list() unhandled case breaking taxes into sections";
1917 warn "_pkg_tax_list() $_: ".$self->$_
1918 for qw(pkgnum billpkgnum feepart);
1923 billpkgtaxlocationnum => $_->billpkgtaxlocationnum,
1924 billpkgnum => $_->billpkgnum,
1925 taxnum => $_->taxnum,
1926 amount => $_->amount,
1927 taxname => $_->taxname,
1930 table => 'cust_bill_pkg_tax_location',
1932 LEFT JOIN cust_bill_pkg
1933 ON cust_bill_pkg.billpkgnum
1934 = cust_bill_pkg_tax_location.taxable_billpkgnum
1936 select => join( ', ', (qw|
1937 cust_bill_pkg.billpkgnum
1938 cust_bill_pkg_tax_location.billpkgtaxlocationnum
1939 cust_bill_pkg_tax_location.taxnum
1940 cust_bill_pkg_tax_location.amount
1944 ' cust_bill_pkg.invnum = ' . dbh->quote( $self->invnum ) .
1952 # Create a queue job to run upgrade_tax_location from January 1, 2012 to
1956 use Date::Parse 'str2time';
1959 my $upgrade = 'tax_location_2012';
1960 return if FS::upgrade_journal->is_done($upgrade);
1961 my $job = FS::queue->new({
1962 'job' => 'FS::cust_bill_pkg::upgrade_tax_location'
1964 # call it kind of like a class method, not that it matters much
1965 $job->insert($class, 's' => str2time('2012-01-01'));
1966 # if there's a customer location upgrade queued also, wait for it to
1968 my $location_job = qsearchs('queue', {
1969 job => 'FS::cust_main::Location::process_upgrade_location'
1971 if ( $location_job ) {
1972 $job->depend_insert($location_job->jobnum);
1974 # Then mark the upgrade as done, so that we don't queue the job twice
1975 # and somehow run two of them concurrently.
1976 FS::upgrade_journal->set_done($upgrade);
1977 # This upgrade now does the job of assigning taxable_billpkgnums to
1978 # cust_bill_pkg_tax_location, so set that task done also.
1979 FS::upgrade_journal->set_done('tax_location_taxable_billpkgnum');
1986 setup and recur shouldn't be separate fields. There should be one "amount"
1987 field and a flag to tell you if it is a setup/one-time fee or a recurring fee.
1989 A line item with both should really be two separate records (preserving
1990 sdate and edate for setup fees for recurring packages - that information may
1991 be valuable later). Invoice generation (cust_main::bill), invoice printing
1992 (cust_bill), tax reports (report_tax.cgi) and line item reports
1993 (cust_bill_pkg.cgi) would need to be updated.
1995 owed_setup and owed_recur could then be repaced by just owed, and
1996 cust_bill::open_cust_bill_pkg and
1997 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
1999 The upgrade procedure is pretty sketchy.
2003 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
2004 from the base documentation.