+push @select, "$part_pkg.pkgpart", "$part_pkg.pkg";
+push @select, "($itemdesc) AS itemdesc"; # available in all report modes
+
+push @select, "COALESCE($part_pkg.taxclass, part_fee.taxclass) AS taxclass"
+ if $conf->exists('enable_taxclasses');
+
+# the non-tax case
+if ( $cgi->param('nottax') ) {
+
+ push @where,
+ '(cust_bill_pkg.pkgnum > 0 OR cust_bill_pkg.feepart IS NOT NULL)';
+
+ my @tax_where; # will go into a subquery
+ my @exempt_where; # will also go into a subquery
+
+ # classnum (of override pkgpart if applicable)
+ # not specified: all classes
+ # 0: empty class
+ # N: classnum
+ if ( grep { $_ eq 'classnum' } $cgi->param ) {
+ my @classnums = grep /^\d+$/, $cgi->param('classnum');
+ push @where, "COALESCE(part_fee.classnum, $part_pkg.classnum, 0) IN ( ".
+ join(',', @classnums ).
+ ' )'
+ if @classnums;
+ }
+
+ if ( grep { $_ eq 'report_optionnum' } $cgi->param ) {
+ my $num = join(',', grep /^[\d,]+$/, $cgi->param('report_optionnum'));
+ my $not_num = join(',', grep /^[\d,]+$/, $cgi->param('not_report_optionnum'));
+ my $all = $cgi->param('all_report_options') ? 1 : 0;
+ push @where, # code reuse FTW
+ FS::Report::Table->with_report_option(
+ report_optionnum => $num,
+ not_report_optionnum => $not_num,
+ use_override => $use_override,
+ all_report_options => $all,
+ );
+ }
+
+ # taxclass
+ if ( $cgi->param('taxclassNULL') ) {
+ # a little different from 'taxclass' in that it applies to the
+ # effective taxclass, not the real one
+ push @tax_where, 'cust_main_county.taxclass IS NULL'
+ } elsif ( $cgi->param('taxclass') ) {
+ push @tax_where, "COALESCE(part_fee.taxclass, $part_pkg.taxclass) IN (" .
+ join(', ', map {dbh->quote($_)} $cgi->param('taxclass') ).
+ ')';
+ }
+
+ if ( $cgi->param('exempt_cust') eq 'Y' ) {
+ # tax-exempt customers
+ push @exempt_where, "(exempt_cust = 'Y' OR exempt_cust_taxname = 'Y')";
+
+ } elsif ( $cgi->param('exempt_pkg') eq 'Y' ) { # non-taxable package
+ # non-taxable package charges
+ push @exempt_where, "(exempt_setup = 'Y' OR exempt_recur = 'Y')";
+ }
+ # we don't handle exempt_monthly here
+
+ if ( $cgi->param('taxname') ) { # specific taxname
+ push @tax_where, "COALESCE(cust_main_county.taxname, 'Tax') = ".
+ dbh->quote($cgi->param('taxname'));
+ }
+
+ # country:state:county:city:district (may be repeated)
+ # You can also pass a big list of taxnums but that leads to huge URLs.
+ # Note that this means "packages whose tax is in this region", not
+ # "packages in this region". It's meant for links from the tax report.
+ if ( $cgi->param('region') ) {
+ my @orwhere;
+ foreach ( $cgi->param('region') ) {
+ my %loc;
+ @loc{qw(country state county city district)} =
+ split(':', $cgi->param('region'));
+ my $string = join(' AND ',
+ map {
+ if ( $loc{$_} ) {
+ "$_ = ".dbh->quote($loc{$_});
+ } else {
+ "$_ IS NULL";
+ }
+ } keys(%loc)
+ );
+ push @orwhere, "($string)";
+ }
+ push @tax_where, '(' . join(' OR ', @orwhere) . ')' if @orwhere;
+ }
+
+ # specific taxnums
+ if ( $cgi->param('taxnum') =~ /^([\d,]+)$/) {
+ push @tax_where, "cust_main_county.taxnum IN ($1)";
+ }
+
+ # If we're showing exempt items, we need to find those with
+ # cust_tax_exempt_pkg records matching the selected taxes.
+ # If we're showing taxable items, we need to find those with
+ # cust_bill_pkg_tax_location records. We also need to find the
+ # exemption records so that we can show the taxable amount.
+ # If we're showing all items, we need the union of those.
+ # If we're showing 'out' (items that aren't region/class taxable),
+ # then we need the set of all items minus the union of those.
+
+ # Always exclude cust_tax_exempt_pkg records with non-NULL creditbillpkgnum.
+
+ if ( $cgi->param('out') ) {
+ # separate from the rest, in that we're not going to join cust_main_county
+ # in the outer query
+
+ my @exclude = ( 'cust_tax_exempt_pkg.billpkgnum',
+ 'cust_bill_pkg_tax_location.taxable_billpkgnum'
+ );
+ foreach my $col (@exclude) {
+ my ($table) = split(/\./, $col);
+ my $this_where = 'WHERE ' . join(' AND ',
+ "$col = cust_bill_pkg.billpkgnum",
+ @tax_where
+ );
+
+ push @where,
+ "NOT EXISTS(SELECT 1 FROM $table
+ JOIN cust_main_county USING (taxnum)
+ $this_where
+ )";
+ }
+
+ } else {
+ # everything that returns things joined to a tax definition
+
+ if ( @exempt_where or @tax_where or $cgi->param('taxable') ) {
+
+ push @exempt_where, "cust_tax_exempt_pkg.creditbillpkgnum IS NULL";
+
+ # process exemption restrictions, including @tax_where
+ my $exempt_sub = 'SELECT SUM(amount) as exempt_amount, billpkgnum
+ FROM cust_tax_exempt_pkg JOIN cust_main_county USING (taxnum)';
+
+ $exempt_sub .= ' WHERE '.join(' AND ', @tax_where, @exempt_where);
+
+ $exempt_sub .= ' GROUP BY billpkgnum';
+
+ $join_pkg .= " LEFT JOIN ($exempt_sub) AS item_exempt
+ ON (cust_bill_pkg.billpkgnum = item_exempt.billpkgnum)";
+
+ }
+
+ if ( @tax_where or $cgi->param('taxable') ) {
+ # process tax restrictions
+ unshift @tax_where,
+ 'cust_main_county.tax > 0';
+
+ my $tax_sub = "SELECT taxable_billpkgnum
+ FROM cust_bill_pkg_tax_location
+ JOIN cust_main_county USING (taxnum)
+ WHERE ". join(' AND ', @tax_where).
+ " GROUP BY taxable_billpkgnum";
+
+ $join_pkg .= " LEFT JOIN ($tax_sub) AS item_tax
+ ON (cust_bill_pkg.billpkgnum = item_tax.taxable_billpkgnum)"
+ }
+
+ # now do something with that
+ if ( $cgi->param('taxable') ) {
+ # taxable query: needs sale amount - exempt amount
+ my $taxable = 'cust_bill_pkg.setup + cust_bill_pkg.recur '.
+ '- COALESCE(item_exempt.exempt_amount, 0)';
+
+ push @where, "item_tax.taxable_billpkgnum IS NOT NULL";
+ push @select, "($taxable) AS taxable_amount";
+ push @peritem, 'taxable_amount';
+ push @peritem_desc, 'Taxable';
+ push @total, "SUM($taxable)";
+ push @total_desc, "$money_char%.2f taxable";
+
+ } elsif ( $cgi->param('exempt_cust') or $cgi->param('exempt_pkg') ) {
+
+ push @where, 'item_exempt.billpkgnum IS NOT NULL';
+ push @select, 'item_exempt.exempt_amount';
+ push @peritem, 'exempt_amount';
+ push @peritem_desc, 'Exempt';
+ push @total, 'SUM(exempt_amount)';
+ push @total_desc, "$money_char%.2f tax-exempt";
+
+ } elsif ( @tax_where ) {
+ # union of taxable + all exempt_ cases
+ push @where,
+ '(item_tax.taxable_billpkgnum IS NOT NULL OR item_exempt.billpkgnum IS NOT NULL)';
+
+ }
+
+ } # handle all joins to cust_main_county
+
+ # setup/recur/usage separation
+ my %charges = map { $_ => 1 } split('', $cgi->param('charges') || 'SRU');
+
+ if ( $charges{S} and $charges{R} and $charges{U} ) {
+ # in this case, show discounts
+
+ $join_pkg .= ' JOIN (
+ SELECT billpkgnum, COALESCE(SUM(amount), 0) AS discounted
+ FROM cust_bill_pkg_discount RIGHT JOIN cust_bill_pkg USING (billpkgnum)
+ GROUP BY billpkgnum
+ ) AS _discount ON (cust_bill_pkg.billpkgnum = _discount.billpkgnum)
+ ';
+ push @select, '_discount.discounted';
+
+ push @peritem, 'discounted';
+ push @peritem_desc, 'Discount';
+ push @total, 'SUM(cust_bill_pkg.setup + cust_bill_pkg.recur + discounted)',
+ 'SUM(discounted)',
+ 'SUM(cust_bill_pkg.setup + cust_bill_pkg.recur)';
+ push @total_desc, "$money_char%.2f gross sales",
+ "− $money_char%.2f discounted",
+ "= $money_char%.2f invoiced";
+
+ } elsif ( $charges{R} and $charges{U} ) {
+
+ # hide rows with no recurring fee, and show the sum of recurring fees only
+ push @where, 'cust_bill_pkg.recur > 0';
+ push @total, "SUM(cust_bill_pkg.recur)";
+ push @total_desc, "$money_char%.2f recurring";
+
+ } elsif ( $charges{R} and !$charges{U} ) {
+
+ my $recur_no_usage = FS::cust_bill_pkg->charged_sql('', '',
+ setuprecur => 'recur', no_usage => 1);
+ push @select, "($recur_no_usage) AS recur_no_usage";
+ $peritem[1] = 'recur_no_usage';
+ $peritem_desc[1] = 'Recurring charges (excluding usage)';
+ push @total, "SUM($recur_no_usage)";
+ push @total_desc, "$money_char%.2f recurring";
+ if ( !$charges{S} ) {
+ push @where, "($recur_no_usage) > 0";
+ }
+
+ } elsif ( !$charges{R} and $charges{U} ) {
+
+ my $usage = FS::cust_bill_pkg->usage_sql();
+ push @select, "($usage) AS _usage";
+ # there's already a method named 'usage'
+ $peritem[1] = '_usage';
+ $peritem_desc[1] = 'Usage charge';
+ push @total, "SUM($usage)";
+ push @total_desc, "$money_char%.2f usage charges";
+ if ( !$charges{S} ) {
+ push @where, "($usage) > 0";
+ }
+
+ } elsif ( $charges{S} ) {
+
+ push @where, "cust_bill_pkg.setup > 0";
+ push @total, "SUM(cust_bill_pkg.setup)";
+ push @total_desc, "$money_char%.2f setup";
+
+ } # else huh? you have to have SOME charges
+
+} elsif ( $cgi->param('istax') ) {
+
+ # ensure that it is a tax:
+ push @where, 'cust_bill_pkg.pkgnum = 0',
+ 'cust_bill_pkg.feepart IS NULL';
+
+ # We MUST NOT join cust_bill_pkg to any table that it's 1:many to.
+ # Otherwise we get duplication of the cust_bill_pkg records,
+ # inaccurate totals, nonsensical paging behavior, etc.
+ # We CAN safely join it to a subquery that has unique billpkgnums, and
+ # that's what we'll do.
+
+ my $tax_subquery;
+ my @tax_where;
+
+ # tax location when using tax_rate_location
+ if ( $cgi->param('vendortax') ) {
+
+ $tax_subquery = '
+ SELECT billpkgnum, SUM(amount) as tax_total
+ FROM cust_bill_pkg_tax_rate_location AS tax
+ JOIN tax_rate_location USING (taxratelocationnum)
+ ';
+ foreach (qw( state county city locationtaxid)) {
+ if ( scalar($cgi->param($_)) ) {
+ my $place = dbh->quote( $cgi->param($_) );
+ push @tax_where, "tax_rate_location.$_ = $place";
+ }
+ }
+
+ # itemdesc, for breakdown from the vendor tax report
+ # (this is definitely used)
+ if ( $cgi->param('itemdesc') ) {
+ if ( $cgi->param('itemdesc') eq 'Tax' ) {
+ push @where, "($itemdesc = 'Tax' OR $itemdesc is null)";
+ } else {
+ push @where, "$itemdesc = ". dbh->quote($cgi->param('itemdesc'));
+ }
+ }
+
+ } else { # the internal-tax case
+
+ my $tax_select = 'SELECT tax.billpkgnum, SUM(tax.amount) as tax_total';
+ my $tax_from = ' FROM cust_bill_pkg_tax_location AS tax JOIN cust_main_county USING (taxnum)';
+
+ # package classnum
+ if ( grep { $_ eq 'classnum' } $cgi->param ) {
+ my @classnums = grep /^\d*$/, $cgi->param('classnum');
+ $tax_from .= '
+ JOIN cust_bill_pkg AS taxed_item
+ ON (tax.taxable_billpkgnum = taxed_item.billpkgnum)
+ LEFT JOIN cust_pkg AS taxed_pkg ON (taxed_item.pkgnum = taxed_pkg.pkgnum)
+ LEFT JOIN part_pkg AS taxed_part_pkg ON (taxed_pkg.pkgpart = taxed_part_pkg.pkgpart)
+ LEFT JOIN part_fee AS taxed_part_fee ON (taxed_item.feepart = taxed_part_fee.feepart)
+ ';
+ push @tax_where,
+ "COALESCE(taxed_part_pkg.classnum, taxed_part_fee.classnum,0) IN ( ".
+ join(',', @classnums ).
+ ' )'
+ if @classnums;
+ }
+
+ # taxclass
+ if ( $cgi->param('taxclassNULL') ) {
+ push @tax_where, 'cust_main_county.taxclass IS NULL';
+ }
+
+ # taxname
+ if ( $cgi->param('taxnameNULL') ) {
+ push @tax_where, 'cust_main_county.taxname IS NULL OR '.
+ 'cust_main_county.taxname = \'Tax\'';
+ } elsif ( $cgi->param('taxname') ) {
+ push @tax_where, 'cust_main_county.taxname = '.
+ dbh->quote($cgi->param('taxname'));
+ }
+
+ # itemdesc, for breakdown from the vendor tax report
+ # (is this even used? vendor tax report shouldn't use cust_bill_pkg.cgi)
+ if ( $cgi->param('itemdesc') ) {
+ if ( $cgi->param('itemdesc') eq 'Tax' ) {
+ push @where, "($itemdesc = 'Tax' OR $itemdesc is null)";
+ } else {
+ push @where, "$itemdesc = ". dbh->quote($cgi->param('itemdesc'));
+ }
+ }
+
+ # specific taxnums (the usual way)
+ if ( $cgi->param('taxnum') =~ /^([\d,]+)$/) {
+ push @tax_where, "cust_main_county.taxnum IN ($1)";
+ }
+
+ $tax_subquery = "$tax_select $tax_from";
+
+ } # end of internal-tax case
+
+ if (@tax_where) {
+ $tax_subquery .= '
+ WHERE ' . join(' AND ', map "($_)", @tax_where);
+ }
+ $tax_subquery .= ' GROUP BY tax.billpkgnum ';
+
+ # now join THAT into the main report
+ # (inner join, so that tax line items that don't match the tax_where
+ # conditions don't appear in the output.)
+
+ $join_pkg .= " JOIN ($tax_subquery) AS _istax USING (billpkgnum) ";
+
+ push @select, 'tax_total';
+
+ @peritem = ( 'setup' ); # total tax on the invoice, not just the filtered tax
+ @peritem_desc = ( 'Tax charge' );
+
+ @total = ( 'COUNT(cust_bill_pkg.billpkgnum)',
+ 'SUM(cust_bill_pkg.setup)' );
+ @total_desc = ( "$money_char%.2f total tax" );
+
+ if ( @tax_where ) {
+ # then also show the filtered tax
+ push @peritem, 'tax_total';
+ push @peritem_desc, 'Tax in category';
+ push @total, 'SUM(tax_total)';
+ push @total_desc, "$money_char%.2f tax in this category";
+ # would also be nice to include a line explaining what the category is
+ }
+
+} # nottax / istax
+
+#total payments
+my $pay_sub = "SELECT SUM(cust_bill_pay_pkg.amount)
+ FROM cust_bill_pay_pkg
+ WHERE cust_bill_pkg.billpkgnum = cust_bill_pay_pkg.billpkgnum
+ ";
+push @select, "($pay_sub) AS pay_amount";
+
+# showing credited amount, optionally with date filtering
+my $credit_where = '';
+if ( $cgi->param('credit_begin') or $cgi->param('credit_end') ) {
+ my($cr_begin, $cr_end) = FS::UI::Web::parse_beginning_ending($cgi, 'credit');
+ $credit_where = "WHERE cust_credit_bill._date >= $cr_begin " .
+ "AND cust_credit_bill._date <= $cr_end";
+}
+
+my $credit_sub = "SELECT SUM(cust_credit_bill_pkg.amount) AS credit_amount, billpkgnum
+ FROM cust_credit_bill_pkg
+ JOIN cust_credit_bill USING (creditbillnum)
+ $credit_where
+ GROUP BY billpkgnum";
+
+$join_pkg .= " LEFT JOIN ($credit_sub) AS item_credit
+ ON (cust_bill_pkg.billpkgnum = item_credit.billpkgnum)";
+push @select, 'credit_amount';
+
+# standard customer fields
+push @select, 'cust_main.custnum', FS::UI::Web::cust_sql_fields();
+
+#salesnum
+if ( $cgi->param('salesnum') =~ /^(\d+)$/ ) {
+
+ my $salesnum = $1;
+ my $sales = FS::sales->by_key($salesnum)
+ or die "salesnum $salesnum not found";
+
+ my $subsearch = $sales->cust_bill_pkg_search('', '',
+ 'cust_main_sales' => ($cgi->param('cust_main_sales') ? 1 : 0),
+ 'paid' => ($cgi->param('paid') ? 1 : 0),
+ 'classnum' => scalar($cgi->param('classnum'))
+ );
+ $join_pkg .= " JOIN sales_pkg_class ON ( COALESCE(sales_pkg_class.classnum, 0) = COALESCE( part_fee.classnum, part_pkg.classnum, 0) )";
+
+ my $extra_sql = $subsearch->{extra_sql};
+ $extra_sql =~ s/^WHERE//;
+ push @where, $extra_sql;
+
+ $cgi->param('classnum', 0) unless $cgi->param('classnum');
+}
+
+#credit flag (include only those that have credit(s) applied)
+if ( $cgi->param('credit') ) {
+ push @where, 'credit_amount > 0';
+}
+
+my $where = join(' AND ', @where);
+$where &&= "WHERE $where";