From: Mark Wells Date: Fri, 6 Mar 2015 22:20:22 +0000 (-0800) Subject: estimate tax on quotations, #32489 X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=35c18f29bc29dedfe2fa4ef037390d90b17f87ba estimate tax on quotations, #32489 --- diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index 897b78173..2cabf851d 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -399,6 +399,7 @@ if ( -e $addl_handler_use_file ) { use FS::prospect_contact; use FS::cust_contact; use FS::legacy_cust_history; + use FS::quotation_pkg_tax; # Sammath Naur if ( $FS::Mason::addl_handler_use ) { diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 89fc5f7f6..cd4f01d3e 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1967,6 +1967,21 @@ sub tables_hashref { ], }, + 'quotation_pkg_tax' => { + 'columns' => [ + 'quotationtaxnum', 'serial', '', '', '', '', + 'quotationpkgnum', 'int', '', '', '', '', + 'itemdesc', 'varchar', '', $char_d, '', '', + 'taxnum', 'int', '', '', '', '', + 'taxtype', 'varchar', '', $char_d, '', '', + 'setup_amount', @money_type, '', '', + 'recur_amount', @money_type, '', '', + ], + 'primary_key' => 'quotationtaxnum',, + 'unique' => [], + 'index' => [ [ 'quotationpkgnum' ] ], + }, + 'cust_location' => { #'location' now that its prospects too, but... 'columns' => [ 'locationnum', 'serial', '', '', '', '', diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index 9045291fc..ed6c8e09a 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -1266,7 +1266,7 @@ sub print_generic { ]; } - + if ( @items_tax ) { my $total = {}; $total->{'total_item'} = $self->mt('Sub-total'); @@ -1316,13 +1316,12 @@ sub print_generic { if ( $self->can('_items_total') ) { # quotations - $self->_items_total(\@total_items); + my @new_total_items = $self->_items_total; - foreach ( @total_items ) { + foreach ( @new_total_items ) { $_->{'total_item'} = &$embolden_function( $_->{'total_item'} ); - $_->{'total_amount'} = &$embolden_function( $other_money_char. - $_->{'total_amount'} - ); + $_->{'total_amount'} = &$embolden_function( $other_money_char.$_->{'total_amount'}); + push @total_items, $_; } } else { #normal invoice case @@ -1544,7 +1543,7 @@ sub print_generic { # invoice history "section" (not really a section) # not to be included in any subtotals, completely independent of # everything... - if ( $conf->exists('previous_invoice_history') ) { + if ( $conf->exists('previous_invoice_history') and $cust_main->isa('FS::cust_main') ) { my %history; my %monthorder; foreach my $cust_bill ( $cust_main->cust_bill ) { @@ -3094,6 +3093,7 @@ sub _items_cust_bill_pkg { ); if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) { + # XXX this should be pulled out into quotation_pkg warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n" if $DEBUG > 1; diff --git a/FS/FS/cust_main_county.pm b/FS/FS/cust_main_county.pm index 654e56749..075ac3278 100644 --- a/FS/FS/cust_main_county.pm +++ b/FS/FS/cust_main_county.pm @@ -278,10 +278,11 @@ sub taxline { my $custnum = $cust_bill ? $cust_bill->custnum : $opt{'custnum'}; my $invoice_time = $cust_bill ? $cust_bill->_date : $opt{'invoice_time'}; my $cust_main = FS::cust_main->by_key($custnum) if $custnum > 0; - if (!$cust_main) { - # better way to handle this? should we just assume that it's taxable? - die "unable to calculate taxes for an unknown customer\n"; - } + # (to avoid complications with estimated tax on quotations, assume it's + # taxable if there is no customer) + #if (!$cust_main) { + #die "unable to calculate taxes for an unknown customer\n"; + #} # set a flag if the customer is tax-exempt my $exempt_cust; @@ -313,6 +314,7 @@ sub taxline { my @tax_location; foreach my $cust_bill_pkg (@$taxables) { + # careful... may be a cust_bill_pkg or a quotation_pkg my $cust_pkg = $cust_bill_pkg->cust_pkg; my $part_pkg = $cust_bill_pkg->part_pkg; @@ -379,7 +381,11 @@ sub taxline { } if ( $self->exempt_amount && $self->exempt_amount > 0 - and $taxable_charged > 0 ) { + and $taxable_charged > 0 + and $cust_main ) { + + # XXX monthly exemptions currently don't work on quotations + # If the billing period extends across multiple calendar months, # there may be several months of exemption available. my $sdate = $cust_bill_pkg->sdate || $invoice_time; @@ -493,7 +499,7 @@ sub taxline { } } - } # if exempt_amount + } # if exempt_amount and $cust_main $_->taxnum($self->taxnum) foreach @new_exemptions; diff --git a/FS/FS/quotation.pm b/FS/FS/quotation.pm index 9cef3c191..55730aeba 100644 --- a/FS/FS/quotation.pm +++ b/FS/FS/quotation.pm @@ -7,10 +7,12 @@ use Tie::RefHash; use FS::CurrentUser; use FS::UID qw( dbh ); use FS::Maketext qw( emt ); -use FS::Record qw( qsearchs ); +use FS::Record qw( qsearch qsearchs ); +use FS::Conf; use FS::cust_main; use FS::cust_pkg; use FS::quotation_pkg; +use FS::quotation_pkg_tax; use FS::type_pkgs; =head1 NAME @@ -248,34 +250,87 @@ sub cust_or_prospect_label_link { } -#prevent things from falsely showing up as taxes, at least until we support -# quoting tax amounts.. sub _items_tax { - return (); + (); } + sub _items_nontax { shift->cust_bill_pkg; } sub _items_total { - my( $self, $total_items ) = @_; + my $self = shift; + $self->quotationnum =~ /^(\d+)$/ or return (); + + my @items; + + # show taxes in here also; the setup/recurring breakdown is different + # from what Template_Mixin expects + my @setup_tax = qsearch({ + select => 'itemdesc, SUM(setup_amount) as setup_amount', + table => 'quotation_pkg_tax', + addl_from => ' JOIN quotation_pkg USING (quotationpkgnum) ', + extra_sql => ' WHERE quotationnum = '.$1, + order_by => ' GROUP BY itemdesc HAVING SUM(setup_amount) > 0' . + ' ORDER BY itemdesc', + }); + # recurs need to be grouped by frequency, and to have a pkgpart + my @recur_tax = qsearch({ + select => 'freq, itemdesc, SUM(recur_amount) as recur_amount, MAX(pkgpart) as pkgpart', + table => 'quotation_pkg_tax', + addl_from => ' JOIN quotation_pkg USING (quotationpkgnum)'. + ' JOIN part_pkg USING (pkgpart)', + extra_sql => ' WHERE quotationnum = '.$1, + order_by => ' GROUP BY freq, itemdesc HAVING SUM(recur_amount) > 0' . + ' ORDER BY freq, itemdesc', + }); - if ( $self->total_setup > 0 ) { - push @$total_items, { + my $total_setup = $self->total_setup; + foreach my $pkg_tax (@setup_tax) { + if ($pkg_tax->setup_amount > 0) { + $total_setup += $pkg_tax->setup_amount; + push @items, { + 'total_item' => $pkg_tax->itemdesc . ' ' . $self->mt('(setup)'), + 'total_amount' => $pkg_tax->setup_amount, + }; + } + } + + if ( $total_setup > 0 ) { + push @items, { 'total_item' => $self->mt( $self->total_recur > 0 ? 'Total Setup' : 'Total' ), - 'total_amount' => $self->total_setup, + 'total_amount' => sprintf('%.2f',$total_setup), + 'break_after' => ( scalar(@recur_tax) ? 1 : 0 ) }; } #could/should add up the different recurring frequencies on lines of their own # but this will cover the 95% cases for now - if ( $self->total_recur > 0 ) { - push @$total_items, { + my $total_recur = $self->total_recur; + # label these with the frequency + foreach my $pkg_tax (@recur_tax) { + if ($pkg_tax->recur_amount > 0) { + $total_recur += $pkg_tax->recur_amount; + # an arbitrary part_pkg, but with the right frequency + # XXX localization + my $part_pkg = qsearchs('part_pkg', { pkgpart => $pkg_tax->pkgpart }); + push @items, { + 'total_item' => $pkg_tax->itemdesc . ' (' . $part_pkg->freq_pretty . ')', + 'total_amount' => $pkg_tax->recur_amount, + }; + } + } + + if ( $total_recur > 0 ) { + push @items, { 'total_item' => $self->mt('Total Recurring'), - 'total_amount' => $self->total_recur, + 'total_amount' => sprintf('%.2f',$total_recur), + 'break_after' => 1, }; } + return @items; + } =item enable_previous @@ -396,7 +451,7 @@ sub charge { $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : ''; $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : ''; $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : ''; - $locationnum = $_[0]->{locationnum} || $self->ship_locationnum; + $locationnum = $_[0]->{locationnum}; } else { $amount = shift; $setup_cost = ''; @@ -517,6 +572,139 @@ sub enable { $self->replace(); } +=item estimate + +Calculates current prices for all items on this quotation, including +discounts and taxes, and updates the quotation_pkg records accordingly. + +=cut + +sub estimate { + my $self = shift; + my $conf = FS::Conf->new; + + my $dbh = dbh; + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + + # bring individual items up to date (set setup/recur and discounts) + my @quotation_pkg = $self->quotation_pkg; + foreach my $pkg (@quotation_pkg) { + my $error = $pkg->estimate; + if ($error) { + $dbh->rollback if $oldAutoCommit; + die "error calculating estimate for pkgpart " . $pkg->pkgpart.": $error\n"; + } + + # delete old tax records + foreach my $quotation_pkg_tax ($pkg->quotation_pkg_tax) { + $error = $quotation_pkg_tax->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + die "error flushing tax records for pkgpart ". $pkg->pkgpart.": $error\n"; + } + } + } + + # annoyingly duplicates handle_taxes--fix this in 4.x + if ( $conf->exists('enable_taxproducts') ) { + warn "can't calculate external taxes for quotations yet\n"; + # then we're done + return; + } + + my %taxnum_exemptions; # for monthly exemptions; as yet unused + + foreach my $pkg (@quotation_pkg) { + my $location = $pkg->cust_location; + + my $part_item = $pkg->part_pkg; # we don't have fees on these yet + my @loc_keys = qw( district city county state country); + my %taxhash = map { $_ => $location->$_ } @loc_keys; + $taxhash{'taxclass'} = $part_item->taxclass; + my @taxes; + my %taxhash_elim = %taxhash; + my @elim = qw( district city county state ); + do { + @taxes = qsearch( 'cust_main_county', \%taxhash_elim ); + if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) { + #then try a match without taxclass + my %no_taxclass = %taxhash_elim; + $no_taxclass{ 'taxclass' } = ''; + @taxes = qsearch( 'cust_main_county', \%no_taxclass ); + } + + $taxhash_elim{ shift(@elim) } = ''; + } while ( !scalar(@taxes) && scalar(@elim) ); + + foreach my $tax_def (@taxes) { + my $taxnum = $tax_def->taxnum; + $taxnum_exemptions{$taxnum} ||= []; + + # XXX do some kind of equivalent to set_exemptions here + # but for now just declare that there are no exemptions, + # and then hack the taxable amounts if the package def + # excludes setup/recur + $pkg->set('cust_tax_exempt_pkg', []); + + if ( $part_item->setuptax or $tax_def->setuptax ) { + $pkg->set('unitsetup', 0); + } + if ( $part_item->recurtax or $tax_def->recurtax ) { + $pkg->set('unitrecur', 0); + } + + my %taxline; + foreach my $pass (qw(first recur)) { + if ($pass eq 'recur') { + $pkg->set('unitsetup', 0); + } + + my $taxline = $tax_def->taxline( + [ $pkg ], + exemptions => $taxnum_exemptions{$taxnum} + ); + if ($taxline and !ref($taxline)) { + $dbh->rollback if $oldAutoCommit; + die "error calculating '".$tax_def->taxname . + "' for pkgpart '".$pkg->pkgpart."': $taxline\n"; + } + $taxline{$pass} = $taxline; + } + + my $quotation_pkg_tax = FS::quotation_pkg_tax->new({ + quotationpkgnum => $pkg->quotationpkgnum, + itemdesc => $tax_def->taxname, + taxnum => $taxnum, + taxtype => ref($tax_def), + }); + my $setup_amount = 0; + my $recur_amount = 0; + if ($taxline{first}) { + $setup_amount = $taxline{first}->setup; # "first cycle", not setup + } + if ($taxline{recur}) { + $recur_amount = $taxline{recur}->setup; + $setup_amount -= $recur_amount; # to get the actual setup amount + } + if ( $recur_amount > 0 or $setup_amount > 0 ) { + $quotation_pkg_tax->set('setup_amount', sprintf('%.2f', $setup_amount)); + $quotation_pkg_tax->set('recur_amount', sprintf('%.2f', $recur_amount)); + + my $error = $quotation_pkg_tax->insert; + if ($error) { + $dbh->rollback if $oldAutoCommit; + die "error recording '".$tax_def->taxname . + "' for pkgpart '".$pkg->pkgpart."': $error\n"; + } # if $error + } # else there are no non-zero taxes; continue + } # foreach $tax_def + } # foreach $pkg + + $dbh->commit if $oldAutoCommit; + ''; +} + =back =head1 CLASS METHODS @@ -649,15 +837,9 @@ first, and doesn't implement the "condensed" option. sub _items_pkg { my ($self, %options) = @_; - my @quotation_pkg = $self->quotation_pkg; - foreach (@quotation_pkg) { - my $error = $_->estimate; - die "error calculating estimate for pkgpart " . $_->pkgpart.": $error\n" - if $error; - } - + $self->estimate; # run it through the Template_Mixin engine - return $self->_items_cust_bill_pkg(\@quotation_pkg, %options); + return $self->_items_cust_bill_pkg([ $self->quotation_pkg ], %options); } =back diff --git a/FS/FS/quotation_pkg.pm b/FS/FS/quotation_pkg.pm index 25edc9458..c579e359b 100644 --- a/FS/FS/quotation_pkg.pm +++ b/FS/FS/quotation_pkg.pm @@ -145,7 +145,7 @@ sub delete { my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; - foreach ($self->quotation_pkg_discount) { + foreach ($self->quotation_pkg_discount, $self->quotation_pkg_tax) { my $error = $_->delete; if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -358,13 +358,13 @@ sub _item_discount { sub setup { my $self = shift; - ($self->unitsetup - sum(map { $_->setup_amount } $self->pkg_discount)) + ($self->unitsetup - sum(0, map { $_->setup_amount } $self->pkg_discount)) * ($self->quantity || 1); } sub recur { my $self = shift; - ($self->unitrecur - sum(map { $_->recur_amount } $self->pkg_discount)) + ($self->unitrecur - sum(0, map { $_->recur_amount } $self->pkg_discount)) * ($self->quantity || 1); } @@ -417,6 +417,11 @@ sub cust_bill_pkg_display { $recur->{'type'} = 'R'; if ( $type eq 'S' ) { +sub tax_locationnum { + my $self = shift; + $self->locationnum; +} + return ($setup); } elsif ( $type eq 'R' ) { return ($recur); @@ -453,11 +458,21 @@ sub prospect_main { $quotation->prospect_main; } +sub quotation_pkg_tax { + my $self = shift; + qsearch('quotation_pkg_tax', { quotationpkgnum => $self->quotationpkgnum }); +} + +sub cust_location { + my $self = shift; + $self->locationnum ? qsearchs('cust_location', { locationnum => $self->locationnum }) : ''; +} + =back =head1 BUGS -Doesn't support taxes, fees, or add-on packages. +Doesn't support fees, or add-on packages. =head1 SEE ALSO diff --git a/FS/FS/quotation_pkg_tax.pm b/FS/FS/quotation_pkg_tax.pm new file mode 100644 index 000000000..3d1dcebbb --- /dev/null +++ b/FS/FS/quotation_pkg_tax.pm @@ -0,0 +1,122 @@ +package FS::quotation_pkg_tax; + +use strict; +use base qw( FS::Record ); +use FS::Record qw( qsearch qsearchs ); +use FS::cust_main_county; +use FS::quotation_pkg; + +=head1 NAME + +FS::quotation_pkg_tax - Object methods for quotation_pkg_tax records + +=head1 SYNOPSIS + + use FS::quotation_pkg_tax; + + $record = new FS::quotation_pkg_tax \%hash; + $record = new FS::quotation_pkg_tax { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::quotation_pkg_tax object represents tax on a quoted package. +FS::quotation_pkg_tax inherits from FS::Record (though it should eventually +inherit from some shared superclass of L). +The following fields are currently supported: + +=over 4 + +=item quotationtaxnum - primary key + +=item quotationpkgnum - the L record that the tax applies +to. + +=item itemdesc - the name of the tax + +=item taxnum - the L or L defining the +tax. + +=item taxtype - the class of the tax rate represented by C. + +=item setup_amount - the amount of tax calculated on one-time charges + +=item recur_amount - the amount of tax calculated on recurring charges + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new estimated tax amount. To add the record to the database, +see L<"insert">. + +=cut + +sub table { 'quotation_pkg_tax'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=item delete + +Delete this record from the database. + +=item replace OLD_RECORD + +Replaces the OLD_RECORD with this one in the database. If there is an error, +returns the error, otherwise returns false. + +=item check + +Checks all fields to make sure this is a valid example. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('quotationtaxnum') + || $self->ut_foreign_key('quotationpkgnum', 'quotation_pkg', 'quotationpkgnum') + || $self->ut_text('itemdesc') + || $self->ut_number('taxnum') + || $self->ut_enum('taxtype', [ 'FS::cust_main_county', 'FS::tax_rate' ]) + || $self->ut_money('setup_amount') + || $self->ut_money('recur_amount') + ; + return $error if $error; + + $self->SUPER::check; +} + +#stub for 3.x +sub quotation_pkg { + my $self = shift; + FS::quotation_pkg->by_key($self->quotationpkgnum); +} + +=back + +=head1 SEE ALSO + +L, schema.html from the base documentation. + +=cut + +1; + diff --git a/FS/MANIFEST b/FS/MANIFEST index 378421cb3..2ea404fe0 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -838,3 +838,5 @@ FS/pkg_discount_Mixin.pm t/pkg_discount_Mixin.t FS/legacy_cust_history.pm t/legacy_cust_history.t +FS/quotation_pkg_tax.pm +t/quotation_pkg_tax.t diff --git a/FS/t/quotation_pkg_tax.t b/FS/t/quotation_pkg_tax.t new file mode 100644 index 000000000..448884bb9 --- /dev/null +++ b/FS/t/quotation_pkg_tax.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::quotation_pkg_tax; +$loaded=1; +print "ok 1\n"; diff --git a/conf/quotation_html b/conf/quotation_html index a05bb60b8..44164b690 100644 --- a/conf/quotation_html +++ b/conf/quotation_html @@ -231,8 +231,10 @@ foreach my $line ( @total_items ) { + # in the quotation logic, we specifically tag the total line + # instead of using "the second one from the bottom" $style .= 'border-bottom: 3px solid #000000;' - if ++$linenum == scalar(@total_items) - ( $balance_due_below_line ? 1 : 0 ); + if $line->{break_after}; $OUT .= ''; diff --git a/conf/quotation_latex b/conf/quotation_latex index 7ebc38d75..6aac16075 100644 --- a/conf/quotation_latex +++ b/conf/quotation_latex @@ -281,18 +281,16 @@ } } - #if ($section == $sections[$#sections]) { - foreach my $line (grep {$_->{section}->{description} eq $section->{description}} @total_items) { - if ($section->{total_line_generator}) { - $OUT .= &{$section->{total_line_generator}}($line); - } else { - $OUT .= '\FStotaldesc{' . $line->{'total_item'} . '}' . - '{' . $line->{'total_amount'} . '}' . "\n"; - } + foreach my $line (grep {$_->{section}->{description} eq $section->{description}} @total_items) { + if ($section->{total_line_generator}) { + $OUT .= &{$section->{total_line_generator}}($line); + } else { + $OUT .= '\FStotaldesc{' . $line->{'total_item'} . '}' . + '{' . $line->{'total_amount'} . '}' . "\n"; + $OUT .= '\hline' . "\n" if $line->{'break_after'}; } - #} + } - $OUT .= '\hline'; $OUT .= '\endlastfoot'; my $lastref = 0;