1 package FS::cust_bill_pkg;
2 use base qw( FS::TemplateItem_Mixin FS::cust_main_Mixin FS::Record );
5 use vars qw( @ISA $DEBUG $me );
7 use List::Util qw( sum min );
9 use FS::Record qw( qsearch qsearchs dbh );
12 use FS::cust_bill_pkg_detail;
13 use FS::cust_bill_pkg_display;
14 use FS::cust_bill_pkg_discount;
15 use FS::cust_bill_pkg_fee;
16 use FS::cust_bill_pay_pkg;
17 use FS::cust_credit_bill_pkg;
18 use FS::cust_tax_exempt_pkg;
19 use FS::cust_bill_pkg_tax_location;
20 use FS::cust_bill_pkg_tax_rate_location;
21 use FS::cust_tax_adjustment;
22 use FS::cust_bill_pkg_void;
23 use FS::cust_bill_pkg_detail_void;
24 use FS::cust_bill_pkg_display_void;
25 use FS::cust_bill_pkg_discount_void;
26 use FS::cust_bill_pkg_tax_location_void;
27 use FS::cust_bill_pkg_tax_rate_location_void;
28 use FS::cust_tax_exempt_pkg_void;
34 $me = '[FS::cust_bill_pkg]';
38 FS::cust_bill_pkg - Object methods for cust_bill_pkg records
42 use FS::cust_bill_pkg;
44 $record = new FS::cust_bill_pkg \%hash;
45 $record = new FS::cust_bill_pkg { 'column' => 'value' };
47 $error = $record->insert;
49 $error = $record->check;
53 An FS::cust_bill_pkg object represents an invoice line item.
54 FS::cust_bill_pkg inherits from FS::Record. The following fields are
65 invoice (see L<FS::cust_bill>)
69 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)
71 =item pkgpart_override
73 optional package definition (see L<FS::part_pkg>) override
85 starting date of recurring fee
89 ending date of recurring fee
93 Line item description (overrides normal package description)
97 If not set, defaults to 1
101 If not set, defaults to setup
105 If not set, defaults to recur
109 If set to Y, indicates data should not appear as separate line item on invoice
113 sdate and edate are specified as UNIX timestamps; see L<perlfunc/"time">. Also
114 see L<Time::Local> and L<Date::Parse> for conversion functions.
122 Creates a new line item. To add the line item to the database, see
123 L<"insert">. Line items are normally created by calling the bill method of a
124 customer object (see L<FS::cust_main>).
128 sub table { 'cust_bill_pkg'; }
130 sub detail_table { 'cust_bill_pkg_detail'; }
131 sub display_table { 'cust_bill_pkg_display'; }
132 sub discount_table { 'cust_bill_pkg_discount'; }
133 #sub tax_location_table { 'cust_bill_pkg_tax_location'; }
134 #sub tax_rate_location_table { 'cust_bill_pkg_tax_rate_location'; }
135 #sub tax_exempt_pkg_table { 'cust_tax_exempt_pkg'; }
139 Adds this line item to the database. If there is an error, returns the error,
140 otherwise returns false.
147 local $SIG{HUP} = 'IGNORE';
148 local $SIG{INT} = 'IGNORE';
149 local $SIG{QUIT} = 'IGNORE';
150 local $SIG{TERM} = 'IGNORE';
151 local $SIG{TSTP} = 'IGNORE';
152 local $SIG{PIPE} = 'IGNORE';
154 my $oldAutoCommit = $FS::UID::AutoCommit;
155 local $FS::UID::AutoCommit = 0;
158 my $error = $self->SUPER::insert;
160 $dbh->rollback if $oldAutoCommit;
164 if ( $self->get('details') ) {
165 foreach my $detail ( @{$self->get('details')} ) {
166 $detail->billpkgnum($self->billpkgnum);
167 $error = $detail->insert;
169 $dbh->rollback if $oldAutoCommit;
170 return "error inserting cust_bill_pkg_detail: $error";
175 if ( $self->get('display') ) {
176 foreach my $cust_bill_pkg_display ( @{ $self->get('display') } ) {
177 $cust_bill_pkg_display->billpkgnum($self->billpkgnum);
178 $error = $cust_bill_pkg_display->insert;
180 $dbh->rollback if $oldAutoCommit;
181 return "error inserting cust_bill_pkg_display: $error";
186 if ( $self->get('discounts') ) {
187 foreach my $cust_bill_pkg_discount ( @{$self->get('discounts')} ) {
188 $cust_bill_pkg_discount->billpkgnum($self->billpkgnum);
189 $error = $cust_bill_pkg_discount->insert;
191 $dbh->rollback if $oldAutoCommit;
192 return "error inserting cust_bill_pkg_discount: $error";
197 foreach my $cust_tax_exempt_pkg ( @{$self->cust_tax_exempt_pkg} ) {
198 $cust_tax_exempt_pkg->billpkgnum($self->billpkgnum);
199 $error = $cust_tax_exempt_pkg->insert;
201 $dbh->rollback if $oldAutoCommit;
202 return "error inserting cust_tax_exempt_pkg: $error";
206 foreach my $tax_link_table (qw(cust_bill_pkg_tax_location
207 cust_bill_pkg_tax_rate_location))
209 my $tax_location = $self->get($tax_link_table) || [];
210 foreach my $link ( @$tax_location ) {
211 my $pkey = $link->primary_key;
212 next if $link->get($pkey); # don't try to double-insert
213 # This cust_bill_pkg can be linked on either side (i.e. it can be the
214 # tax or the taxed item). If the other side is already inserted,
215 # then set billpkgnum to ours, and insert the link. Otherwise,
216 # set billpkgnum to ours and pass the link off to the cust_bill_pkg
217 # on the other side, to be inserted later.
219 my $tax_cust_bill_pkg = $link->get('tax_cust_bill_pkg');
220 if ( $tax_cust_bill_pkg && $tax_cust_bill_pkg->billpkgnum ) {
221 $link->set('billpkgnum', $tax_cust_bill_pkg->billpkgnum);
222 # break circular links when doing this
223 $link->set('tax_cust_bill_pkg', '');
225 my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg');
226 if ( $taxable_cust_bill_pkg && $taxable_cust_bill_pkg->billpkgnum ) {
227 $link->set('taxable_billpkgnum', $taxable_cust_bill_pkg->billpkgnum);
228 # XXX pkgnum is zero for tax on tax; it might be better to use
229 # the underlying package?
230 $link->set('pkgnum', $taxable_cust_bill_pkg->pkgnum);
231 $link->set('locationnum', $taxable_cust_bill_pkg->tax_locationnum);
232 $link->set('taxable_cust_bill_pkg', '');
235 if ( $link->billpkgnum and $link->taxable_billpkgnum ) {
236 $error = $link->insert;
238 $dbh->rollback if $oldAutoCommit;
239 return "error inserting cust_bill_pkg_tax_location: $error";
243 $other = $link->billpkgnum ? $link->get('taxable_cust_bill_pkg')
244 : $link->get('tax_cust_bill_pkg');
245 my $link_array = $other->get('cust_bill_pkg_tax_location') || [];
246 push @$link_array, $link;
247 $other->set('cust_bill_pkg_tax_location' => $link_array);
252 # someday you will be as awesome as cust_bill_pkg_tax_location...
253 # and today is that day
254 #my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location');
255 #if ( $tax_rate_location ) {
256 # foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) {
257 # $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum);
258 # $error = $cust_bill_pkg_tax_rate_location->insert;
260 # $dbh->rollback if $oldAutoCommit;
261 # return "error inserting cust_bill_pkg_tax_rate_location: $error";
266 my $fee_links = $self->get('cust_bill_pkg_fee');
268 foreach my $link ( @$fee_links ) {
269 # very similar to cust_bill_pkg_tax_location, for obvious reasons
270 next if $link->billpkgfeenum; # don't try to double-insert
272 my $target = $link->get('cust_bill_pkg'); # the line item of the fee
273 my $base = $link->get('base_cust_bill_pkg'); # line item it was based on
275 if ( $target and $target->billpkgnum ) {
276 $link->set('billpkgnum', $target->billpkgnum);
277 # base_invnum => null indicates that the fee is based on its own
279 $link->set('base_invnum', $target->invnum) unless $link->base_invnum;
280 $link->set('cust_bill_pkg', '');
283 if ( $base and $base->billpkgnum ) {
284 $link->set('base_billpkgnum', $base->billpkgnum);
285 $link->set('base_cust_bill_pkg', '');
287 # it's based on a line item that's not yet inserted
288 my $link_array = $base->get('cust_bill_pkg_fee') || [];
289 push @$link_array, $link;
290 $base->set('cust_bill_pkg_fee' => $link_array);
291 next; # don't insert the link yet
294 $error = $link->insert;
296 $dbh->rollback if $oldAutoCommit;
297 return "error inserting cust_bill_pkg_fee: $error";
302 my $cust_event_fee = $self->get('cust_event_fee');
303 if ( $cust_event_fee ) {
304 $cust_event_fee->set('billpkgnum' => $self->billpkgnum);
305 $error = $cust_event_fee->replace;
307 $dbh->rollback if $oldAutoCommit;
308 return "error updating cust_event_fee: $error";
312 my $cust_tax_adjustment = $self->get('cust_tax_adjustment');
313 if ( $cust_tax_adjustment ) {
314 $cust_tax_adjustment->billpkgnum($self->billpkgnum);
315 $error = $cust_tax_adjustment->replace;
317 $dbh->rollback if $oldAutoCommit;
318 return "error replacing cust_tax_adjustment: $error";
322 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
329 Voids this line item: deletes the line item and adds a record of the voided
330 line item to the FS::cust_bill_pkg_void table (and related tables).
336 my $reason = scalar(@_) ? shift : '';
338 local $SIG{HUP} = 'IGNORE';
339 local $SIG{INT} = 'IGNORE';
340 local $SIG{QUIT} = 'IGNORE';
341 local $SIG{TERM} = 'IGNORE';
342 local $SIG{TSTP} = 'IGNORE';
343 local $SIG{PIPE} = 'IGNORE';
345 my $oldAutoCommit = $FS::UID::AutoCommit;
346 local $FS::UID::AutoCommit = 0;
349 my $cust_bill_pkg_void = new FS::cust_bill_pkg_void ( {
350 map { $_ => $self->get($_) } $self->fields
352 $cust_bill_pkg_void->reason($reason);
353 my $error = $cust_bill_pkg_void->insert;
355 $dbh->rollback if $oldAutoCommit;
359 foreach my $table (qw(
361 cust_bill_pkg_display
362 cust_bill_pkg_discount
363 cust_bill_pkg_tax_location
364 cust_bill_pkg_tax_rate_location
368 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
370 my $vclass = 'FS::'.$table.'_void';
371 my $void = $vclass->new( {
372 map { $_ => $linked->get($_) } $linked->fields
374 my $error = $void->insert || $linked->delete;
376 $dbh->rollback if $oldAutoCommit;
384 $error = $self->delete;
386 $dbh->rollback if $oldAutoCommit;
390 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
405 local $SIG{HUP} = 'IGNORE';
406 local $SIG{INT} = 'IGNORE';
407 local $SIG{QUIT} = 'IGNORE';
408 local $SIG{TERM} = 'IGNORE';
409 local $SIG{TSTP} = 'IGNORE';
410 local $SIG{PIPE} = 'IGNORE';
412 my $oldAutoCommit = $FS::UID::AutoCommit;
413 local $FS::UID::AutoCommit = 0;
416 foreach my $table (qw(
418 cust_bill_pkg_display
419 cust_bill_pkg_discount
420 cust_bill_pkg_tax_location
421 cust_bill_pkg_tax_rate_location
427 foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
428 my $error = $linked->delete;
430 $dbh->rollback if $oldAutoCommit;
437 foreach my $cust_tax_adjustment (
438 qsearch('cust_tax_adjustment', { billpkgnum=>$self->billpkgnum })
440 $cust_tax_adjustment->billpkgnum(''); #NULL
441 my $error = $cust_tax_adjustment->replace;
443 $dbh->rollback if $oldAutoCommit;
448 my $error = $self->SUPER::delete(@_);
450 $dbh->rollback if $oldAutoCommit;
454 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
460 #alas, bin/follow-tax-rename
462 #=item replace OLD_RECORD
464 #Currently unimplemented. This would be even more of an accounting nightmare
465 #than deleteing the items. Just don't do it.
470 # return "Can't modify cust_bill_pkg records!";
475 Checks all fields to make sure this is a valid line item. If there is an
476 error, returns the error, otherwise returns false. Called by the insert
485 $self->ut_numbern('billpkgnum')
486 || $self->ut_snumber('pkgnum')
487 || $self->ut_number('invnum')
488 || $self->ut_money('setup')
489 || $self->ut_money('recur')
490 || $self->ut_numbern('sdate')
491 || $self->ut_numbern('edate')
492 || $self->ut_textn('itemdesc')
493 || $self->ut_textn('itemcomment')
494 || $self->ut_enum('hidden', [ '', 'Y' ])
496 return $error if $error;
498 $self->regularize_details;
500 #if ( $self->pkgnum != 0 ) { #allow unchecked pkgnum 0 for tax! (add to part_pkg?)
501 if ( $self->pkgnum > 0 ) { #allow -1 for non-pkg line items and 0 for tax (add to part_pkg?)
502 return "Unknown pkgnum ". $self->pkgnum
503 unless qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
506 return "Unknown invnum"
507 unless qsearchs( 'cust_bill' ,{ 'invnum' => $self->invnum } );
512 =item regularize_details
514 Converts the contents of the 'details' pseudo-field to
515 L<FS::cust_bill_pkg_detail> objects, if they aren't already.
519 sub regularize_details {
521 if ( $self->get('details') ) {
522 foreach my $detail ( @{$self->get('details')} ) {
523 if ( ref($detail) ne 'FS::cust_bill_pkg_detail' ) {
524 # then turn it into one
526 if ( ! ref($detail) ) {
527 $hash{'detail'} = $detail;
529 elsif ( ref($detail) eq 'HASH' ) {
532 elsif ( ref($detail) eq 'ARRAY' ) {
533 carp "passing invoice details as arrays is deprecated";
534 #carp "this way sucks, use a hash"; #but more useful/friendly
535 $hash{'format'} = $detail->[0];
536 $hash{'detail'} = $detail->[1];
537 $hash{'amount'} = $detail->[2];
538 $hash{'classnum'} = $detail->[3];
539 $hash{'phonenum'} = $detail->[4];
540 $hash{'accountcode'} = $detail->[5];
541 $hash{'startdate'} = $detail->[6];
542 $hash{'duration'} = $detail->[7];
543 $hash{'regionname'} = $detail->[8];
546 die "unknown detail type ". ref($detail);
548 $detail = new FS::cust_bill_pkg_detail \%hash;
550 $detail->billpkgnum($self->billpkgnum) if $self->billpkgnum;
556 =item set_exemptions TAXOBJECT, OPTIONS
558 Sets up tax exemptions. TAXOBJECT is the L<FS::cust_main_county> or
559 L<FS::tax_rate> record for the tax.
561 This will deal with the following cases:
565 =item Fully exempt customers (cust_main.tax flag) or customer classes
568 =item Customers exempt from specific named taxes (cust_main_exemption
571 =item Taxes that don't apply to setup or recurring fees
572 (cust_main_county.setuptax and recurtax, tax_rate.setuptax and recurtax).
574 =item Packages that are marked as tax-exempt (part_pkg.setuptax,
577 =item Fees that aren't marked as taxable (part_fee.taxable).
581 It does NOT deal with monthly tax exemptions, which need more context
582 than this humble little method cares to deal with.
584 OPTIONS should include "custnum" => the customer number if this tax line
585 hasn't been inserted (which it probably hasn't).
587 Returns a list of exemption objects, which will also be attached to the
588 line item as the 'cust_tax_exempt_pkg' pseudo-field. Inserting the line
589 item will insert these records as well.
598 my $part_pkg = $self->part_pkg;
599 my $part_fee = $self->part_fee;
602 my $custnum = $opt{custnum};
603 $custnum ||= $self->cust_bill->custnum if $self->cust_bill;
605 $cust_main = FS::cust_main->by_key( $custnum )
606 or die "set_exemptions can't identify customer (pass custnum option)\n";
609 my $taxable_charged = $self->setup + $self->recur;
610 return unless $taxable_charged > 0;
612 ### Fully exempt customer ###
614 my $conf = FS::Conf->new;
615 if ( $conf->exists('cust_class-tax_exempt') ) {
616 my $cust_class = $cust_main->cust_class;
617 $exempt_cust = $cust_class->tax if $cust_class;
619 $exempt_cust = $cust_main->tax;
622 ### Exemption from named tax ###
623 my $exempt_cust_taxname;
624 if ( !$exempt_cust and $tax->taxname ) {
625 $exempt_cust_taxname = $cust_main->tax_exemption($tax->taxname);
628 if ( $exempt_cust ) {
630 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
631 amount => $taxable_charged,
634 $taxable_charged = 0;
636 } elsif ( $exempt_cust_taxname ) {
638 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
639 amount => $taxable_charged,
640 exempt_cust_taxname => 'Y',
642 $taxable_charged = 0;
646 my $exempt_setup = ( ($part_fee and not $part_fee->taxable)
647 or ($part_pkg and $part_pkg->setuptax)
652 and $taxable_charged > 0 ) {
654 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
655 amount => $self->setup,
658 $taxable_charged -= $self->setup;
662 my $exempt_recur = ( ($part_fee and not $part_fee->taxable)
663 or ($part_pkg and $part_pkg->recurtax)
668 and $taxable_charged > 0 ) {
670 push @new_exemptions, FS::cust_tax_exempt_pkg->new({
671 amount => $self->recur,
674 $taxable_charged -= $self->recur;
678 foreach (@new_exemptions) {
679 $_->set('taxnum', $tax->taxnum);
680 $_->set('taxtype', ref($tax));
683 push @{ $self->cust_tax_exempt_pkg }, @new_exemptions;
684 return @new_exemptions;
690 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
696 qsearchs( 'cust_bill', { 'invnum' => $self->invnum } );
701 Returns the customer (L<FS::cust_main> object) for this line item.
706 # required for cust_main_Mixin equivalence
707 # and use cust_bill instead of cust_pkg because this might not have a
710 my $cust_bill = $self->cust_bill or return '';
711 $cust_bill->cust_main;
714 =item previous_cust_bill_pkg
716 Returns the previous cust_bill_pkg for this package, if any.
720 sub previous_cust_bill_pkg {
722 return unless $self->sdate;
724 'table' => 'cust_bill_pkg',
725 'hashref' => { 'pkgnum' => $self->pkgnum,
726 'sdate' => { op=>'<', value=>$self->sdate },
728 'order_by' => 'ORDER BY sdate DESC LIMIT 1',
734 Returns the amount owed (still outstanding) on this line item's setup fee,
735 which is the amount of the line item minus all payment applications (see
736 L<FS::cust_bill_pay_pkg> and credit applications (see
737 L<FS::cust_credit_bill_pkg>).
743 $self->owed('setup', @_);
748 Returns the amount owed (still outstanding) on this line item's recurring fee,
749 which is the amount of the line item minus all payment applications (see
750 L<FS::cust_bill_pay_pkg> and credit applications (see
751 L<FS::cust_credit_bill_pkg>).
757 $self->owed('recur', @_);
760 # modeled after cust_bill::owed...
762 my( $self, $field ) = @_;
763 my $balance = $self->$field();
764 $balance -= $_->amount foreach ( $self->cust_bill_pay_pkg($field) );
765 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
766 $balance = sprintf( '%.2f', $balance );
767 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
773 my( $self, $field ) = @_;
774 my $balance = $self->$field();
775 $balance -= $_->amount foreach ( $self->cust_credit_bill_pkg($field) );
776 $balance = sprintf( '%.2f', $balance );
777 $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp
781 sub cust_bill_pay_pkg {
782 my( $self, $field ) = @_;
783 qsearch( 'cust_bill_pay_pkg', { 'billpkgnum' => $self->billpkgnum,
784 'setuprecur' => $field,
789 sub cust_credit_bill_pkg {
790 my( $self, $field ) = @_;
791 qsearch( 'cust_credit_bill_pkg', { 'billpkgnum' => $self->billpkgnum,
792 'setuprecur' => $field,
799 Returns the number of billing units (for tax purposes) represented by this,
806 $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
811 If this item has any discounts, returns a hashref in the format used
812 by L<FS::Template_Mixin/_items_cust_bill_pkg> to describe the discount(s)
813 on an invoice. This will contain the keys 'description', 'amount',
814 'ext_description' (an arrayref of text lines describing the discounts),
815 and '_is_discount' (a flag).
817 The value for 'amount' will be negative, and will be scaled for the package
824 my @pkg_discounts = $self->pkg_discount;
825 return if @pkg_discounts == 0;
826 # special case: if there are old "discount details" on this line item, don't
827 # show discount line items
828 if ( FS::cust_bill_pkg_detail->count("detail LIKE 'Includes discount%' AND billpkgnum = ?", $self->billpkgnum || 0) > 0 ) {
835 description => $self->mt('Discount'),
837 ext_description => \@ext,
838 # maybe should show quantity/unit discount?
840 foreach my $pkg_discount (@pkg_discounts) {
841 push @ext, $pkg_discount->description;
842 $d->{amount} -= $pkg_discount->amount;
844 $d->{amount} *= $self->quantity || 1;
849 =item set_display OPTION => VALUE ...
851 A helper method for I<insert>, populates the pseudo-field B<display> with
852 appropriate FS::cust_bill_pkg_display objects.
854 Options are passed as a list of name/value pairs. Options are:
856 part_pkg: FS::part_pkg object from this line item's package.
858 real_pkgpart: if this line item comes from a bundled package, the pkgpart
859 of the owning package. Otherwise the same as the part_pkg's pkgpart above.
864 my( $self, %opt ) = @_;
865 my $part_pkg = $opt{'part_pkg'};
866 my $cust_pkg = new FS::cust_pkg { pkgpart => $opt{real_pkgpart} };
868 my $conf = new FS::Conf;
870 # whether to break this down into setup/recur/usage
871 my $separate = $conf->exists('separate_usage');
873 my $usage_mandate = $part_pkg->option('usage_mandate', 'Hush!')
874 || $cust_pkg->part_pkg->option('usage_mandate', 'Hush!');
876 # or use the category from $opt{'part_pkg'} if its not bundled?
877 my $categoryname = $cust_pkg->part_pkg->categoryname;
879 # if we don't have to separate setup/recur/usage, or put this in a
880 # package-specific section, or display a usage summary, then don't
881 # even create one of these. The item will just display in the unnamed
882 # section as a single line plus details.
883 return $self->set('display', [])
884 unless $separate || $categoryname || $usage_mandate;
888 my %hash = ( 'section' => $categoryname );
890 # whether to put usage details in a separate section, and if so, which one
891 my $usage_section = $part_pkg->option('usage_section', 'Hush!')
892 || $cust_pkg->part_pkg->option('usage_section', 'Hush!');
894 # whether to show a usage summary line (total usage charges, no details)
895 my $summary = $part_pkg->option('summarize_usage', 'Hush!')
896 || $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
899 # create lines for setup and (non-usage) recur, in the main section
900 push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
901 push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
903 # display everything in a single line
904 push @display, new FS::cust_bill_pkg_display
907 # and if usage_mandate is enabled, hide details
908 # (this only works on multisection invoices...)
909 ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
913 if ($separate && $usage_section && $summary) {
914 # create a line for the usage summary in the main section
915 push @display, new FS::cust_bill_pkg_display { type => 'U',
921 if ($usage_mandate || ($usage_section && $summary) ) {
922 $hash{post_total} = 'Y';
925 if ($separate || $usage_mandate) {
926 # show call details for this line item in the usage section.
927 # if usage_mandate is on, this will display below the section subtotal.
928 # this also happens if usage is in a separate section and there's a
929 # summary in the main section, though I'm not sure why.
930 $hash{section} = $usage_section if $usage_section;
931 push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
934 $self->set('display', \@display);
940 Returns a hash: keys are "setup", "recur" or usage classnum, values are
941 FS::cust_bill_pkg objects, each with no more than a single class (setup or
948 # XXX this goes away with cust_bill_pkg refactor
949 # or at least I wish it would, but it turns out to be harder than
952 #my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash }; # wha huh?
953 my %cust_bill_pkg = ();
956 foreach my $classnum ($self->usage_classes) {
957 next if $classnum eq ''; # null-class usage is included in 'recur'
958 my $amount = $self->usage($classnum);
959 next if $amount == 0; # though if so we shouldn't be here
960 my $usage_item = FS::cust_bill_pkg->new({
964 'taxclass' => $classnum,
967 $cust_bill_pkg{$classnum} = $usage_item;
968 $usage_total += $amount;
971 foreach (qw(setup recur)) {
972 next if ($self->get($_) == 0);
973 my $item = FS::cust_bill_pkg->new({
980 $item->set($_, $self->get($_));
981 $cust_bill_pkg{$_} = $item;
985 $cust_bill_pkg{recur}->set('recur',
986 sprintf('%.2f', $cust_bill_pkg{recur}->get('recur') - $usage_total)
995 Returns the amount of the charge associated with usage class CLASSNUM if
996 CLASSNUM is defined. Otherwise returns the total charge associated with
1002 my( $self, $classnum ) = @_;
1003 $self->regularize_details;
1005 if ( $self->get('details') ) {
1008 map { $_->amount || 0 }
1009 grep { !defined($classnum) or $classnum eq $_->classnum }
1010 @{ $self->get('details') }
1015 my $sql = 'SELECT SUM(COALESCE(amount,0)) FROM cust_bill_pkg_detail '.
1016 ' WHERE billpkgnum = '. $self->billpkgnum;
1017 if ($classnum =~ /^(\d+)$/) {
1018 $sql .= " AND classnum = $1";
1019 } elsif (defined($classnum) and $classnum eq '') {
1020 $sql .= " AND classnum IS NULL";
1023 my $sth = dbh->prepare($sql) or die dbh->errstr;
1024 $sth->execute or die $sth->errstr;
1026 return $sth->fetchrow_arrayref->[0] || 0;
1034 Returns a list of usage classnums associated with this invoice line's
1041 $self->regularize_details;
1043 if ( $self->get('details') ) {
1045 my %seen = ( map { $_->classnum => 1 } @{ $self->get('details') } );
1050 map { $_->classnum }
1051 qsearch({ table => 'cust_bill_pkg_detail',
1052 hashref => { billpkgnum => $self->billpkgnum },
1053 select => 'DISTINCT classnum',
1060 sub cust_tax_exempt_pkg {
1063 my $array = $self->{Hash}->{cust_tax_exempt_pkg} ||= [];
1066 =item cust_bill_pkg_fee
1068 Returns the list of associated cust_bill_pkg_fee objects, if this is
1073 sub cust_bill_pkg_fee {
1075 qsearch('cust_bill_pkg_fee', { billpkgnum => $self->billpkgnum });
1078 =item cust_bill_pkg_tax_Xlocation
1080 Returns the list of associated cust_bill_pkg_tax_location and/or
1081 cust_bill_pkg_tax_rate_location objects
1085 sub cust_bill_pkg_tax_Xlocation {
1088 my %hash = ( 'billpkgnum' => $self->billpkgnum );
1091 qsearch ( 'cust_bill_pkg_tax_location', { %hash } ),
1092 qsearch ( 'cust_bill_pkg_tax_rate_location', { %hash } )
1097 =item recur_show_zero
1101 sub recur_show_zero { shift->_X_show_zero('recur'); }
1102 sub setup_show_zero { shift->_X_show_zero('setup'); }
1105 my( $self, $what ) = @_;
1107 return 0 unless $self->$what() == 0 && $self->pkgnum;
1109 $self->cust_pkg->_X_show_zero($what);
1112 =item credited [ BEFORE, AFTER, OPTIONS ]
1114 Returns the sum of credits applied to this item. Arguments are the same as
1115 owed_sql/paid_sql/credited_sql.
1121 $self->scalar_sql('SELECT '. $self->credited_sql(@_).' FROM cust_bill_pkg WHERE billpkgnum = ?', $self->billpkgnum);
1124 =item tax_locationnum
1126 Returns the L<FS::cust_location> number that this line item is in for tax
1127 purposes. For package sales, it's the package tax location; for fees,
1128 it's the customer's default service location.
1132 sub tax_locationnum {
1134 if ( $self->pkgnum ) { # normal sales
1135 return $self->cust_pkg->tax_locationnum;
1136 } elsif ( $self->feepart and $self->invnum ) { # fees
1137 return $self->cust_bill->cust_main->ship_locationnum;
1145 if ( $self->pkgnum ) { # normal sales
1146 return $self->cust_pkg->tax_location;
1147 } elsif ( $self->feepart and $self->invnum ) { # fees
1148 return $self->cust_bill->cust_main->ship_location;
1156 Returns the L<FS::part_pkg> or L<FS::part_fee> object that defines this
1157 charge. If called on a tax line, returns nothing.
1163 if ( $self->pkgpart_override ) {
1164 return FS::part_pkg->by_key($self->pkgpart_override);
1165 } elsif ( $self->pkgnum ) {
1166 return $self->cust_pkg->part_pkg;
1167 } elsif ( $self->feepart ) {
1168 return $self->part_fee;
1179 ? FS::part_fee->by_key($self->feepart)
1185 =head1 CLASS METHODS
1191 Returns an SQL expression for the total usage charges in details on
1197 '(SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0)
1198 FROM cust_bill_pkg_detail
1199 WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum)';
1201 sub usage_sql { $usage_sql }
1203 # this makes owed_sql, etc. much more concise
1205 my ($class, $start, $end, %opt) = @_;
1206 my $setuprecur = $opt{setuprecur} || '';
1208 $setuprecur =~ /^s/ ? 'cust_bill_pkg.setup' :
1209 $setuprecur =~ /^r/ ? 'cust_bill_pkg.recur' :
1210 'cust_bill_pkg.setup + cust_bill_pkg.recur';
1212 if ($opt{no_usage} and $charged =~ /recur/) {
1213 $charged = "$charged - $usage_sql"
1220 =item owed_sql [ BEFORE, AFTER, OPTIONS ]
1222 Returns an SQL expression for the amount owed. BEFORE and AFTER specify
1223 a date window. OPTIONS may include 'no_usage' (excludes usage charges)
1224 and 'setuprecur' (set to "setup" or "recur" to limit to one or the other).
1230 '(' . $class->charged_sql(@_) .
1231 ' - ' . $class->paid_sql(@_) .
1232 ' - ' . $class->credited_sql(@_) . ')'
1235 =item paid_sql [ BEFORE, AFTER, OPTIONS ]
1237 Returns an SQL expression for the sum of payments applied to this item.
1242 my ($class, $start, $end, %opt) = @_;
1243 my $s = $start ? "AND cust_pay._date <= $start" : '';
1244 my $e = $end ? "AND cust_pay._date > $end" : '';
1245 my $setuprecur = $opt{setuprecur} || '';
1246 $setuprecur = 'setup' if $setuprecur =~ /^s/;
1247 $setuprecur = 'recur' if $setuprecur =~ /^r/;
1248 $setuprecur &&= "AND setuprecur = '$setuprecur'";
1250 my $paid = "( SELECT COALESCE(SUM(cust_bill_pay_pkg.amount),0)
1251 FROM cust_bill_pay_pkg JOIN cust_bill_pay USING (billpaynum)
1252 JOIN cust_pay USING (paynum)
1253 WHERE cust_bill_pay_pkg.billpkgnum = cust_bill_pkg.billpkgnum
1254 $s $e $setuprecur )";
1256 if ( $opt{no_usage} ) {
1257 # cap the amount paid at the sum of non-usage charges,
1258 # minus the amount credited against non-usage charges
1260 $class->charged_sql($start, $end, %opt) . ' - ' .
1261 $class->credited_sql($start, $end, %opt).')';
1270 my ($class, $start, $end, %opt) = @_;
1271 my $s = $start ? "AND cust_credit._date <= $start" : '';
1272 my $e = $end ? "AND cust_credit._date > $end" : '';
1273 my $setuprecur = $opt{setuprecur} || '';
1274 $setuprecur = 'setup' if $setuprecur =~ /^s/;
1275 $setuprecur = 'recur' if $setuprecur =~ /^r/;
1276 $setuprecur &&= "AND setuprecur = '$setuprecur'";
1278 my $credited = "( SELECT COALESCE(SUM(cust_credit_bill_pkg.amount),0)
1279 FROM cust_credit_bill_pkg JOIN cust_credit_bill USING (creditbillnum)
1280 JOIN cust_credit USING (crednum)
1281 WHERE cust_credit_bill_pkg.billpkgnum = cust_bill_pkg.billpkgnum
1282 $s $e $setuprecur )";
1284 if ( $opt{no_usage} ) {
1285 # cap the amount credited at the sum of non-usage charges
1286 "LEAST($credited, ". $class->charged_sql($start, $end, %opt).')';
1294 sub upgrade_tax_location {
1295 # For taxes that were calculated/invoiced before cust_location refactoring
1296 # (May-June 2012), there are no cust_bill_pkg_tax_location records unless
1297 # they were calculated on a package-location basis. Create them here,
1298 # along with any necessary cust_location records and any tax exemption
1301 my ($class, %opt) = @_;
1302 # %opt may include 's' and 'e': start and end date ranges
1303 # and 'X': abort on any error, instead of just rolling back changes to
1306 my $oldAutoCommit = $FS::UID::AutoCommit;
1307 local $FS::UID::AutoCommit = 0;
1310 use FS::h_cust_main;
1311 use FS::h_cust_bill;
1313 use FS::h_cust_main_exemption;
1316 local $FS::cust_location::import = 1;
1318 my $conf = FS::Conf->new; # h_conf?
1319 return if $conf->exists('enable_taxproducts'); #don't touch this case
1320 my $use_ship = $conf->exists('tax-ship_address');
1321 my $use_pkgloc = $conf->exists('tax-pkg_address');
1323 my $date_where = '';
1325 $date_where .= " AND cust_bill._date >= $opt{s}";
1328 $date_where .= " AND cust_bill._date < $opt{e}";
1331 my $commit_each_invoice = 1 unless $opt{X};
1333 # if an invoice has either of these kinds of objects, then it doesn't
1334 # need to be upgraded...probably
1335 my $sub_has_tax_link = 'SELECT 1 FROM cust_bill_pkg_tax_location'.
1336 ' JOIN cust_bill_pkg USING (billpkgnum)'.
1337 ' WHERE cust_bill_pkg.invnum = cust_bill.invnum';
1338 my $sub_has_exempt = 'SELECT 1 FROM cust_tax_exempt_pkg'.
1339 ' JOIN cust_bill_pkg USING (billpkgnum)'.
1340 ' WHERE cust_bill_pkg.invnum = cust_bill.invnum'.
1341 ' AND exempt_monthly IS NULL';
1343 my %all_tax_names = (
1346 map { $_->taxname => 1 }
1347 qsearch('h_cust_main_county', { taxname => { op => '!=', value => '' }})
1350 my $search = FS::Cursor->new({
1351 table => 'cust_bill',
1353 extra_sql => "WHERE NOT EXISTS($sub_has_tax_link) ".
1354 "AND NOT EXISTS($sub_has_exempt) ".
1358 #print "Processing ".scalar(@invnums)." invoices...\n";
1362 while (my $cust_bill = $search->fetch) {
1363 my $invnum = $cust_bill->invnum;
1365 print STDERR "Invoice #$invnum\n";
1367 my %pkgpart_taxclass; # pkgpart => taxclass
1368 my %pkgpart_exempt_setup;
1369 my %pkgpart_exempt_recur;
1370 my $h_cust_bill = qsearchs('h_cust_bill',
1371 { invnum => $invnum,
1372 history_action => 'insert' });
1373 if (!$h_cust_bill) {
1374 warn "no insert record for invoice $invnum; skipped\n";
1375 #$date = $cust_bill->_date as a fallback?
1376 # We're trying to avoid using non-real dates (-d/-y invoice dates)
1377 # when looking up history records in other tables.
1380 my $custnum = $h_cust_bill->custnum;
1382 # Determine the address corresponding to this tax region.
1383 # It's either the bill or ship address of the customer as of the
1384 # invoice date-of-insertion. (Not necessarily the invoice date.)
1385 my $date = $h_cust_bill->history_date;
1386 my $h_cust_main = qsearchs('h_cust_main',
1387 { custnum => $custnum },
1388 FS::h_cust_main->sql_h_searchs($date)
1390 if (!$h_cust_main ) {
1391 warn "no historical address for cust#".$h_cust_bill->custnum."; skipped\n";
1393 # fallback to current $cust_main? sounds dangerous.
1396 # This is a historical customer record, so it has a historical address.
1397 # If there's no cust_location matching this custnum and address (there
1398 # probably isn't), create one.
1399 my %tax_loc; # keys are pkgnums, values are cust_location objects
1400 my $default_tax_loc;
1401 if ( $h_cust_main->bill_locationnum ) {
1402 # the location has already been upgraded
1404 $default_tax_loc = $h_cust_main->ship_location;
1406 $default_tax_loc = $h_cust_main->bill_location;
1409 $pre = 'ship_' if $use_ship and length($h_cust_main->get('ship_last'));
1410 my %hash = map { $_ => $h_cust_main->get($pre.$_) }
1411 FS::cust_main->location_fields;
1412 # not really needed for this, and often result in duplicate locations
1413 delete @hash{qw(censustract censusyear latitude longitude coord_auto)};
1415 $hash{custnum} = $h_cust_main->custnum;
1416 $default_tax_loc = FS::cust_location->new(\%hash);
1417 my $error = $default_tax_loc->find_or_insert || $default_tax_loc->disable_if_unused;
1419 warn "couldn't create historical location record for cust#".
1420 $h_cust_main->custnum.": $error\n";
1425 $exempt_cust = 1 if $h_cust_main->tax;
1427 # classify line items
1429 my %nontax_items; # taxclass => array of cust_bill_pkg
1430 foreach my $item ($h_cust_bill->cust_bill_pkg) {
1431 my $pkgnum = $item->pkgnum;
1433 if ( $pkgnum == 0 ) {
1435 push @tax_items, $item;
1438 # (pkgparts really shouldn't change, right?)
1439 my $h_cust_pkg = qsearchs('h_cust_pkg', { pkgnum => $pkgnum },
1440 FS::h_cust_pkg->sql_h_searchs($date)
1442 if ( !$h_cust_pkg ) {
1443 warn "no historical package #".$item->pkgpart."; skipped\n";
1446 my $pkgpart = $h_cust_pkg->pkgpart;
1448 if ( $use_pkgloc and $h_cust_pkg->locationnum ) {
1449 # then this package already had a locationnum assigned, and that's
1450 # the one to use for tax calculation
1451 $tax_loc{$pkgnum} = FS::cust_location->by_key($h_cust_pkg->locationnum);
1453 # use the customer's bill or ship loc, which was inserted earlier
1454 $tax_loc{$pkgnum} = $default_tax_loc;
1457 if (!exists $pkgpart_taxclass{$pkgpart}) {
1458 my $h_part_pkg = qsearchs('h_part_pkg', { pkgpart => $pkgpart },
1459 FS::h_part_pkg->sql_h_searchs($date)
1461 if ( !$h_part_pkg ) {
1462 warn "no historical package def #$pkgpart; skipped\n";
1465 $pkgpart_taxclass{$pkgpart} = $h_part_pkg->taxclass || '';
1466 $pkgpart_exempt_setup{$pkgpart} = 1 if $h_part_pkg->setuptax;
1467 $pkgpart_exempt_recur{$pkgpart} = 1 if $h_part_pkg->recurtax;
1470 # mark any exemptions that apply
1471 if ( $pkgpart_exempt_setup{$pkgpart} ) {
1472 $item->set('exempt_setup' => 1);
1475 if ( $pkgpart_exempt_recur{$pkgpart} ) {
1476 $item->set('exempt_recur' => 1);
1479 my $taxclass = $pkgpart_taxclass{ $pkgpart };
1481 $nontax_items{$taxclass} ||= [];
1482 push @{ $nontax_items{$taxclass} }, $item;
1486 printf("%d tax items: \$%.2f\n", scalar(@tax_items), map {$_->setup} @tax_items)
1489 # Get any per-customer taxname exemptions that were in effect.
1490 my %exempt_cust_taxname;
1491 foreach (keys %all_tax_names) {
1492 my $h_exemption = qsearchs('h_cust_main_exemption', {
1493 'custnum' => $custnum,
1496 FS::h_cust_main_exemption->sql_h_searchs($date, $date)
1499 $exempt_cust_taxname{ $_ } = 1;
1503 # Use a variation on the procedure in
1504 # FS::cust_main::Billing::_handle_taxes to identify taxes that apply
1506 my @loc_keys = qw( district city county state country );
1507 my %taxdef_by_name; # by name, and then by taxclass
1508 my %est_tax; # by name, and then by taxclass
1509 my %taxable_items; # by taxnum, and then an array
1511 foreach my $taxclass (keys %nontax_items) {
1512 foreach my $orig_item (@{ $nontax_items{$taxclass} }) {
1513 my $my_tax_loc = $tax_loc{ $orig_item->pkgnum };
1514 my %myhash = map { $_ => $my_tax_loc->get($pre.$_) } @loc_keys;
1515 my @elim = qw( district city county state );
1516 my @taxdefs; # because there may be several with different taxnames
1518 $myhash{taxclass} = $taxclass;
1519 @taxdefs = qsearch('cust_main_county', \%myhash);
1521 $myhash{taxclass} = '';
1522 @taxdefs = qsearch('cust_main_county', \%myhash);
1524 $myhash{ shift @elim } = '';
1525 } while scalar(@elim) and !@taxdefs;
1527 foreach my $taxdef (@taxdefs) {
1528 next if $taxdef->tax == 0;
1529 $taxdef_by_name{$taxdef->taxname}{$taxdef->taxclass} = $taxdef;
1531 $taxable_items{$taxdef->taxnum} ||= [];
1532 # clone the item so that taxdef-dependent changes don't
1533 # change it for other taxdefs
1534 my $item = FS::cust_bill_pkg->new({ $orig_item->hash });
1536 # these flags are already set if the part_pkg declares itself exempt
1537 $item->set('exempt_setup' => 1) if $taxdef->setuptax;
1538 $item->set('exempt_recur' => 1) if $taxdef->recurtax;
1541 my $taxable = $item->setup + $item->recur;
1543 # h_cust_credit_bill_pkg?
1544 # NO. Because if these exemptions HAD been created at the time of
1545 # billing, and then a credit applied later, the exemption would
1546 # have been adjusted by the amount of the credit. So we adjust
1547 # the taxable amount before creating the exemption.
1548 # But don't deduct the credit from taxable, because the tax was
1549 # calculated before the credit was applied.
1550 foreach my $f (qw(setup recur)) {
1551 my $credited = FS::Record->scalar_sql(
1552 "SELECT SUM(amount) FROM cust_credit_bill_pkg ".
1553 "WHERE billpkgnum = ? AND setuprecur = ?",
1557 $item->set($f, $item->get($f) - $credited) if $credited;
1559 my $existing_exempt = FS::Record->scalar_sql(
1560 "SELECT SUM(amount) FROM cust_tax_exempt_pkg WHERE ".
1561 "billpkgnum = ? AND taxnum = ?",
1562 $item->billpkgnum, $taxdef->taxnum
1564 $taxable -= $existing_exempt;
1566 if ( $taxable and $exempt_cust ) {
1567 push @new_exempt, { exempt_cust => 'Y', amount => $taxable };
1570 if ( $taxable and $exempt_cust_taxname{$taxdef->taxname} ){
1571 push @new_exempt, { exempt_cust_taxname => 'Y', amount => $taxable };
1574 if ( $taxable and $item->exempt_setup ) {
1575 push @new_exempt, { exempt_setup => 'Y', amount => $item->setup };
1576 $taxable -= $item->setup;
1578 if ( $taxable and $item->exempt_recur ) {
1579 push @new_exempt, { exempt_recur => 'Y', amount => $item->recur };
1580 $taxable -= $item->recur;
1583 $item->set('taxable' => $taxable);
1584 push @{ $taxable_items{$taxdef->taxnum} }, $item
1587 # estimate the amount of tax (this is necessary because different
1588 # taxdefs with the same taxname may have different tax rates)
1589 # and sum that for each taxname/taxclass combination
1591 $est_tax{$taxdef->taxname} ||= {};
1592 $est_tax{$taxdef->taxname}{$taxdef->taxclass} ||= 0;
1593 $est_tax{$taxdef->taxname}{$taxdef->taxclass} +=
1594 $taxable * $taxdef->tax;
1596 foreach (@new_exempt) {
1597 next if $_->{amount} == 0;
1598 my $cust_tax_exempt_pkg = FS::cust_tax_exempt_pkg->new({
1600 billpkgnum => $item->billpkgnum,
1601 taxnum => $taxdef->taxnum,
1603 my $error = $cust_tax_exempt_pkg->insert;
1605 my $pkgnum = $item->pkgnum;
1606 warn "error creating tax exemption for inv$invnum pkg$pkgnum:".
1610 } #foreach @new_exempt
1613 } #foreach $taxclass
1615 # Now go through the billed taxes and match them up with the line items.
1616 TAX_ITEM: foreach my $tax_item ( @tax_items )
1618 my $taxname = $tax_item->itemdesc;
1619 $taxname = '' if $taxname eq 'Tax';
1621 if ( !exists( $taxdef_by_name{$taxname} ) ) {
1622 # then we didn't find any applicable taxes with this name
1623 warn "no definition found for tax item '$taxname', custnum $custnum\n";
1624 # possibly all of these should be "next TAX_ITEM", but whole invoices
1625 # are transaction protected and we can go back and retry them.
1628 # classname => cust_main_county
1629 my %taxdef_by_class = %{ $taxdef_by_name{$taxname} };
1631 # Divide the tax item among taxclasses, if necessary
1632 # classname => estimated tax amount
1633 my $this_est_tax = $est_tax{$taxname};
1634 if (!defined $this_est_tax) {
1635 warn "no taxable sales found for inv#$invnum, tax item '$taxname'.\n";
1638 my $est_total = sum(values %$this_est_tax);
1639 if ( $est_total == 0 ) {
1641 warn "estimated tax on invoice #$invnum is zero.\n";
1645 my $real_tax = $tax_item->setup;
1646 printf ("Distributing \$%.2f tax:\n", $real_tax);
1647 my $cents_remaining = $real_tax * 100; # for rounding error
1648 my @tax_links; # partial CBPTL hashrefs
1649 foreach my $taxclass (keys %taxdef_by_class) {
1650 my $taxdef = $taxdef_by_class{$taxclass};
1651 # these items already have "taxable" set to their charge amount
1652 # after applying any credits or exemptions
1653 my @items = @{ $taxable_items{$taxdef->taxnum} };
1654 my $subtotal = sum(map {$_->get('taxable')} @items);
1655 printf("\t$taxclass: %.2f\n", $this_est_tax->{$taxclass}/$est_total);
1657 foreach my $nontax (@items) {
1658 my $my_tax_loc = $tax_loc{ $nontax->pkgnum };
1659 my $part = int($real_tax
1661 * ($this_est_tax->{$taxclass}/$est_total)
1663 * ($nontax->get('taxable'))/$subtotal
1667 $cents_remaining -= $part;
1669 taxnum => $taxdef->taxnum,
1670 pkgnum => $nontax->pkgnum,
1671 locationnum => $my_tax_loc->locationnum,
1672 billpkgnum => $nontax->billpkgnum,
1676 } #foreach $taxclass
1677 # Distribute any leftover tax round-robin style, one cent at a time.
1679 my $nlinks = scalar(@tax_links);
1681 # ensure that it really is an integer
1682 $cents_remaining = sprintf('%.0f', $cents_remaining);
1683 while ($cents_remaining > 0) {
1684 $tax_links[$i % $nlinks]->{cents} += 1;
1689 warn "Can't create tax links--no taxable items found.\n";
1693 # Gather credit/payment applications so that we can link them
1696 qsearch( 'cust_credit_bill_pkg',
1697 { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1699 qsearch( 'cust_bill_pay_pkg',
1700 { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
1704 # grab the first one
1705 my $this_unlinked = shift @unlinked;
1706 my $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1708 # Create tax links (yay!)
1709 printf("Creating %d tax links.\n",scalar(@tax_links));
1710 foreach (@tax_links) {
1711 my $link = FS::cust_bill_pkg_tax_location->new({
1712 billpkgnum => $tax_item->billpkgnum,
1713 taxtype => 'FS::cust_main_county',
1714 locationnum => $_->{locationnum},
1715 taxnum => $_->{taxnum},
1716 pkgnum => $_->{pkgnum},
1717 amount => sprintf('%.2f', $_->{cents} / 100),
1718 taxable_billpkgnum => $_->{billpkgnum},
1720 my $error = $link->insert;
1722 warn "Can't create tax link for inv#$invnum: $error\n";
1726 my $link_cents = $_->{cents};
1727 # update/create subitem links
1729 # If $this_unlinked is undef, then we've allocated all of the
1730 # credit/payment applications to the tax item. If $link_cents is 0,
1731 # then we've applied credits/payments to all of this package fraction,
1732 # so go on to the next.
1733 while ($this_unlinked and $link_cents) {
1734 # apply as much as possible of $link_amount to this credit/payment
1736 my $apply_cents = min($link_cents, $unlinked_cents);
1737 $link_cents -= $apply_cents;
1738 $unlinked_cents -= $apply_cents;
1739 # $link_cents or $unlinked_cents or both are now zero
1740 $this_unlinked->set('amount' => sprintf('%.2f',$apply_cents/100));
1741 $this_unlinked->set('billpkgtaxlocationnum' => $link->billpkgtaxlocationnum);
1742 my $pkey = $this_unlinked->primary_key; #creditbillpkgnum or billpaypkgnum
1743 if ( $this_unlinked->$pkey ) {
1744 # then it's an existing link--replace it
1745 $error = $this_unlinked->replace;
1747 $this_unlinked->insert;
1749 # what do we do with errors at this stage?
1751 warn "Error creating tax application link: $error\n";
1752 next INVOICE; # for lack of a better idea
1755 if ( $unlinked_cents == 0 ) {
1756 # then we've allocated all of this payment/credit application,
1757 # so grab the next one
1758 $this_unlinked = shift @unlinked;
1759 $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
1760 } elsif ( $link_cents == 0 ) {
1761 # then we've covered all of this package tax fraction, so split
1762 # off a new application from this one
1763 $this_unlinked = $this_unlinked->new({
1764 $this_unlinked->hash,
1767 # $unlinked_cents is still what it is
1770 } #while $this_unlinked and $link_cents
1771 } #foreach (@tax_links)
1772 } #foreach $tax_item
1774 $dbh->commit if $commit_each_invoice and $oldAutoCommit;
1780 $dbh->rollback if $oldAutoCommit;
1781 die "Upgrade halted.\n" unless $commit_each_invoice;
1785 $dbh->commit if $oldAutoCommit and !$commit_each_invoice;
1790 # Create a queue job to run upgrade_tax_location from January 1, 2012 to
1794 use Date::Parse 'str2time';
1797 my $upgrade = 'tax_location_2012';
1798 return if FS::upgrade_journal->is_done($upgrade);
1799 my $job = FS::queue->new({
1800 'job' => 'FS::cust_bill_pkg::upgrade_tax_location'
1802 # call it kind of like a class method, not that it matters much
1803 $job->insert($class, 's' => str2time('2012-01-01'));
1804 # if there's a customer location upgrade queued also, wait for it to
1806 my $location_job = qsearchs('queue', {
1807 job => 'FS::cust_main::Location::process_upgrade_location'
1809 if ( $location_job ) {
1810 $job->depend_insert($location_job->jobnum);
1812 # Then mark the upgrade as done, so that we don't queue the job twice
1813 # and somehow run two of them concurrently.
1814 FS::upgrade_journal->set_done($upgrade);
1815 # This upgrade now does the job of assigning taxable_billpkgnums to
1816 # cust_bill_pkg_tax_location, so set that task done also.
1817 FS::upgrade_journal->set_done('tax_location_taxable_billpkgnum');
1824 setup and recur shouldn't be separate fields. There should be one "amount"
1825 field and a flag to tell you if it is a setup/one-time fee or a recurring fee.
1827 A line item with both should really be two separate records (preserving
1828 sdate and edate for setup fees for recurring packages - that information may
1829 be valuable later). Invoice generation (cust_main::bill), invoice printing
1830 (cust_bill), tax reports (report_tax.cgi) and line item reports
1831 (cust_bill_pkg.cgi) would need to be updated.
1833 owed_setup and owed_recur could then be repaced by just owed, and
1834 cust_bill::open_cust_bill_pkg and
1835 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
1837 The upgrade procedure is pretty sketchy.
1841 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
1842 from the base documentation.