X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=httemplate%2Fsearch%2Fcust_bill_pkg.cgi;h=ab5aad776ad3f2aea37af3919d2701c7a33510c2;hp=975d02a5ad5a7e23e4bde6f616aec87295219a54;hb=b71b1576c68bc40ad26592b354feace37a029f0e;hpb=25353c2067b60343e0c17ebc956a4d35baf1dbb4 diff --git a/httemplate/search/cust_bill_pkg.cgi b/httemplate/search/cust_bill_pkg.cgi index 975d02a5a..ab5aad776 100644 --- a/httemplate/search/cust_bill_pkg.cgi +++ b/httemplate/search/cust_bill_pkg.cgi @@ -1,601 +1,803 @@ -<% include( 'elements/search.html', - 'title' => 'Line items', - 'name' => 'line items', +<& elements/search.html, + 'title' => emt('Line items'), + 'name' => emt('line items'), 'query' => $query, 'count_query' => $count_query, - 'count_addl' => [ $money_char. '%.2f total', - $unearned ? ( $money_char. '%.2f unearned revenue' ) : (), - ], + 'count_addl' => \@total_desc, 'header' => [ - #'#', - 'Description', - ( $unearned - ? ( 'Unearned', 'Owed', 'Payment date' ) - : ( 'Setup charge' ) - ), - ( $use_usage eq 'usage' - ? 'Usage charge' - : 'Recurring charge' - ), - ( $unearned - ? ( 'Charge start', 'Charge end' ) - : () - ), - 'Invoice', - 'Date', + @pkgnum_header, + emt('Pkg Def'), + emt('Description'), + @post_desc_header, + @peritem_desc, + @currency_desc, + emt('Invoice'), + emt('Date'), + emt('Paid'), + emt('Credited'), FS::UI::Web::cust_header(), ], 'fields' => [ - #'billpkgnum', + @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; - if ( $unearned ) { - my $period = - $cust_bill_pkg->edate - $cust_bill_pkg->sdate; - my $elapsed = $unearned - $cust_bill_pkg->sdate; - $elapsed = 0 if $elapsed < 0; - - my $remaining = 1 - $elapsed/$period; - - sprintf($money_char. '%.2f', - $remaining * $cust_bill_pkg->recur ); - - } else { - sprintf($money_char.'%.2f', $cust_bill_pkg->setup ); - } - }, - ( $unearned - ? ( $owed_sub, $payment_date_sub, ) - : () - ), - 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 ); - }, - ( $unearned - ? ( sub { time2str('%b %d %Y', shift->sdate ) }, - sub { time2str('%b %d %Y', shift->edate ) }, - ) - : () - ), + @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' => [ - 'setup', #broken in $unearned case i guess - ( $unearned ? ('', '') : () ), - ( $use_usage eq 'recurring' ? 'recur - usage' : - $use_usage eq 'usage' ? 'usage' - : 'recur' - ), - ( $unearned ? ('sdate', 'edate') : () ), + @pkgnum_null, + '', + '', + @post_desc_null, + @peritem, + @currency, 'invnum', '_date', + 'pay_amount', + 'credit_amount', + FS::UI::Web::cust_sort_fields(), ], 'links' => [ - #'', - '', + @pkgnum_null, '', - ( $unearned ? ( '', '' ) : () ), '', - ( $unearned ? ( '', '' ) : () ), + @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'. - ( $unearned ? 'rc' : '' ). - 'r'. - ( $unearned ? 'cc' : '' ). - 'rc'. + 'align' => $pkgnum_align. + 'rl'. + $post_desc_align. + $peritem_align. + $currency_align. + 'rcrr'. FS::UI::Web::cust_aligns(), 'color' => [ - #'', + @pkgnum_null, '', '', - ( $unearned ? ( '', '' ) : () ), + @post_desc_null, + @peritem_null, + @currency_null, + '', '', - ( $unearned ? ( '', '' ) : () ), '', '', FS::UI::Web::cust_colors(), ], 'style' => [ - #'', + @pkgnum_null, + '', '', + @post_desc_null, + @peritem_null, + @currency_null, '', - ( $unearned ? ( '', '' ) : () ), '', - ( $unearned ? ( '', '' ) : () ), '', '', FS::UI::Web::cust_styles(), ], - ) -%> -<%init> +&> +<%doc> -#LOTS of false laziness below w/cust_credit_bill_pkg.cgi +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. -die "access denied" - unless $FS::CurrentUser::CurrentUser->access_right('Financial reports'); +- charges: 'S'etup, 'R'ecur, 'U'sage, or any string combination of those. -my $conf = new FS::Conf; +Filtering parameters: +- begin, end: Date range. Applies to invoice date, not necessarily package + date range. But see "distribute". -my $unearned = ''; +- status: Customer status (active, suspended, etc.). This will filter on + _current_ customer status, not status at the time the invoice was generated. -#here is the agent virtualization -my $agentnums_sql = - $FS::CurrentUser::CurrentUser->agentnums_sql( 'table' => 'cust_main' ); +- agentnum: Filter on customer agent. -my @where = ( $agentnums_sql ); +- refnum: Filter on customer reference source. -my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi); -push @where, "_date >= $beginning", - "_date <= $ending"; +- cust_classnum: Filter on customer class. -if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) { - push @where, "cust_main.agentnum = $1"; -} +- classnum: Filter on package class. -#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"; - } +- 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". - 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"; - } -} +- use_override: Apply "classnum" and "taxclass" filtering based on the + override (bundle) pkgpart, rather than always using the true pkgpart. -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 - #if ( $use_override ) { - # - # push @where, - # ' ( '. join(' OR ', - # map { - # ' ( part_pkg.taxclass = '. dbh->quote($_). - # ' AND pkgpart_override IS NULL '. - # ' OR '. - # ' override.taxclass = '. dbh->quote($_). - # ' AND pkgpart_override IS NOT NULL '. - # ' ) ' - # } - # $cgi->param('taxclass') - # ). - # ' ) '; - # - #} else { - - push @where, - ' ( '. join(' OR ', - map ' part_pkg.taxclass = '.dbh->quote($_), - $cgi->param('taxclass') - ). - ' ) '; - - #} +- 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). -my @loc_param = qw( city county state country ); +- 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. -if ( $cgi->param('out') ) { +- 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. - my ( $loc_sql, @param ) = FS::cust_pkg->location_sql( 'ornull' => 1 ); - while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution - $loc_sql =~ s/\?/'cust_main_county.'.shift(@param)/e; - } +- 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'". - $loc_sql =~ s/cust_pkg\.locationnum/cust_bill_pkg_tax_location.locationnum/g - if $cgi->param('istax'); +- 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. - push @where, " - 0 = ( - SELECT COUNT(*) FROM cust_main_county - WHERE cust_main_county.tax > 0 - AND $loc_sql - ) - "; +- locationtaxid: With "nottax", limit to packages matching this + tax_rate_location ID; with "tax", limit to taxes generated from that + location. - #not linked to by anything, but useful for debugging "out of taxable region" - if ( grep $cgi->param($_), @loc_param ) { +- taxclass: Filter on package taxclass. - my %ph = map { $_ => dbh->quote( scalar($cgi->param($_)) ) } @loc_param; +- 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. - my ( $loc_sql, @param ) = FS::cust_pkg->location_sql; - while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution - $loc_sql =~ s/\?/$ph{shift(@param)}/e; - } +- itemdesc: Limit to line items with this description. Note that non-tax + packages usually have a description of NULL. (Deprecated.) - push @where, $loc_sql; +- 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. -} elsif ( $cgi->param('country') ) { +- pkg_tax: Limit to packages that are tax-exempt, and only include the + exempt portion (setup, recurring, or both) when calculating totals. - my @counties = $cgi->param('county'); - - if ( scalar(@counties) > 1 ) { +- taxable: Limit to packages that are subject to tax, i.e. where a + cust_bill_pkg_tax_location record exists. - #hacky, could be more efficient. care if it is ever used for more than the - # tax-report_groups filtering kludge +- credit: Limit to line items that received a credit application. The + amount of the credit will also be shown. - my $locs_sql = - ' ( '. join(' OR ', map { + +<%init> - my %ph = ( 'county' => dbh->quote($_), - map { $_ => dbh->quote( $cgi->param($_) ) } - qw( city state country ) - ); +my $curuser = $FS::CurrentUser::CurrentUser; - my ( $loc_sql, @param ) = FS::cust_pkg->location_sql; - while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution - $loc_sql =~ s/\?/$ph{shift(@param)}/e; - } +die "access denied" unless $curuser->access_right('Financial reports'); - $loc_sql; +my $conf = new FS::Conf; +my $money_char = $conf->config('money_char') || '$'; - } @counties +my @select = ( 'cust_bill_pkg.*', 'cust_bill._date' ); +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 +} - ). ' ) '; +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'; +} - push @where, $locs_sql; +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'; +} - } else { +# used in several places +my $itemdesc = 'COALESCE(cust_bill_pkg.itemdesc, part_fee.itemdesc, part_pkg.pkg, cust_bill_pkg.itemdesc)'; - my %ph = map { $_ => dbh->quote( scalar($cgi->param($_)) ) } @loc_param; +# 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'); - my ( $loc_sql, @param ) = FS::cust_pkg->location_sql; - while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution - $loc_sql =~ s/\?/$ph{shift(@param)}/e; - } +#agent virtualization +my $agentnums_sql = + $FS::CurrentUser::CurrentUser->agentnums_sql( 'table' => 'cust_main' ); - push @where, $loc_sql; +my @where = ( $agentnums_sql ); - } - - 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"; - } +# date range +my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi); - if ( $cgi->param('taxclassNULL') ) { +if ( $cgi->param('distribute') == 1 ) { + push @where, "sdate <= $ending", + "edate > $beginning", + ; +} else { + push @where, "cust_bill._date >= $beginning", + "cust_bill._date <= $ending"; +} - my %hash = ( 'country' => scalar($cgi->param('country')) ); - foreach (qw( state county )) { - $hash{$_} = scalar($cgi->param($_)) if $cgi->param($_); - } - my $cust_main_county = qsearchs('cust_main_county', \%hash); - die "unknown base region for empty taxclass" unless $cust_main_county; +# status +if ( $cgi->param('status') =~ /^([a-z]+)$/ ) { + push @where, FS::cust_main->cust_status_sql . " = '$1'"; +} - my $same_sql = $cust_main_county->sql_taxclass_sameregion; - push @where, $same_sql if $same_sql; +# 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"; +} -} elsif ( scalar( grep( /locationtaxid/, $cgi->param ) ) ) { +# 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; +} - push @where, FS::tax_rate_location->location_sql( - map { $_ => (scalar($cgi->param($_)) || '') } - qw( city county state locationtaxid ) - ); -} elsif ( $cgi->param('unearned_now') =~ /^(\d+)$/ ) { +# custnum +if ( $cgi->param('custnum') =~ /^(\d+)$/ ) { + push @where, "cust_main.custnum = $1"; +} - $unearned = $1; +# 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 - push @where, "cust_bill_pkg.sdate < $unearned", - "cust_bill_pkg.edate > $unearned", - "cust_bill_pkg.recur != 0", - "part_pkg.freq != '0'", - "part_pkg.freq != '1'", - "part_pkg.freq NOT LIKE '%h'", - "part_pkg.freq NOT LIKE '%d'", - "part_pkg.freq NOT LIKE '%w'"; +push @select, "COALESCE($part_pkg.taxclass, part_fee.taxclass) AS taxclass" + if $conf->exists('enable_taxclasses'); -} +# the non-tax case +if ( $cgi->param('nottax') ) { -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, + '(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 ( $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"; + 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, + ); } - -} -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' "; + # 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, $cust_exempt; -} + if ( $cgi->param('exempt_cust') eq 'Y' ) { + # tax-exempt customers + push @exempt_where, "(exempt_cust = 'Y' OR exempt_cust_taxname = 'Y')"; -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 - )"; + } 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')); + } - my $recur_taxable = "( - CASE WHEN part_pkg.recurtax = 'Y' - THEN 0 - ELSE cust_bill_pkg.recur - END - )"; + # 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 $exempt = "( - SELECT COALESCE( SUM(amount), 0 ) FROM cust_tax_exempt_pkg - WHERE cust_tax_exempt_pkg.billpkgnum = cust_bill_pkg.billpkgnum - )"; + # specific taxnums + if ( $cgi->param('taxnum') =~ /^([\d,]+)$/) { + push @tax_where, "cust_main_county.taxnum IN ($1)"; + } - $count_query = - "SELECT COUNT(*), SUM( $setup_taxable + $recur_taxable - $exempt )"; + # 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 - 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 )"; - #not covered in full by a monthly tax exemption (texas tax) - "0 < ( $setup_taxable + $recur_taxable - $exempt )", + if ( @exempt_where or @tax_where or $cgi->param('taxable') ) { -} else { + push @exempt_where, "cust_tax_exempt_pkg.creditbillpkgnum IS NULL"; - if ( $use_usage ) { - $count_query = "SELECT COUNT(*), "; - } else { - $count_query = "SELECT COUNT(DISTINCT billpkgnum), "; - } + # 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)'; - if ( $use_usage eq 'recurring' ) { - $count_query .= "SUM(setup + recur - usage)"; - } elsif ( $use_usage eq 'usage' ) { - $count_query .= "SUM(usage)"; - } elsif ( $unearned ) { - $count_query .= "SUM(cust_bill_pkg.recur)"; - } 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)"; - } + $exempt_sub .= ' WHERE '.join(' AND ', @tax_where, @exempt_where); - if ( $unearned ) { + $exempt_sub .= ' GROUP BY billpkgnum'; - #false laziness w/report_prepaid_income.cgi + $join_pkg .= " LEFT JOIN ($exempt_sub) AS item_exempt + ON (cust_bill_pkg.billpkgnum = item_exempt.billpkgnum)"; - my $float = 'REAL'; #'DOUBLE PRECISION'; + } - my $period = "CAST(cust_bill_pkg.edate - cust_bill_pkg.sdate AS $float)"; - my $elapsed = "(CASE WHEN cust_bill_pkg.sdate > $unearned - THEN 0 - ELSE ($unearned - cust_bill_pkg.sdate) - END)"; - #my $elapsed = "CAST($unearned - cust_bill_pkg.sdate AS $float)"; + if ( @tax_where or $cgi->param('taxable') ) { + # process tax restrictions + unshift @tax_where, + 'cust_main_county.tax > 0'; - my $remaining = "(1 - $elapsed/$period)"; + 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"; - $count_query .= ", SUM($remaining * cust_bill_pkg.recur)"; + $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)'; -} + } -my $join_cust = ' JOIN cust_bill USING ( invnum ) - LEFT JOIN cust_main USING ( custnum ) '; + } # 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"; + } -my $join_pkg; -if ( $cgi->param('nottax') ) { + } elsif ( $charges{S} ) { - $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 '; - $join_pkg .= ' LEFT JOIN cust_location USING ( locationnum ) ' - if $conf->exists('tax-pkg_address'); + 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') ) { - #false laziness w/report_tax.cgi $taxfromwhere - if ( scalar( grep( /locationtaxid/, $cgi->param ) ) || - $cgi->param('iscredit') eq 'rate') { + # 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"; + } + } - $join_pkg .= - ' LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum ) '. - ' LEFT JOIN tax_rate_location USING ( taxratelocationnum ) '; + # 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')); + } + } - } elsif ( $conf->exists('tax-pkg_address') ) { + } 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; + } - $join_pkg .= ' LEFT JOIN cust_bill_pkg_tax_location USING ( billpkgnum ) - LEFT JOIN cust_location USING ( locationnum ) '; + # taxclass + if ( $cgi->param('taxclassNULL') ) { + push @tax_where, 'cust_main_county.taxclass IS NULL'; + } - #quelle kludge, somewhat false laziness w/report_tax.cgi - s/cust_pkg\.locationnum/cust_bill_pkg_tax_location.locationnum/g for @where; - } + # 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')); + } + } - 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"; + # 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 '; -} else { + # 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.) - #die? - warn "neiether nottax nor istax parameters specified"; - #same as before? - $join_pkg = ' LEFT JOIN cust_pkg USING ( pkgnum ) - LEFT JOIN part_pkg USING ( pkgpart ) '; + $join_pkg .= " JOIN ($tax_subquery) AS _istax USING (billpkgnum) "; -} + push @select, 'tax_total'; -my $where = ' WHERE '. join(' AND ', @where); + @peritem = ( 'setup' ); # total tax on the invoice, not just the filtered tax + @peritem_desc = ( 'Tax charge' ); -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"; + @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 @select = ( 'cust_bill_pkg.*', - 'cust_bill._date', ); +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"; -push @select, 'part_pkg.pkg', - 'part_pkg.freq', - unless $cgi->param('istax'); + 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) )"; -push @select, 'cust_main.custnum', - FS::UI::Web::cust_sql_fields(); + 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"; my $query = { 'table' => 'cust_bill_pkg', - 'addl_from' => "$join_cust $join_pkg", + 'addl_from' => "$join_pkg $join_cust", 'hashref' => {}, - 'select' => join(', ', @select ), + 'select' => join(",\n", @select ), 'extra_sql' => $where, - 'order_by' => 'ORDER BY _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 $owed_sub = sub { - $money_char. shift->owed_recur; #_recur :/ -}; +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', ]; -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 "\n\nQUERY:\n".Dumper($query)."\n\nCOUNT_QUERY:\n$count_query\n\n" + if $cgi->param('debug');