Option to ignore old CDRs, RT#81480
[freeside.git] / httemplate / search / cust_bill_pkg.cgi
index b6b70a0..ab5aad7 100644 (file)
@@ -3,74 +3,81 @@
                  'name'        => emt('line items'),
                  'query'       => $query,
                  'count_query' => $count_query,
-                 'count_addl'  => [ $money_char. '%.2f total', ],
+                 'count_addl'  => \@total_desc,
                  'header'      => [
+                   @pkgnum_header,
+                   emt('Pkg Def'),
                    emt('Description'),
-                   emt('Setup charge'),
-                   ( $use_usage eq 'usage'
-                     ? emt('Usage charge')
-                     : emt('Recurring charge')
-                   ),
+                   @post_desc_header,
+                   @peritem_desc,
+                   @currency_desc,
                    emt('Invoice'),
                    emt('Date'),
+                   emt('Paid'),
+                   emt('Credited'),
                    FS::UI::Web::cust_header(),
                  ],
                  'fields'      => [
+                   @pkgnum,
                    sub { $_[0]->pkgnum > 0
-                           ? $_[0]->get('pkg')      # possibly use override.pkg
-                           : $_[0]->get('itemdesc') # but i think this correct
+                           ? $_[0]->get('pkgpart')
+                           : ''
                        },
+                   'itemdesc', # is part_pkg.pkg if applicable
+                   @post_desc,
                    #strikethrough or "N/A ($amount)" or something these when
                    # they're not applicable to pkg_tax search
-                   sub { my $cust_bill_pkg = shift;
-                         sprintf($money_char.'%.2f', $cust_bill_pkg->setup );
-                       },
-                   sub { my $row = shift;
-                         my $value = 0;
-                         if ( $use_usage eq 'recurring' ) {
-                           $value = $row->recur - $row->usage;
-                         } elsif ( $use_usage eq 'usage' ) {
-                           $value = $row->usage;
-                         } else {
-                           $value = $row->recur;
-                         }
-                         sprintf($money_char.'%.2f', $value );
-                       },
+                   @peritem_sub,
+                   @currency_sub,
                    'invnum',
                    sub { time2str('%b %d %Y', shift->_date ) },
+                   sub { sprintf($money_char.'%.2f', shift->get('pay_amount')) },
+                   sub { sprintf($money_char.'%.2f', shift->get('credit_amount')) },
                    \&FS::UI::Web::cust_fields,
                  ],
                  'sort_fields' => [
+                   @pkgnum_null,
                    '',
-                   'setup',
-                   ( $use_usage eq 'recurring'
-                        ? 'recur - usage' :
-                     $use_usage eq 'usage' 
-                        ? 'usage'
-                        : 'recur'
-                   ),
+                   '',
+                   @post_desc_null,
+                   @peritem,
+                   @currency,
                    'invnum',
                    '_date',
+                   'pay_amount',
+                   'credit_amount',
+                   FS::UI::Web::cust_sort_fields(),
                  ],
                  'links'       => [
-                   #'',
-                   '',
+                   @pkgnum_null,
                    '',
                    '',
+                   @post_desc_null,
+                   @peritem_null,
+                   @currency_null,
                    $ilink,
                    $ilink,
+                   $pay_link,
+                   $credit_link,
                    ( map { $_ ne 'Cust. Status' ? $clink : '' }
                          FS::UI::Web::cust_header()
                    ),
                  ],
                  #'align' => 'rlrrrc'.FS::UI::Web::cust_aligns(),
-                 'align' => 'lr'.
-                            'r'.
-                            'rc'.
+                 'align' => $pkgnum_align.
+                            'rl'.
+                            $post_desc_align.
+                            $peritem_align.
+                            $currency_align.
+                            'rcrr'.
                             FS::UI::Web::cust_aligns(),
                  'color' => [ 
-                              #'',
+                              @pkgnum_null,
+                              '',
                               '',
+                              @post_desc_null,
+                              @peritem_null,
+                              @currency_null,
                               '',
                               '',
                               '',
                               FS::UI::Web::cust_colors(),
                             ],
                  'style' => [ 
-                              #'',
+                              @pkgnum_null,
                               '',
                               '',
+                              @post_desc_null,
+                              @peritem_null,
+                              @currency_null,
+                              '',
                               '',
                               '',
                               '',
                               FS::UI::Web::cust_styles(),
                             ],
 &>
+<%doc>
+
+Output control parameters:
+- distribute: Boolean.  If true, recurring fees will be "prorated" for the 
+  portion of the package date range (sdate-edate) that falls within the date
+  range of the report.  Line items will be limited to those for which this 
+  portion is > 0.  This disables filtering on invoice date.
+
+- charges: 'S'etup, 'R'ecur, 'U'sage, or any string combination of those.
+
+Filtering parameters:
+- begin, end: Date range.  Applies to invoice date, not necessarily package
+  date range.  But see "distribute".
+
+- status: Customer status (active, suspended, etc.).  This will filter on 
+  _current_ customer status, not status at the time the invoice was generated.
+
+- agentnum: Filter on customer agent.
+
+- refnum: Filter on customer reference source.
+
+- cust_classnum: Filter on customer class.
+
+- classnum: Filter on package class.
+
+- report_optionnum: Filter on package report class.  Can be a single report
+  class number or a comma-separated list (where 0 is "no report class"), or the
+  word "multiple".
+
+- use_override: Apply "classnum" and "taxclass" filtering based on the 
+  override (bundle) pkgpart, rather than always using the true pkgpart.
+
+- nottax: Limit to items that are not taxes (pkgnum > 0 or feepart > 0).
+
+- istax: Limit to items that are taxes (pkgnum == 0 and feepart = null).
+
+- taxnum: Limit to items whose tax definition matches this taxnum.
+  With "nottax" that means items that are subject to that tax;
+  with "istax" it's the tax charges themselves.  Can be a comma-separated
+  list to include multiple taxes.
+
+- country, state, county, city: Limit to items whose tax location 
+  matches these fields.  If "nottax" it's the tax location of the package;
+  if "istax" the location of the tax.
+
+- taxname, taxnameNULL: With "nottax", limit to items whose tax location
+  matches a tax with this name.  With "istax", limit to items that have
+  this tax name.  taxnameNULL is equivalent to "taxname = '' OR taxname 
+  = 'Tax'".
+
+- out: With "nottax", limit to items that don't match any tax definition.
+  With "istax", find tax items that are unlinked to their tax definitions.
+  Current Freeside (> July 2012) always creates tax links, but unlinked
+  items may result from an incomplete upgrade of legacy data.
+
+- locationtaxid: With "nottax", limit to packages matching this 
+  tax_rate_location ID; with "tax", limit to taxes generated from that 
+  location.
+
+- taxclass: Filter on package taxclass.
+
+- taxclassNULL: With "nottax", limit to items that would be subject to the
+  tax with taxclass = NULL.  This doesn't necessarily mean part_pkg.taxclass
+  is NULL; it also includes taxclasses that don't have a tax in this region.
+
+- itemdesc: Limit to line items with this description.  Note that non-tax
+  packages usually have a description of NULL.  (Deprecated.)
+
+- report_group: Can contain '=' or '!=' followed by a string to limit to 
+  line items where itemdesc starts with, or doesn't start with, the string.
+
+- cust_tax: Limit to customers who are tax-exempt.  If "taxname" is also
+  specified, limit to customers who are also specifically exempt from that 
+  tax.
+
+- pkg_tax: Limit to packages that are tax-exempt, and only include the 
+  exempt portion (setup, recurring, or both) when calculating totals.
+
+- taxable: Limit to packages that are subject to tax, i.e. where a
+  cust_bill_pkg_tax_location record exists.
+
+- credit: Limit to line items that received a credit application.  The
+  amount of the credit will also be shown.
+
+</%doc>
 <%init>
 
-#LOTS of false laziness below w/cust_credit_bill_pkg.cgi
+my $curuser = $FS::CurrentUser::CurrentUser;
 
-die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+die "access denied" unless $curuser->access_right('Financial reports');
 
 my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
 
 my @select = ( 'cust_bill_pkg.*', 'cust_bill._date' );
-my ($join_cust, $join_pkg ) = ('', '');
+my @total = ( 'COUNT(*)' );
+my @total_desc = ();
+
+my @peritem = ( 'setup', 'recur' );
+my @peritem_desc = ( 'Setup charges', 'Recurring charges' );
+
+my @currency_desc = ();
+my @currency_sub = ();
+my @currency = ();
+if ( $conf->config('currencies') ) {
+  @currency_desc = ( 'Setup billed', 'Recurring billed' );
+  @currency_sub = (
+    map {
+      my $what = $_;
+      sub { my $currency = $_[0]->get($what.'_billed_currency') or return '';
+            $currency. ' '. currency_symbol($currency, SYM_HTML).
+              $_[0]->get($what.'_billed_amount');
+          };
+    } qw( setup recur )
+  );
+  @currency = ( 'setup_billed_amount', 'recur_billed_amount' ); #for sorting
+}
 
-#here is the agent virtualization
+my @pkgnum_header = ();
+my @pkgnum = ();
+my @pkgnum_null;
+my $pkgnum_align = '';
+if ( $curuser->option('show_pkgnum') ) {
+  push @select, 'cust_bill_pkg.pkgnum';
+  push @pkgnum_header, 'Pkg Num';
+  push @pkgnum, sub { $_[0]->pkgnum > 0 ? $_[0]->pkgnum : '' };
+  push @pkgnum_null, '';
+  $pkgnum_align .= 'r';
+}
+
+my @post_desc_header = ();
+my @post_desc = ();
+my @post_desc_null = ();
+my $post_desc_align = '';
+if ( $conf->exists('enable_taxclasses') ) {
+  push @post_desc_header, 'Tax class';
+  push @post_desc, 'taxclass';
+  push @post_desc_null, '';
+  $post_desc_align .= 'l';
+}
+
+# used in several places
+my $itemdesc = 'COALESCE(cust_bill_pkg.itemdesc, part_fee.itemdesc, part_pkg.pkg, cust_bill_pkg.itemdesc)';
+
+# valid in both the tax and non-tax cases
+my $join_cust = 
+  " LEFT JOIN cust_bill ON (cust_bill_pkg.invnum = cust_bill.invnum)".
+  # use cust_pkg.locationnum if it exists
+  FS::UI::Web::join_cust_main('cust_bill', 'cust_pkg');
+
+#agent virtualization
 my $agentnums_sql =
   $FS::CurrentUser::CurrentUser->agentnums_sql( 'table' => 'cust_main' );
 
 my @where = ( $agentnums_sql );
 
+# date range
 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
 
-if ( $cgi->param('status') =~ /^([a-z]+)$/ ) {
-  push @where, FS::cust_main->cust_status_sql . " = '$1'";
-}
-
 if ( $cgi->param('distribute') == 1 ) {
   push @where, "sdate <= $ending",
                "edate >  $beginning",
   ;
-}
-else {
+} else {
   push @where, "cust_bill._date >= $beginning",
                "cust_bill._date <= $ending";
 }
 
+# status
+if ( $cgi->param('status') =~ /^([a-z]+)$/ ) {
+  push @where, FS::cust_main->cust_status_sql . " = '$1'";
+}
+
+# agentnum
 if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.agentnum = $1";
 }
 
+# salesnum--see below
+# refnum
 if ( $cgi->param('refnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.refnum = $1";
 }
 
-#classnum
-# not specified: all classes
-# 0: empty class
-# N: classnum
-my $use_override = $cgi->param('use_override');
-if ( $cgi->param('classnum') =~ /^(\d+)$/ ) {
-  my $comparison = '';
-  if ( $1 == 0 ) {
-    $comparison = "IS NULL";
-  } else {
-    $comparison = "= $1";
-  }
-
-  if ( $use_override ) {
-    push @where, "(
-      part_pkg.classnum $comparison AND pkgpart_override IS NULL OR
-      override.classnum $comparison AND pkgpart_override IS NOT NULL
-    )";
-  } else {
-    push @where, "part_pkg.classnum $comparison";
-  }
+# cust_classnum (false laziness w/ elements/cust_main_dayranges.html, elements/cust_pay_or_refund.html, prepaid_income.html, cust_bill_pay.html, cust_bill_pkg_referral.html, unearned_detail.html, cust_credit.html, cust_credit_refund.html, cust_main::Search::search_sql)
+if ( grep { $_ eq 'cust_classnum' } $cgi->param ) {
+  my @classnums = grep /^\d*$/, $cgi->param('cust_classnum');
+  push @where, 'COALESCE( cust_main.classnum, 0) IN ( '.
+                   join(',', map { $_ || '0' } @classnums ).
+               ' )'
+    if @classnums;
 }
 
-if ( $cgi->param('taxclass')
-     && ! $cgi->param('istax')  #no part_pkg.taxclass in this case
-                                #(should we save a taxclass or a link to taxnum
-                                # in cust_bill_pkg or something like
-                                # cust_bill_pkg_tax_location?)
-   )
-{
-
-  #override taxclass when use_override is specified?  probably
-
-    push @where, ' part_pkg.taxclass IN ( '.
-                   join(', ', map dbh->quote($_), $cgi->param('taxclass') ).
-                 ' ) ';
 
+# custnum
+if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+  push @where, "cust_main.custnum = $1";
 }
 
-my @loc_param = qw( district city county state country );
-
-if ( $cgi->param('out') ) {
+# we want the package and its definition if available
+my $join_pkg = 
+' LEFT JOIN cust_pkg      USING (pkgnum) 
+  LEFT JOIN part_pkg      USING (pkgpart)
+  LEFT JOIN part_fee      USING (feepart)';
+
+my $part_pkg = 'part_pkg';
+# "Separate sub-packages from parents"
+my $use_override = $cgi->param('use_override') ? 1 : 0;
+if ( $use_override ) {
+  # still need the real part_pkg for tax applicability, 
+  # so alias this one
+  $join_pkg .= " LEFT JOIN part_pkg AS override ON (
+  COALESCE(cust_bill_pkg.pkgpart_override, cust_pkg.pkgpart, 0) = override.pkgpart
+  )";
+  $part_pkg = 'override';
+}
+push @select, "$part_pkg.pkgpart", "$part_pkg.pkg";
+push @select, "($itemdesc) AS itemdesc"; # available in all report modes
 
-  my ( $loc_sql, @param ) = FS::cust_location->in_county_sql( 'ornull' => 1 );
-#  while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution
-#    $loc_sql =~ s/\?/'cust_main_county.'.shift(@param)/e;
-#  }
+push @select, "COALESCE($part_pkg.taxclass, part_fee.taxclass) AS taxclass"
+  if $conf->exists('enable_taxclasses');
 
-  push @where, "
-    0 = (
-          SELECT COUNT(*) FROM cust_main_county
-           WHERE cust_main_county.tax > 0
-             AND $loc_sql
-        )
-  ";
+# the non-tax case
+if ( $cgi->param('nottax') ) {
 
-  #not linked to by anything, but useful for debugging "out of taxable region"
-  if ( grep $cgi->param($_), @loc_param ) {
+  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;
+  }
 
-    my %ph = map { $_ => dbh->quote( scalar($cgi->param($_)) ) } @loc_param;
+  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,
+      );
+  }
 
-    my ( $loc_sql, @param ) = FS::cust_location->in_county_sql(param => 1);
-    while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution
-      $loc_sql =~ s/\?/$ph{shift(@param)}/e;
-    }
+  # 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') ).
+                 ')';
+  }
 
-    push @where, $loc_sql;
+  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'));
   }
 
-} elsif ( $cgi->param('country') ) { # and not $cgi->param('out')
+  # 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;
+  }
 
-  my @counties = $cgi->param('county');
-   
-  if ( scalar(@counties) > 1 ) {
+  # specific taxnums
+  if ( $cgi->param('taxnum') =~ /^([\d,]+)$/) {
+    push @tax_where, "cust_main_county.taxnum IN ($1)";
+  }
 
-    #hacky, could be more efficient.  care if it is ever used for more than the
-    # tax-report_groups filtering kludge
+  # 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
 
-    my $locs_sql =
-      ' ( '. join(' OR ', map {
+    if ( @exempt_where or @tax_where or $cgi->param('taxable') ) {
 
-          my %ph = ( 'county' => dbh->quote($_),
-                     map { $_ => dbh->quote( $cgi->param($_) ) }
-                       qw( district city state country )
-                   );
+      push @exempt_where, "cust_tax_exempt_pkg.creditbillpkgnum IS NULL";
 
-          my ( $loc_sql, @param ) = FS::cust_location->in_county_sql(param => 1);
-          while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution
-            $loc_sql =~ s/\?/$ph{shift(@param)}/e;
-          }
+      # 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)';
 
-          $loc_sql;
+      $exempt_sub .= ' WHERE '.join(' AND ', @tax_where, @exempt_where);
 
-        } @counties
+      $exempt_sub .= ' GROUP BY billpkgnum';
 
-      ). ' ) ';
+      $join_pkg .= " LEFT JOIN ($exempt_sub) AS item_exempt
+      ON (cust_bill_pkg.billpkgnum = item_exempt.billpkgnum)";
 
-    push @where, $locs_sql;
+    }
 
-  } else { #scalar(@counties) <= 1
+    if ( @tax_where or $cgi->param('taxable') ) {
+      # process tax restrictions
+      unshift @tax_where,
+        'cust_main_county.tax > 0';
 
-    my %ph = map { $_ => dbh->quote( scalar($cgi->param($_)) ) } @loc_param;
+      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";
 
-    
-    my ( $loc_sql, @param ) = FS::cust_location->in_county_sql(param => 1);
-    while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution
-      $loc_sql =~ s/\?/$ph{shift(@param)}/e;
+      $join_pkg .= " LEFT JOIN ($tax_sub) AS item_tax
+      ON (cust_bill_pkg.billpkgnum = item_tax.taxable_billpkgnum)"
     }
 
-    push @where, $loc_sql;
+    # 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)';
 
-  }
-   
-  if ( $cgi->param('istax') ) {
-    if ( $cgi->param('taxname') ) {
-      push @where, 'itemdesc = '. dbh->quote( $cgi->param('taxname') );
-    #} elsif ( $cgi->param('taxnameNULL') {
-    } else {
-      push @where, "( itemdesc IS NULL OR itemdesc = '' OR itemdesc = 'Tax' )";
     }
-  } elsif ( $cgi->param('nottax') ) {
-    #what can we usefully do with "taxname" ????  look up a class???
-  } else {
-    #warn "neither nottax nor istax parameters specified";
-  }
 
-  if ( $cgi->param('taxclassNULL')
-       && ! $cgi->param('istax')  #no part_pkg.taxclass in this case
-                                  #(see comment above?)
-     )
-  {
-    my %hash = ( 'country' => scalar($cgi->param('country')) );
-    foreach (qw( state county )) {
-      $hash{$_} = scalar($cgi->param($_)) if $cgi->param($_);
+  } # 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",
+                      "&minus; $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";
     }
-    my $cust_main_county = qsearchs('cust_main_county', \%hash);
-    die "unknown base region for empty taxclass" unless $cust_main_county;
-
-    my $same_sql = $cust_main_county->sql_taxclass_sameregion;
-    $same_sql =~ s/taxclass/part_pkg.taxclass/g;
-    push @where, $same_sql if $same_sql;
 
-  }
-
-} elsif ( scalar( grep( /locationtaxid/, $cgi->param ) ) ) {
-# and not $cgi->param('out' or 'country')
+  } 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";
+    }
 
-  push @where, FS::tax_rate_location->location_sql(
-                 map { $_ => (scalar($cgi->param($_)) || '') }
-                   qw( district city county state locationtaxid )
-               );
+  } elsif ( $charges{S} ) {
 
-}
+    push @where, "cust_bill_pkg.setup > 0";
+    push @total, "SUM(cust_bill_pkg.setup)";
+    push @total_desc, "$money_char%.2f setup";
 
-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 huh? you have to have SOME charges
 
-if ( $cgi->param('report_group') =~ /^(=|!=) (.*)$/ && $cgi->param('istax') ) {
-  my ( $group_op, $group_value ) = ( $1, $2 );
-  if ( $group_op eq '=' ) {
-    #push @where, 'itemdesc LIKE '. dbh->quote($group_value.'%');
-    push @where, 'itemdesc = '. dbh->quote($group_value);
-  } elsif ( $group_op eq '!=' ) {
-    push @where, '( itemdesc != '. dbh->quote($group_value) .' OR itemdesc IS NULL )';
-  } else {
-    die "guru meditation #00de: group_op $group_op\n";
-  }
-  
-}
+} elsif ( $cgi->param('istax') ) {
 
-push @where, 'cust_bill_pkg.pkgnum != 0' if $cgi->param('nottax');
-push @where, 'cust_bill_pkg.pkgnum  = 0' if $cgi->param('istax');
-
-if ( $cgi->param('cust_tax') ) {
-  #false laziness -ish w/report_tax.cgi
-  my $cust_exempt;
-  if ( $cgi->param('taxname') ) {
-    my $q_taxname = dbh->quote($cgi->param('taxname'));
-    $cust_exempt =
-      "( tax = 'Y'
-         OR EXISTS ( SELECT 1 FROM cust_main_exemption
-                       WHERE cust_main_exemption.custnum = cust_main.custnum
-                         AND cust_main_exemption.taxname = $q_taxname )
-       )
-      ";
-  } else {
-    $cust_exempt = " tax = 'Y' ";
-  }
+  # 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";
+      }
+    }
 
-  push @where, $cust_exempt;
-}
+    # 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'));
+      }
+    }
 
-my $use_usage = $cgi->param('use_usage');
-
-my $count_query;
-if ( $cgi->param('pkg_tax') ) {
-
-  $count_query =
-    "SELECT COUNT(*),
-            SUM(
-                 ( CASE WHEN part_pkg.setuptax = 'Y'
-                        THEN cust_bill_pkg.setup
-                        ELSE 0
-                   END
-                 )
-                 +
-                 ( CASE WHEN part_pkg.recurtax = 'Y'
-                        THEN cust_bill_pkg.recur
-                        ELSE 0
-                   END
-                 )
-               )
-    ";
-
-  push @where, "(    ( part_pkg.setuptax = 'Y' AND cust_bill_pkg.setup > 0 )
-                  OR ( part_pkg.recurtax = 'Y' AND cust_bill_pkg.recur > 0 ) )",
-               "( tax != 'Y' OR tax IS NULL )";
-
-} elsif ( $cgi->param('taxable') ) {
-
-  my $setup_taxable = "(
-    CASE WHEN part_pkg.setuptax = 'Y'
-         THEN 0
-         ELSE cust_bill_pkg.setup
-    END
-  )";
+  } 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;
+    }
 
-  my $recur_taxable = "(
-    CASE WHEN part_pkg.recurtax = 'Y'
-         THEN 0
-         ELSE cust_bill_pkg.recur
-    END
-  )";
+    # taxclass
+    if ( $cgi->param('taxclassNULL') ) {
+      push @tax_where, 'cust_main_county.taxclass IS NULL';
+    }
 
-  my $exempt = "(
-    SELECT COALESCE( SUM(amount), 0 ) FROM cust_tax_exempt_pkg
-      WHERE cust_tax_exempt_pkg.billpkgnum = cust_bill_pkg.billpkgnum
-  )";
+    # 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'));
+    }
 
-  $count_query =
-    "SELECT COUNT(*), SUM( $setup_taxable + $recur_taxable - $exempt )";
+    # 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'));
+      }
+    }
 
-  push @where,
-    #not tax-exempt package (setup or recur)
-    "(
-          ( ( part_pkg.setuptax != 'Y' OR part_pkg.setuptax IS NULL )
-            AND cust_bill_pkg.setup > 0 )
-       OR
-          ( ( part_pkg.recurtax != 'Y' OR part_pkg.recurtax IS NULL )
-            AND cust_bill_pkg.recur > 0 )
-    )",
-    #not a tax_exempt customer
-    "( tax != 'Y' OR tax IS NULL )", # assume this was intended?
-    #not covered in full by a monthly tax exemption (texas tax)
-    "0 < ( $setup_taxable + $recur_taxable - $exempt )";
+    # specific taxnums (the usual way)
+    if ( $cgi->param('taxnum') =~ /^([\d,]+)$/) {
+      push @tax_where, "cust_main_county.taxnum IN ($1)";
+    }
 
-} else {
+    $tax_subquery = "$tax_select $tax_from";
 
-  if ( $use_usage ) {
-    $count_query = "SELECT COUNT(*), ";
-  } else {
-    $count_query = "SELECT COUNT(DISTINCT billpkgnum), ";
-  }
+  } # end of internal-tax case
 
-  if ( $use_usage eq 'recurring' ) {
-    $count_query .= "SUM(cust_bill_pkg.setup + cust_bill_pkg.recur - usage)";
-  } elsif ( $use_usage eq 'usage' ) {
-    $count_query .= "SUM(usage)";
-  } elsif ( scalar( grep( /locationtaxid/, $cgi->param ) ) ) {
-    $count_query .= "SUM( COALESCE(cust_bill_pkg_tax_rate_location.amount, cust_bill_pkg.setup + cust_bill_pkg.recur))";
-  } elsif ( $cgi->param('iscredit') eq 'rate') {
-    $count_query .= "SUM( cust_credit_bill_pkg.amount )";
-  } else {
-    $count_query .= "SUM(cust_bill_pkg.setup + cust_bill_pkg.recur)";
+  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_cust =  '        JOIN cust_bill USING ( invnum )
-                  LEFT JOIN cust_main USING ( custnum ) ';
+  $join_pkg .= " JOIN ($tax_subquery) AS _istax USING (billpkgnum) ";
 
-if ( $cgi->param('nottax') ) {
+  push @select, 'tax_total';
 
-  $join_pkg .=  ' LEFT JOIN cust_pkg USING ( pkgnum )
-                  LEFT JOIN part_pkg USING ( pkgpart )
-                  LEFT JOIN part_pkg AS override
-                    ON pkgpart_override = override.pkgpart
-                  LEFT JOIN cust_location
-                    ON cust_location.locationnum = '.
-                    FS::cust_pkg->tax_locationnum_sql;
+  @peritem = ( 'setup' ); # total tax on the invoice, not just the filtered tax
+  @peritem_desc = ( 'Tax charge' );
 
-} elsif ( $cgi->param('istax') ) {
+  @total = ( 'COUNT(cust_bill_pkg.billpkgnum)',
+             'SUM(cust_bill_pkg.setup)' );
+  @total_desc = ( "$money_char%.2f total tax" );
 
-  #false laziness w/report_tax.cgi $taxfromwhere
-  if ( scalar( grep( /locationtaxid/, $cgi->param ) ) ||
-            $cgi->param('iscredit') eq 'rate') {
+  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
+  }
 
-    # using tax_rate_location and friends
-    $join_pkg .=
-      ' LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum ) '.
-      ' LEFT JOIN tax_rate_location USING ( taxratelocationnum ) ';
+} # 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";
+}
 
-  #} elsif ( $conf->exists('tax-pkg_address') ) {
-  } else {
+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";
 
-    # using cust_bill_pkg_tax_location to relate tax items to locations
-    # ...but for consolidated taxes we don't want to duplicate this
-    my $tax_item_location = '(SELECT DISTINCT billpkgnum, locationnum
-      FROM cust_bill_pkg_tax_location) AS tax_item_location';
+$join_pkg .= " LEFT JOIN ($credit_sub) AS item_credit
+  ON (cust_bill_pkg.billpkgnum = item_credit.billpkgnum)";
+push @select, 'credit_amount';
 
-    $join_pkg .= " LEFT JOIN $tax_item_location USING ( billpkgnum )
-                   LEFT JOIN cust_location
-                    ON tax_item_location.locationnum =
-                       cust_location.locationnum ";
+# standard customer fields
+push @select, 'cust_main.custnum', FS::UI::Web::cust_sql_fields();
 
-    #quelle kludge, somewhat false laziness w/report_tax.cgi
-    s/cust_pkg\.locationnum/tax_item_location.locationnum/g for @where;
-  }
+#salesnum
+if ( $cgi->param('salesnum') =~ /^(\d+)$/ ) {
 
-  if ( $cgi->param('iscredit') ) {
-    $join_pkg .= ' JOIN cust_credit_bill_pkg USING ( billpkgnum';
-    if ( $cgi->param('iscredit') eq 'rate' ) {
-      $join_pkg .= ', billpkgtaxratelocationnum )';
-    } elsif ( $conf->exists('tax-pkg_address') ) {
-      $join_pkg .= ', billpkgtaxlocationnum )';
-      push @where, "billpkgtaxratelocationnum IS NULL";
-    } else {
-      $join_pkg .= ' )';
-      push @where, "billpkgtaxratelocationnum IS NULL";
-    }
-  }
+  my $salesnum = $1;
+  my $sales = FS::sales->by_key($salesnum)
+    or die "salesnum $salesnum not found";
 
-} else { 
+  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) )";
 
-  #die?
-  warn "neither nottax nor istax parameters specified";
-  #same as before?
-  $join_pkg =  ' LEFT JOIN cust_pkg USING ( pkgnum )
-                 LEFT JOIN part_pkg USING ( pkgpart ) ';
+  my $extra_sql = $subsearch->{extra_sql};
+  $extra_sql =~ s/^WHERE//;
+  push @where, $extra_sql;
 
+  $cgi->param('classnum', 0) unless $cgi->param('classnum');
 }
 
-my $where = ' WHERE '. join(' AND ', @where);
-
-if ($use_usage) {
-  $count_query .=
-    " FROM (SELECT cust_bill_pkg.setup, cust_bill_pkg.recur, 
-             ( SELECT COALESCE( SUM(amount), 0 ) FROM cust_bill_pkg_detail
-               WHERE cust_bill_pkg.billpkgnum = cust_bill_pkg_detail.billpkgnum
-             ) AS usage FROM cust_bill_pkg  $join_cust $join_pkg $where
-           ) AS countquery";
-} else {
-  $count_query .= " FROM cust_bill_pkg $join_cust $join_pkg $where";
+#credit flag (include only those that have credit(s) applied)
+if ( $cgi->param('credit') ) {
+  push @where, 'credit_amount > 0';
 }
 
-push @select, 'part_pkg.pkg',
-              'part_pkg.freq',
-  unless $cgi->param('istax');
-
-push @select, 'cust_main.custnum',
-              FS::UI::Web::cust_sql_fields();
+my $where = join(' AND ', @where);
+$where &&= "WHERE $where";
 
 my $query = {
   'table'     => 'cust_bill_pkg',
-  'addl_from' => "$join_cust $join_pkg",
+  'addl_from' => "$join_pkg $join_cust",
   'hashref'   => {},
   'select'    => join(",\n", @select ),
   'extra_sql' => $where,
-  'order_by'  => 'ORDER BY cust_bill._date, billpkgnum',
+  'order_by'  => 'ORDER BY cust_bill._date, cust_bill_pkg.billpkgnum',
 };
 
+my $count_query =
+  'SELECT ' . join(',', @total) .
+  " FROM cust_bill_pkg $join_pkg $join_cust
+  $where";
+
+@peritem_desc = map {emt($_)} @peritem_desc;
+my @peritem_sub = map {
+  my $field = $_;
+  if ($field =~ /_text$/) { # kludge for credit reason/username fields
+    sub {$_[0]->get($field)};
+  } else {
+    sub { sprintf($money_char.'%.2f', $_[0]->get($field)) }
+  }
+} @peritem;
+my @peritem_null = map { '' } @peritem; # placeholders
+my $peritem_align = 'r' x scalar(@peritem);
+
+@currency_desc = map {emt($_)} @currency_desc;
+my @currency_null = map { '' } @currency; # placeholders
+my $currency_align = 'r' x scalar(@currency);
+
 my $ilink = [ "${p}view/cust_bill.cgi?", 'invnum' ];
 my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ];
 
-my $conf = new FS::Conf;
-my $money_char = $conf->config('money_char') || '$';
+my $pay_link    = ''; #[, 'billpkgnum', ];
+my $credit_param = '';
+foreach ('credit_begin', 'credit_end') {
+  if ( $cgi->param($_) ) {
+    $credit_param .= "$_=" . $cgi->param($_) . ';';
+  }
+}
+my $credit_link = [ "${p}search/cust_credit_bill_pkg.html?${credit_param}billpkgnum=", 'billpkgnum', ];
+
+warn "\n\nQUERY:\n".Dumper($query)."\n\nCOUNT_QUERY:\n$count_query\n\n"
+  if $cgi->param('debug');
 
-my $owed_sub = sub {
-  $money_char . shift->get('owed') # owed_recur is not correct here
-};
-my $payment_date_sub = sub {
-  #my $cust_bill_pkg = shift;
-  my @cust_pay = sort { $a->_date <=> $b->_date }
-                      map $_->cust_bill_pay->cust_pay,
-                          shift->cust_bill_pay_pkg('recur') #recur :/
-    or return '';
-  time2str('%b %d %Y', $cust_pay[-1]->_date );
-};
-warn $count_query;
 </%init>