+ $item->set('taxable' => $taxable);
+ push @{ $taxable_items{$taxdef->taxnum} }, $item
+ if $taxable > 0;
+
+ # estimate the amount of tax (this is necessary because different
+ # taxdefs with the same taxname may have different tax rates)
+ # and sum that for each taxname/taxclass combination
+ # (in cents)
+ $est_tax{$taxdef->taxname} ||= {};
+ $est_tax{$taxdef->taxname}{$taxdef->taxclass} ||= 0;
+ $est_tax{$taxdef->taxname}{$taxdef->taxclass} +=
+ $taxable * $taxdef->tax;
+
+ foreach (@new_exempt) {
+ next if $_->{amount} == 0;
+ my $cust_tax_exempt_pkg = FS::cust_tax_exempt_pkg->new({
+ %$_,
+ billpkgnum => $item->billpkgnum,
+ taxnum => $taxdef->taxnum,
+ });
+ my $error = $cust_tax_exempt_pkg->insert;
+ if ($error) {
+ my $pkgnum = $item->pkgnum;
+ warn "error creating tax exemption for inv$invnum pkg$pkgnum:".
+ "\n$error\n\n";
+ next INVOICE;
+ }
+ } #foreach @new_exempt
+ } #foreach $taxdef
+ } #foreach $item
+ } #foreach $taxclass
+
+ # Now go through the billed taxes and match them up with the line items.
+ TAX_ITEM: foreach my $tax_item ( @tax_items )
+ {
+ my $taxname = $tax_item->itemdesc;
+ $taxname = '' if $taxname eq 'Tax';
+
+ if ( !exists( $taxdef_by_name{$taxname} ) ) {
+ # then we didn't find any applicable taxes with this name
+ warn "no definition found for tax item '$taxname', custnum $custnum\n";
+ # possibly all of these should be "next TAX_ITEM", but whole invoices
+ # are transaction protected and we can go back and retry them.
+ next INVOICE;
+ }
+ # classname => cust_main_county
+ my %taxdef_by_class = %{ $taxdef_by_name{$taxname} };
+
+ # Divide the tax item among taxclasses, if necessary
+ # classname => estimated tax amount
+ my $this_est_tax = $est_tax{$taxname};
+ if (!defined $this_est_tax) {
+ warn "no taxable sales found for inv#$invnum, tax item '$taxname'.\n";
+ next INVOICE;
+ }
+ my $est_total = sum(values %$this_est_tax);
+ if ( $est_total == 0 ) {
+ # shouldn't happen
+ warn "estimated tax on invoice #$invnum is zero.\n";
+ next INVOICE;
+ }
+
+ my $real_tax = $tax_item->setup;
+ printf ("Distributing \$%.2f tax:\n", $real_tax);
+ my $cents_remaining = $real_tax * 100; # for rounding error
+ my @tax_links; # partial CBPTL hashrefs
+ foreach my $taxclass (keys %taxdef_by_class) {
+ my $taxdef = $taxdef_by_class{$taxclass};
+ # these items already have "taxable" set to their charge amount
+ # after applying any credits or exemptions
+ my @items = @{ $taxable_items{$taxdef->taxnum} };
+ my $subtotal = sum(map {$_->get('taxable')} @items);
+ printf("\t$taxclass: %.2f\n", $this_est_tax->{$taxclass}/$est_total);
+
+ foreach my $nontax (@items) {
+ my $my_tax_loc = $tax_loc{ $nontax->pkgnum };
+ my $part = int($real_tax
+ # class allocation
+ * ($this_est_tax->{$taxclass}/$est_total)
+ # item allocation
+ * ($nontax->get('taxable'))/$subtotal
+ # convert to cents
+ * 100
+ );
+ $cents_remaining -= $part;
+ push @tax_links, {
+ taxnum => $taxdef->taxnum,
+ pkgnum => $nontax->pkgnum,
+ locationnum => $my_tax_loc->locationnum,
+ billpkgnum => $nontax->billpkgnum,
+ cents => $part,
+ };
+ } #foreach $nontax
+ } #foreach $taxclass
+ # Distribute any leftover tax round-robin style, one cent at a time.
+ my $i = 0;
+ my $nlinks = scalar(@tax_links);
+ if ( $nlinks ) {
+ # ensure that it really is an integer
+ $cents_remaining = sprintf('%.0f', $cents_remaining);
+ while ($cents_remaining > 0) {
+ $tax_links[$i % $nlinks]->{cents} += 1;
+ $cents_remaining--;
+ $i++;
+ }
+ } else {
+ warn "Can't create tax links--no taxable items found.\n";
+ next INVOICE;
+ }
+
+ # Gather credit/payment applications so that we can link them
+ # appropriately.
+ my @unlinked = (
+ qsearch( 'cust_credit_bill_pkg',
+ { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
+ ),
+ qsearch( 'cust_bill_pay_pkg',
+ { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
+ )
+ );
+
+ # grab the first one
+ my $this_unlinked = shift @unlinked;
+ my $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
+
+ # Create tax links (yay!)
+ printf("Creating %d tax links.\n",scalar(@tax_links));
+ foreach (@tax_links) {
+ my $link = FS::cust_bill_pkg_tax_location->new({
+ billpkgnum => $tax_item->billpkgnum,
+ taxtype => 'FS::cust_main_county',
+ locationnum => $_->{locationnum},
+ taxnum => $_->{taxnum},
+ pkgnum => $_->{pkgnum},
+ amount => sprintf('%.2f', $_->{cents} / 100),
+ taxable_billpkgnum => $_->{billpkgnum},
+ });
+ my $error = $link->insert;
+ if ( $error ) {
+ warn "Can't create tax link for inv#$invnum: $error\n";
+ next INVOICE;
+ }
+
+ my $link_cents = $_->{cents};
+ # update/create subitem links
+ #
+ # If $this_unlinked is undef, then we've allocated all of the
+ # credit/payment applications to the tax item. If $link_cents is 0,
+ # then we've applied credits/payments to all of this package fraction,
+ # so go on to the next.
+ while ($this_unlinked and $link_cents) {
+ # apply as much as possible of $link_amount to this credit/payment
+ # link
+ my $apply_cents = min($link_cents, $unlinked_cents);
+ $link_cents -= $apply_cents;
+ $unlinked_cents -= $apply_cents;
+ # $link_cents or $unlinked_cents or both are now zero
+ $this_unlinked->set('amount' => sprintf('%.2f',$apply_cents/100));
+ $this_unlinked->set('billpkgtaxlocationnum' => $link->billpkgtaxlocationnum);
+ my $pkey = $this_unlinked->primary_key; #creditbillpkgnum or billpaypkgnum
+ if ( $this_unlinked->$pkey ) {
+ # then it's an existing link--replace it
+ $error = $this_unlinked->replace;
+ } else {
+ $this_unlinked->insert;
+ }
+ # what do we do with errors at this stage?
+ if ( $error ) {
+ warn "Error creating tax application link: $error\n";
+ next INVOICE; # for lack of a better idea
+ }
+
+ if ( $unlinked_cents == 0 ) {
+ # then we've allocated all of this payment/credit application,
+ # so grab the next one
+ $this_unlinked = shift @unlinked;
+ $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
+ } elsif ( $link_cents == 0 ) {
+ # then we've covered all of this package tax fraction, so split
+ # off a new application from this one
+ $this_unlinked = $this_unlinked->new({
+ $this_unlinked->hash,
+ $pkey => '',
+ });
+ # $unlinked_cents is still what it is
+ }
+
+ } #while $this_unlinked and $link_cents
+ } #foreach (@tax_links)
+ } #foreach $tax_item
+
+ $dbh->commit if $commit_each_invoice and $oldAutoCommit;
+ $committed = 1;
+
+ } #foreach $invnum
+ continue {
+ if (!$committed) {
+ $dbh->rollback if $oldAutoCommit;
+ die "Upgrade halted.\n" unless $commit_each_invoice;
+ }
+ }
+
+ $dbh->commit if $oldAutoCommit and !$commit_each_invoice;
+ '';
+}
+
+sub _pkg_tax_list {
+ # Return an array of hashrefs for each cust_bill_pkg_tax_location
+ # applied to this bill for this cust_bill_pkg.pkgnum.
+ #
+ # ! Important Note:
+ # In some situations, this list will contain more tax records than the
+ # ones directly related to $self->billpkgnum. The returned list contains
+ # all records, for this bill, charged against this billpkgnum's pkgnum.
+ #
+ # One must keep this in mind when using data returned by this method.
+ #
+ # An unaddressed deficiency in the cust_bill_pkg_tax_location model makes
+ # this necessary: When a linked-hidden package generates a tax/fee as a row
+ # in cust_bill_pkg_tax_location, there is not enough information to surmise
+ # with specificity which billpkgnum row represents the direct parent of the
+ # the linked-hidden package's tax row. The closest we can get to this
+ # backwards reassociation is to use the pkgnum. Therefore, when multiple
+ # billpkgnum's appear with the same pkgnum, this method is going to return
+ # the tax records for ALL of those billpkgnum's, not just $self->billpkgnum.
+ #
+ # This could be addressed with an update to the model, and to the billing
+ # routine that generates rows into cust_bill_pkg_tax_location. Perhaps a
+ # column, link_billpkgnum or parent_billpkgnum, recording the link. I'm not
+ # doing that now, because there would be no possible repair of data stored
+ # historically prior to such a fix. I need _pkg_tax_list() to not be
+ # broken for already-generated bills.
+ #
+ # Any code you write relying on _pkg_tax_list() MUST be aware of, and
+ # account for, the possible return of duplicated tax records returned
+ # when method is called on multiple cust_bill_pkg_tax_location rows.
+ # Duplicates can be identified by billpkgtaxlocationnum column.
+
+ my $self = shift;
+ return unless $self->pkgnum;
+
+ map +{
+ billpkgtaxlocationnum => $_->billpkgtaxlocationnum,
+ billpkgnum => $_->billpkgnum,
+ taxnum => $_->taxnum,
+ amount => $_->amount,
+ taxname => $_->taxname,
+ },
+ qsearch({
+ table => 'cust_bill_pkg_tax_location',
+ addl_from => '
+ LEFT JOIN cust_bill_pkg
+ ON cust_bill_pkg.billpkgnum
+ = cust_bill_pkg_tax_location.taxable_billpkgnum
+ ',
+ select => join( ', ', (qw|
+ cust_bill_pkg.billpkgnum
+ cust_bill_pkg_tax_location.billpkgtaxlocationnum
+ cust_bill_pkg_tax_location.taxnum
+ cust_bill_pkg_tax_location.amount
+ |)),
+ extra_sql =>
+ ' WHERE '.
+ ' cust_bill_pkg.invnum = ' . dbh->quote( $self->invnum ) .
+ ' AND '.
+ ' cust_bill_pkg_tax_location.pkgnum = ' . dbh->quote( $self->pkgnum ),
+ });
+
+}
+
+sub _upgrade_data {
+ # Create a queue job to run upgrade_tax_location from January 1, 2012 to
+ # the present date.
+ eval {
+ use FS::queue;
+ use Date::Parse 'str2time';
+ };
+ my $class = shift;
+ my $upgrade = 'tax_location_2012';
+ return if FS::upgrade_journal->is_done($upgrade);
+ my $job = FS::queue->new({
+ 'job' => 'FS::cust_bill_pkg::upgrade_tax_location'
+ });
+ # call it kind of like a class method, not that it matters much
+ $job->insert($class, 's' => str2time('2012-01-01'));
+ # if there's a customer location upgrade queued also, wait for it to
+ # finish
+ my $location_job = qsearchs('queue', {
+ job => 'FS::cust_main::Location::process_upgrade_location'
+ });
+ if ( $location_job ) {
+ $job->depend_insert($location_job->jobnum);
+ }
+ # Then mark the upgrade as done, so that we don't queue the job twice
+ # and somehow run two of them concurrently.
+ FS::upgrade_journal->set_done($upgrade);
+ # This upgrade now does the job of assigning taxable_billpkgnums to
+ # cust_bill_pkg_tax_location, so set that task done also.
+ FS::upgrade_journal->set_done('tax_location_taxable_billpkgnum');