X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Ftax_rate.pm;h=8bc0c6ef3ff30d5db71c05c4e151e8aee42e908b;hp=ec3bb12b61aadd8967ddd83c927503400a3c91f7;hb=57bb423fe457ba4e13726877f53bcdf944f828f8;hpb=b962b3fbfd38f932061329e2872007b3854e8d35 diff --git a/FS/FS/tax_rate.pm b/FS/FS/tax_rate.pm index ec3bb12b6..8bc0c6ef3 100644 --- a/FS/FS/tax_rate.pm +++ b/FS/FS/tax_rate.pm @@ -18,6 +18,7 @@ use HTTP::Response; use DBIx::DBSchema; use DBIx::DBSchema::Table; use DBIx::DBSchema::Column; +use List::Util 'sum'; use FS::Record qw( qsearch qsearchs dbh dbdef ); use FS::Conf; use FS::tax_class; @@ -379,11 +380,21 @@ sub passtype_name { $tax_passtypes{$self->passtype}; } -=item taxline_cch TAXABLES, [ OPTIONSHASH ] +=item taxline_cch TAXABLES, CLASSES -Returns a listref of a name and an amount of tax calculated for the list -of packages/amounts referenced by TAXABLES. If an error occurs, a message -is returned as a scalar. +Takes an arrayref of L objects representing taxable line +items, and an arrayref of charge classes ('setup', 'recur', '' for +unclassified usage, or an L number). Calculates the tax on +each item under this tax definition and returns a list of new +L objects for the taxes charged. + +If the taxable objects are linked to an invoice, this will also calculate +per-customer exemptions (cust_exempt and cust_taxname_exempt) and attach them +to the line items in the 'cust_tax_exempt_pkg' pseudo-field. + +For accurate calculation of per-customer or per-location taxes, ALL items +appearing on the invoice (and subject to this tax) MUST be passed to this +method together, and NO items from any other invoice should be included. =cut @@ -392,44 +403,37 @@ sub taxline_cch { # this used to accept a hash of options but none of them did anything # so it's been removed. - my $taxables; - - if (ref($_[0]) eq 'ARRAY') { - $taxables = shift; - }else{ - $taxables = [ @_ ]; - #exemptions would be broken in this case - } + my $taxables = shift; + my $classes = shift || []; my $name = $self->taxname; $name = 'Other surcharges' if ($self->passtype == 2); my $amount = 0; - - if ( $self->disabled ) { # we always know how to handle disabled taxes - return { - 'name' => $name, - 'amount' => $amount, - }; - } + + return unless @$taxables; # nothing to do + return if $self->disabled; + return if $self->passflag eq 'N'; # tax can't be passed to the customer + # but should probably still appear on the liability report--create a + # cust_tax_exempt_pkg record for it? + + # in 4.x, the invoice is _already inserted_ before we try to calculate + # tax on it. though it may be a quotation, so be careful. + + my $cust_main; + my $cust_bill = $taxables->[0]->cust_bill; + $cust_main = $cust_bill->cust_main if $cust_bill; my $taxable_charged = 0; my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; } @$taxables; + my $taxratelocationnum = $self->tax_rate_location->taxratelocationnum; + warn "calculating taxes for ". $self->taxnum. " on ". join (",", map { $_->pkgnum } @cust_bill_pkg) if $DEBUG; - if ($self->passflag eq 'N') { - # return "fatal: can't (yet) handle taxes not passed to the customer"; - # until someone needs to track these in freeside - return { - 'name' => $name, - 'amount' => 0, - }; - } - my $maxtype = $self->maxtype || 0; if ($maxtype != 0 && $maxtype != 1 && $maxtype != 14 && $maxtype != 15 @@ -451,54 +455,146 @@ sub taxline_cch { $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' ); } - unless ($self->setuptax =~ /^Y$/i) { - $taxable_charged += $_->setup foreach @cust_bill_pkg; - } - unless ($self->recurtax =~ /^Y$/i) { - $taxable_charged += $_->recur foreach @cust_bill_pkg; - } + my @tax_links; # for output + my %seen; # locationnum or pkgnum => 1 + my $taxable_cents = 0; my $taxable_units = 0; - unless ($self->recurtax =~ /^Y$/i) { - - if (( $self->unittype || 0 ) == 0) { #access line - my %seen = (); - foreach (@cust_bill_pkg) { - $taxable_units += $_->units - unless $seen{$_->pkgnum}++; + my $tax_cents = 0; + + while (@$taxables) { + my $cust_bill_pkg = shift @$taxables; + my $class = shift @$classes; + $class = 'all' if !defined($class); + + my %usage_map = map { $_ => $cust_bill_pkg->usage($_) } + $cust_bill_pkg->usage_classes; + my $usage_total = sum( values(%usage_map), 0 ); + + # determine if the item has exemptions that apply to this tax def + my @exemptions = grep { $_->taxnum == $self->taxnum } + @{ $cust_bill_pkg->cust_tax_exempt_pkg }; + + if ( $self->tax > 0 ) { + + my $taxable_charged = 0; + if ($class eq 'all') { + $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur; + } elsif ($class eq 'setup') { + $taxable_charged = $cust_bill_pkg->setup; + } elsif ($class eq 'recur') { + $taxable_charged = $cust_bill_pkg->recur - $usage_total; + } else { + $taxable_charged = $usage_map{$class} || 0; } - } elsif ($self->unittype == 1) { #minute - return $self->_fatal_or_null( 'fee with minute unit type' ); - - } elsif ($self->unittype == 2) { #account + foreach my $ex (@exemptions) { + # the only cases where the exemption doesn't apply: + # if it's a setup exemption and $class is not 'setup' or 'all' + # if it's a recur exemption and $class is 'setup' + if ( ( $ex->exempt_recur and $class eq 'setup' ) + or ( $ex->exempt_setup and $class ne 'setup' and $class ne 'all' ) + ) { + next; + } - my $conf = new FS::Conf; - if ( $conf->exists('tax-pkg_address') ) { - #number of distinct locations - my %seen = (); - foreach (@cust_bill_pkg) { - $taxable_units++ - unless $seen{$_->cust_pkg->locationnum}++; + $taxable_charged -= $ex->amount; + } + # cust_main_county handles monthly capped exemptions; this doesn't. + # + # $taxable_charged can also be less than zero at this point + # (recur exemption + usage class breakdown); treat that as zero. + next if $taxable_charged <= 0; + + # yeah, some false laziness with cust_main_county + my $this_tax_cents = int(100 * $taxable_charged * $self->tax); + my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({ + 'taxnum' => $self->taxnum, + 'taxtype' => ref($self), + 'cents' => $this_tax_cents, # not a real field + 'locationtaxid' => $self->location, # fundamentally silly + 'taxable_billpkgnum' => $cust_bill_pkg->billpkgnum, + 'taxable_cust_bill_pkg' => $cust_bill_pkg, + 'taxratelocationnum' => $taxratelocationnum, + 'taxclass' => $class, + }); + push @tax_links, $tax_link; + + $taxable_cents += 100 * $taxable_charged; + $tax_cents += $this_tax_cents; + + } elsif ( $self->fee > 0 ) { + # most CCH taxes are this type, because nearly every county has a 911 + # fee + my $units = 0; + + # since we don't support partial exemptions (except setup/recur), + # if there's an exemption that applies to this package and taxrate, + # don't charge ANY per-unit fees + next if @exemptions; + + # don't apply fees to usage classes (maybe if we ever get per-minute + # fees?) + next unless $class eq 'setup' + or $class eq 'recur' + or $class eq 'all'; + + if ( $self->unittype == 0 ) { + if ( !$seen{$cust_bill_pkg->pkgnum} ) { + # per access line + $units = $cust_bill_pkg->units; + $seen{$cust_bill_pkg->pkgnum} = 1; + } # else it's been seen, leave it at zero units + + } elsif ($self->unittype == 1) { # per minute + # STILL not supported...fortunately these only exist if you happen + # to be in Idaho or Little Rock, Arkansas + # + # though a voip_cdr package could easily report minutes of usage... + return $self->_fatal_or_null( 'fee with minute unit type' ); + + } elsif ( $self->unittype == 2 ) { + + # per account + my $locationnum = $cust_bill_pkg->tax_locationnum; + if (!$locationnum and $cust_main) { + $locationnum = $cust_main->ship_locationnum; } + # the other case is that it's a quotation + + $units = 1 unless $seen{$cust_bill_pkg->tax_locationnum}; + $seen{$cust_bill_pkg->tax_locationnum} = 1; + } else { - $taxable_units = 1; + # Unittype 19 is used for prepaid wireless E911 charges in many states. + # Apparently "per retail purchase", which for us would mean per invoice. + # Unittype 20 is used for some 911 surcharges and I have no idea what + # it means. + return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum ); } + my $this_tax_cents = int($units * $self->fee * 100); + my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({ + 'taxnum' => $self->taxnum, + 'taxtype' => ref($self), + 'cents' => $this_tax_cents, + 'locationtaxid' => $self->location, + 'taxable_billpkgnum' => $cust_bill_pkg->billpkgnum, + 'taxable_cust_bill_pkg' => $cust_bill_pkg, + 'taxratelocationnum' => $taxratelocationnum, + 'taxclass' => $class, + }); + push @tax_links, $tax_link; + + $taxable_units += $units; + $tax_cents += $this_tax_cents; - } else { - return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum ); } + } # foreach $cust_bill_pkg - } - - # XXX handle excessrate (use_excessrate) / excessfee / - # taxbase/feebase / taxmax/feemax - # and eventually exemptions - # - # the tax or fee is applied to taxbase or feebase and then - # the excessrate or excess fee is applied to taxmax or feemax + # check bracket maxima; throw an error if we've gone over, because + # we don't really implement them - if ( ($self->taxmax > 0 and $taxable_charged > $self->taxmax) or + if ( ($self->taxmax > 0 and $taxable_cents > $self->taxmax*100 ) or ($self->feemax > 0 and $taxable_units > $self->feemax) ) { # throw an error # (why not just cap taxable_charged/units at the taxmax/feemax? because @@ -507,17 +603,23 @@ sub taxline_cch { return $self->_fatal_or_null( 'tax base > taxmax/feemax for tax'.$self->taxnum ); } - $amount += $taxable_charged * $self->tax; - $amount += $taxable_units * $self->fee; - - warn "calculated taxes as [ $name, $amount ]\n" - if $DEBUG; - - return { - 'name' => $name, - 'amount' => $amount, - }; + # round and distribute + my $total_tax_cents = sprintf('%.0f', + ($taxable_cents * $self->tax) + ($taxable_units * $self->fee * 100) + ); + my $extra_cents = sprintf('%.0f', $total_tax_cents - $tax_cents); + $tax_cents += $extra_cents; + my $i = 0; + foreach (@tax_links) { # can never require more than a single pass, yes? + my $cents = $_->get('cents'); + if ( $extra_cents > 0 ) { + $cents++; + $extra_cents--; + } + $_->set('amount', sprintf('%.2f', $cents/100)); + } + return @tax_links; } sub _fatal_or_null { @@ -587,7 +689,9 @@ sub tax_on_tax { "AND (". join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")"; qsearch({ 'table' => 'tax_rate', - 'hashref' => { 'geocode' => $geocode, }, + 'hashref' => { 'data_vendor' => $self->data_vendor, + 'geocode' => $geocode, + }, 'extra_sql' => $extra_sql, }) @@ -2055,9 +2159,6 @@ sub generate_liability_report { join(';', map { "$_=". uri_escape($t->$_) } @params); my $itemdesc_loc = - # " payby != 'COMP' ". # breaks the entire report under 4.x - # # and unnecessary since COMP accounts don't - # # get taxes calculated in the first place " ( itemdesc = ? OR ? = '' AND itemdesc IS NULL ) ". "AND ". FS::tax_rate_location->location_sql( map { $_ => $t->$_ } @taxparams @@ -2229,12 +2330,20 @@ EOF my $dropstring = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/report.'; $reportname =~ s/^$dropstring//; - my $reporturl = "%%%ROOTURL%%%/misc/queued_report?report=$reportname"; + my $reporturl = "%%%ROOTURL%%%/misc/queued_report.html?report=$reportname"; die "view\n"; } +sub _upgrade_data { + my $class = shift; + + my $sql = "UPDATE tax_rate SET data_vendor = 'compliance_solutions' WHERE data_vendor = 'compliance solutions'"; + my $sth = dbh->prepare($sql) or die $DBI::errstr; + $sth->execute() or die $sth->errstr; + +} =back