1 <& elements/search.html,
2 'title' => emt('Line items'),
3 'name' => emt('line items'),
5 'count_query' => $count_query,
6 'count_addl' => \@total_desc,
17 FS::UI::Web::cust_header(),
21 sub { $_[0]->pkgnum > 0
22 # possibly use override.pkg but i think this correct
23 ? $_[0]->get('pkgpart')
26 sub { $_[0]->pkgnum > 0
27 # possibly use override.pkg but i think this correct
29 : $_[0]->get('itemdesc')
32 #strikethrough or "N/A ($amount)" or something these when
33 # they're not applicable to pkg_tax search
36 sub { time2str('%b %d %Y', shift->_date ) },
37 sub { sprintf($money_char.'%.2f', shift->get('pay_amount')) },
38 sub { sprintf($money_char.'%.2f', shift->get('credit_amount')) },
39 \&FS::UI::Web::cust_fields,
51 FS::UI::Web::cust_sort_fields(),
63 ( map { $_ ne 'Cust. Status' ? $clink : '' }
64 FS::UI::Web::cust_header()
67 #'align' => 'rlrrrc'.FS::UI::Web::cust_aligns(),
68 'align' => $pkgnum_align.
73 FS::UI::Web::cust_aligns(),
84 FS::UI::Web::cust_colors(),
96 FS::UI::Web::cust_styles(),
101 Output control parameters:
102 - distribute: Boolean. If true, recurring fees will be "prorated" for the
103 portion of the package date range (sdate-edate) that falls within the date
104 range of the report. Line items will be limited to those for which this
105 portion is > 0. This disables filtering on invoice date.
107 - usage: Separate usage (cust_bill_pkg_detail records) from
108 recurring charges. If set to "usage", will show usage instead of
109 recurring charges. If set to "recurring", will deduct usage and only
110 show the flat rate charge. If not passed, the "recurring charge" column
111 will include usage charges also.
113 Filtering parameters:
114 - begin, end: Date range. Applies to invoice date, not necessarily package
115 date range. But see "distribute".
117 - status: Customer status (active, suspended, etc.). This will filter on
118 _current_ customer status, not status at the time the invoice was generated.
120 - agentnum: Filter on customer agent.
122 - refnum: Filter on customer reference source.
124 - cust_classnum: Filter on customer class.
126 - classnum: Filter on package class.
128 - report_optionnum: Filter on package report class. Can be a single report
129 class number or a comma-separated list (where 0 is "no report class"), or the
132 - use_override: Apply "classnum" and "taxclass" filtering based on the
133 override (bundle) pkgpart, rather than always using the true pkgpart.
135 - nottax: Limit to items that are not taxes (pkgnum > 0).
137 - istax: Limit to items that are taxes (pkgnum == 0).
139 - taxnum: Limit to items whose tax definition matches this taxnum.
140 With "nottax" that means items that are subject to that tax;
141 with "istax" it's the tax charges themselves. Can be specified
142 more than once to include multiple taxes.
144 - country, state, county, city: Limit to items whose tax location
145 matches these fields. If "nottax" it's the tax location of the package;
146 if "istax" the location of the tax.
148 - taxname, taxnameNULL: With "nottax", limit to items whose tax location
149 matches a tax with this name. With "istax", limit to items that have
150 this tax name. taxnameNULL is equivalent to "taxname = '' OR taxname
153 - out: With "nottax", limit to items that don't match any tax definition.
154 With "istax", find tax items that are unlinked to their tax definitions.
155 Current Freeside (> July 2012) always creates tax links, but unlinked
156 items may result from an incomplete upgrade of legacy data.
158 - locationtaxid: With "nottax", limit to packages matching this
159 tax_rate_location ID; with "tax", limit to taxes generated from that
162 - taxclass: Filter on package taxclass.
164 - taxclassNULL: With "nottax", limit to items that would be subject to the
165 tax with taxclass = NULL. This doesn't necessarily mean part_pkg.taxclass
166 is NULL; it also includes taxclasses that don't have a tax in this region.
168 - itemdesc: Limit to line items with this description. Note that non-tax
169 packages usually have a description of NULL. (Deprecated.)
171 - report_group: Can contain '=' or '!=' followed by a string to limit to
172 line items where itemdesc starts with, or doesn't start with, the string.
174 - cust_tax: Limit to customers who are tax-exempt. If "taxname" is also
175 specified, limit to customers who are also specifically exempt from that
178 - pkg_tax: Limit to packages that are tax-exempt, and only include the
179 exempt portion (setup, recurring, or both) when calculating totals.
181 - taxable: Limit to packages that are subject to tax, i.e. where a
182 cust_bill_pkg_tax_location record exists.
184 - credit: Limit to line items that received a credit application. The
185 amount of the credit will also be shown.
190 my $curuser = $FS::CurrentUser::CurrentUser;
192 die "access denied" unless $curuser->access_right('Financial reports');
194 my $conf = new FS::Conf;
195 my $money_char = $conf->config('money_char') || '$';
197 my @select = ( 'cust_bill_pkg.*', 'cust_bill._date' );
198 my @total = ( 'COUNT(*)', 'SUM(cust_bill_pkg.setup + cust_bill_pkg.recur)');
199 my @total_desc = ( $money_char.'%.2f total' ); # sprintf strings
201 my @peritem = ( 'setup', 'recur' );
202 my @peritem_desc = ( 'Setup charge', 'Recurring charge' );
204 my @pkgnum_header = ();
207 my $pkgnum_align = '';
208 if ( $curuser->option('show_pkgnum') ) {
209 push @select, 'cust_bill_pkg.pkgnum';
210 push @pkgnum_header, 'Pkg Num';
211 push @pkgnum, sub { $_[0]->pkgnum > 0 ? $_[0]->pkgnum : '' };
212 push @pkgnum_null, '';
213 $pkgnum_align .= 'r';
216 my @post_desc_header = ();
218 my @post_desc_null = ();
219 my $post_desc_align = '';
220 if ( $conf->exists('enable_taxclasses') ) {
221 push @post_desc_header, 'Tax class';
222 push @post_desc, 'taxclass';
223 push @post_desc_null, '';
224 $post_desc_align .= 'l';
225 push @select, 'part_pkg.taxclass'; # or should this use override?
228 # valid in both the tax and non-tax cases
230 " LEFT JOIN cust_bill ON (cust_bill_pkg.invnum = cust_bill.invnum)".
231 # use cust_pkg.locationnum if it exists
232 FS::UI::Web::join_cust_main('cust_bill', 'cust_pkg');
234 #agent virtualization
236 $FS::CurrentUser::CurrentUser->agentnums_sql( 'table' => 'cust_main' );
238 my @where = ( $agentnums_sql );
241 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
243 if ( $cgi->param('distribute') == 1 ) {
244 push @where, "sdate <= $ending",
245 "edate > $beginning",
249 push @where, "cust_bill._date >= $beginning",
250 "cust_bill._date <= $ending";
254 if ( $cgi->param('status') =~ /^([a-z]+)$/ ) {
255 push @where, FS::cust_main->cust_status_sql . " = '$1'";
259 if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
260 push @where, "cust_main.agentnum = $1";
263 # salesnum--see below
265 if ( $cgi->param('refnum') =~ /^(\d+)$/ ) {
266 push @where, "cust_main.refnum = $1";
269 # 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)
270 if ( grep { $_ eq 'cust_classnum' } $cgi->param ) {
271 my @classnums = grep /^\d*$/, $cgi->param('cust_classnum');
272 push @where, 'COALESCE( cust_main.classnum, 0) IN ( '.
273 join(',', map { $_ || '0' } @classnums ).
280 if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
281 push @where, "cust_main.custnum = $1";
284 # we want the package and its definition if available
286 ' LEFT JOIN cust_pkg USING (pkgnum)
287 LEFT JOIN part_pkg USING (pkgpart)';
289 my $part_pkg = 'part_pkg';
290 if ( $cgi->param('use_override') ) { #"Separate sub-packages from parents"
291 # still need the real part_pkg for tax applicability,
293 $join_pkg .= " LEFT JOIN part_pkg AS override ON (
294 COALESCE(cust_bill_pkg.pkgpart_override, cust_pkg.pkgpart, 0) = override.pkgpart
296 $part_pkg = 'override';
298 push @select, 'part_pkg.pkgpart', 'part_pkg.pkg'; # or should this use override?
301 if ( $cgi->param('nottax') ) {
303 push @where, 'cust_bill_pkg.pkgnum > 0';
305 my @tax_where; # will go into a subquery
306 my @exempt_where; # will also go into a subquery
308 # classnum (of override pkgpart if applicable)
309 # not specified: all classes
312 if ( grep { $_ eq 'classnum' } $cgi->param ) {
313 my @classnums = grep /^\d+$/, $cgi->param('classnum');
314 push @where, "COALESCE($part_pkg.classnum, 0) IN ( ".
315 join(',', @classnums ).
320 if ( grep { $_ eq 'report_optionnum' } $cgi->param ) {
321 my @nums = grep /^\w+$/, $cgi->param('report_optionnum');
322 my $num = join(',', @nums);
323 push @where, # code reuse FTW
324 FS::Report::Table->with_report_option( $num, $cgi->param('use_override'));
327 if ( $cgi->param('report_optionnum') =~ /^(\w+)$/ ) {
332 if ( $cgi->param('taxclassNULL') ) {
333 # a little different from 'taxclass' in that it applies to the
334 # effective taxclass, not the real one
335 push @tax_where, 'cust_main_county.taxclass IS NULL'
336 } elsif ( $cgi->param('taxclass') ) {
337 push @tax_where, "$part_pkg.taxclass IN (" .
338 join(', ', map {dbh->quote($_)} $cgi->param('taxclass') ).
342 if ( $cgi->param('exempt_cust') eq 'Y' ) {
343 # tax-exempt customers
344 push @exempt_where, "(exempt_cust = 'Y' OR exempt_cust_taxname = 'Y')";
346 } elsif ( $cgi->param('exempt_pkg') eq 'Y' ) { # non-taxable package
347 # non-taxable package charges
348 push @exempt_where, "(exempt_setup = 'Y' OR exempt_recur = 'Y')";
350 # we don't handle exempt_monthly here
352 if ( $cgi->param('taxname') ) { # specific taxname
353 push @tax_where, 'cust_main_county.taxname = '.
354 dbh->quote($cgi->param('taxname'));
355 } elsif ( $cgi->param('taxnameNULL') ) {
356 push @tax_where, 'cust_main_county.taxname IS NULL OR '.
357 'cust_main_county.taxname = \'Tax\'';
360 # country:state:county:city:district (may be repeated)
361 # You can also pass a big list of taxnums but that leads to huge URLs.
362 # Note that this means "packages whose tax is in this region", not
363 # "packages in this region". It's meant for links from the tax report.
364 if ( $cgi->param('region') ) {
366 foreach ( $cgi->param('region') ) {
368 @loc{qw(country state county city district)} =
369 split(':', $cgi->param('region'));
370 my $string = join(' AND ',
373 "$_ = ".dbh->quote($loc{$_});
379 push @orwhere, "($string)";
381 push @tax_where, '(' . join(' OR ', @orwhere) . ')' if @orwhere;
385 if ( $cgi->param('taxnum') ) {
386 my $taxnum_in = join(',',
387 grep /^\d+$/, $cgi->param('taxnum')
389 push @tax_where, "cust_main_county.taxnum IN ($taxnum_in)"
393 # If we're showing exempt items, we need to find those with
394 # cust_tax_exempt_pkg records matching the selected taxes.
395 # If we're showing taxable items, we need to find those with
396 # cust_bill_pkg_tax_location records. We also need to find the
397 # exemption records so that we can show the taxable amount.
398 # If we're showing all items, we need the union of those.
399 # If we're showing 'out' (items that aren't region/class taxable),
400 # then we need the set of all items minus the union of those.
404 if ( @exempt_where or @tax_where
405 or $cgi->param('taxable') or $cgi->param('out') )
407 # process exemption restrictions, including @tax_where
408 my $exempt_sub = 'SELECT SUM(amount) as exempt_amount, billpkgnum
409 FROM cust_tax_exempt_pkg JOIN cust_main_county USING (taxnum)';
411 $exempt_sub .= ' WHERE '.join(' AND ', @tax_where, @exempt_where)
412 if (@tax_where or @exempt_where);
414 $exempt_sub .= ' GROUP BY billpkgnum';
416 $join_pkg .= " LEFT JOIN ($exempt_sub) AS item_exempt
420 if ( @tax_where or $cgi->param('taxable') or $cgi->param('out') ) {
421 # process tax restrictions
423 'cust_main_county.tax > 0';
425 my $tax_sub = "SELECT invnum, cust_bill_pkg_tax_location.pkgnum
426 FROM cust_bill_pkg_tax_location
427 JOIN cust_bill_pkg AS tax_item USING (billpkgnum)
428 JOIN cust_main_county USING (taxnum)
429 WHERE ". join(' AND ', @tax_where).
430 " GROUP BY invnum, cust_bill_pkg_tax_location.pkgnum";
432 $join_pkg .= " LEFT JOIN ($tax_sub) AS item_tax
433 ON (item_tax.invnum = cust_bill_pkg.invnum AND
434 item_tax.pkgnum = cust_bill_pkg.pkgnum)";
437 # now do something with that
438 if ( @exempt_where ) {
440 push @where, 'item_exempt.billpkgnum IS NOT NULL';
441 push @select, 'item_exempt.exempt_amount';
442 push @peritem, 'exempt_amount';
443 push @peritem_desc, 'Exempt';
444 push @total, 'SUM(exempt_amount)';
445 push @total_desc, "$money_char%.2f tax-exempt";
447 } elsif ( $cgi->param('taxable') ) {
449 my $taxable = 'cust_bill_pkg.setup + cust_bill_pkg.recur '.
450 '- COALESCE(item_exempt.exempt_amount, 0)';
452 push @where, 'item_tax.invnum IS NOT NULL';
453 push @select, "($taxable) AS taxable_amount";
454 push @peritem, 'taxable_amount';
455 push @peritem_desc, 'Taxable';
456 push @total, "SUM($taxable)";
457 push @total_desc, "$money_char%.2f taxable";
459 } elsif ( $cgi->param('out') ) {
461 push @where, 'item_tax.invnum IS NULL',
462 'item_exempt.billpkgnum IS NULL';
464 } elsif ( @tax_where ) {
466 # union of taxable + all exempt_ cases
468 '(item_tax.invnum IS NOT NULL OR item_exempt.billpkgnum IS NOT NULL)';
472 # recur/usage separation
473 if ( $cgi->param('usage') eq 'recurring' ) {
475 my $recur_no_usage = FS::cust_bill_pkg->charged_sql('', '', no_usage => 1);
476 push @select, "($recur_no_usage) AS recur_no_usage";
477 $peritem[1] = 'recur_no_usage';
478 $total[1] = "SUM(cust_bill_pkg.setup + $recur_no_usage)";
479 $total_desc[0] .= ' (excluding usage)';
481 } elsif ( $cgi->param('usage') eq 'usage' ) {
483 my $usage = FS::cust_bill_pkg->usage_sql();
484 push @select, "($usage) AS _usage";
485 # there's already a method named 'usage'
486 $peritem[1] = '_usage';
487 $peritem_desc[1] = 'Usage charge';
488 $total[1] = "SUM($usage)";
489 $total_desc[0] .= ' usage charges';
492 } elsif ( $cgi->param('istax') ) {
494 @peritem = ( 'setup' ); # taxes only have setup
495 @peritem_desc = ( 'Tax charge' );
497 push @where, 'cust_bill_pkg.pkgnum = 0';
499 # tax location when using tax_rate_location
500 if ( scalar( grep( /locationtaxid/, $cgi->param ) ) ) {
502 $join_pkg .= ' LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum ) '.
503 ' LEFT JOIN tax_rate_location USING ( taxratelocationnum )';
504 push @where, FS::tax_rate_location->location_sql(
505 map { $_ => (scalar($cgi->param($_)) || '') }
506 qw( district city county state locationtaxid )
510 COALESCE(cust_bill_pkg_tax_rate_location.amount,
511 cust_bill_pkg.setup + cust_bill_pkg.recur)
514 } elsif ( $cgi->param('out') ) {
517 LEFT JOIN cust_bill_pkg_tax_location USING (billpkgnum)
519 push @where, 'cust_bill_pkg_tax_location.billpkgnum IS NULL';
521 # each billpkgnum should appear only once
522 $total[0] = 'COUNT(*)';
523 $total[1] = 'SUM(cust_bill_pkg.setup)';
525 } else { # not locationtaxid or 'out'--the normal case
528 LEFT JOIN cust_bill_pkg_tax_location USING (billpkgnum)
529 JOIN cust_main_county USING (taxnum)
532 # don't double-count the components of consolidated taxes
533 $total[0] = 'COUNT(DISTINCT cust_bill_pkg.billpkgnum)';
534 $total[1] = 'SUM(cust_bill_pkg_tax_location.amount)';
538 if ( $cgi->param('taxclassNULL') ) {
539 push @where, 'cust_main_county.taxclass IS NULL';
543 if ( $cgi->param('taxnameNULL') ) {
544 push @where, 'cust_main_county.taxname IS NULL OR '.
545 'cust_main_county.taxname = \'Tax\'';
546 } elsif ( $cgi->param('taxname') ) {
547 push @where, 'cust_main_county.taxname = '.
548 dbh->quote($cgi->param('taxname'));
552 if ( $cgi->param('taxnum') ) {
553 my $taxnum_in = join(',',
554 grep /^\d+$/, $cgi->param('taxnum')
556 push @where, "cust_main_county.taxnum IN ($taxnum_in)"
560 # report group (itemdesc)
561 if ( $cgi->param('report_group') =~ /^(=|!=) (.*)$/ ) {
562 my ( $group_op, $group_value ) = ( $1, $2 );
563 if ( $group_op eq '=' ) {
564 #push @where, 'itemdesc LIKE '. dbh->quote($group_value.'%');
565 push @where, 'itemdesc = '. dbh->quote($group_value);
566 } elsif ( $group_op eq '!=' ) {
567 push @where, '( itemdesc != '. dbh->quote($group_value) .' OR itemdesc IS NULL )';
569 die "guru meditation #00de: group_op $group_op\n";
573 # itemdesc, for some reason
574 if ( $cgi->param('itemdesc') ) {
575 if ( $cgi->param('itemdesc') eq 'Tax' ) {
576 push @where, "(itemdesc='Tax' OR itemdesc is null)";
578 push @where, 'itemdesc='. dbh->quote($cgi->param('itemdesc'));
586 my $pay_sub = "SELECT SUM(cust_bill_pay_pkg.amount)
587 FROM cust_bill_pay_pkg
588 WHERE cust_bill_pkg.billpkgnum = cust_bill_pay_pkg.billpkgnum
590 push @select, "($pay_sub) AS pay_amount";
594 if ( $cgi->param('credit') ) {
598 if ( $cgi->param('istax') ) {
599 # then we need to group/join by billpkgtaxlocationnum, to get only the
600 # relevant part of partial taxes
601 my $credit_sub = "SELECT SUM(cust_credit_bill_pkg.amount) AS credit_amount,
602 reason.reason as reason_text, access_user.username AS username_text,
603 billpkgtaxlocationnum, billpkgnum
604 FROM cust_credit_bill_pkg
605 JOIN cust_credit_bill USING (creditbillnum)
606 JOIN cust_credit USING (crednum)
607 LEFT JOIN reason USING (reasonnum)
608 LEFT JOIN access_user USING (usernum)
609 GROUP BY billpkgnum, billpkgtaxlocationnum, reason.reason,
610 access_user.username";
612 if ( $cgi->param('out') ) {
614 # find credits that are applied to the line items, but not to
615 # a cust_bill_pkg_tax_location link
616 $join_pkg .= " LEFT JOIN ($credit_sub) AS item_credit
618 push @where, 'item_credit.billpkgtaxlocationnum IS NULL';
622 # find credits that are applied to the CBPTL links that are
623 # considered "interesting" by the report criteria
624 $join_pkg .= " LEFT JOIN ($credit_sub) AS item_credit
625 USING (billpkgtaxlocationnum)";
630 # then only group by billpkgnum
631 my $credit_sub = "SELECT SUM(cust_credit_bill_pkg.amount) AS credit_amount,
632 reason.reason as reason_text, access_user.username AS username_text,
634 FROM cust_credit_bill_pkg
635 JOIN cust_credit_bill USING (creditbillnum)
636 JOIN cust_credit USING (crednum)
637 LEFT JOIN reason USING (reasonnum)
638 LEFT JOIN access_user USING (usernum)
639 GROUP BY billpkgnum, reason.reason, access_user.username";
640 $join_pkg .= " LEFT JOIN ($credit_sub) AS item_credit USING (billpkgnum)";
643 push @where, 'item_credit.billpkgnum IS NOT NULL';
644 push @select, 'item_credit.credit_amount',
645 'item_credit.username_text',
646 'item_credit.reason_text';
647 push @peritem, 'credit_amount', 'username_text', 'reason_text';
648 push @peritem_desc, 'Credited', 'By', 'Reason';
649 push @total, 'SUM(credit_amount)';
650 push @total_desc, "$money_char%.2f credited";
654 #still want a credit total column
657 SELECT SUM(cust_credit_bill_pkg.amount)
658 FROM cust_credit_bill_pkg
659 WHERE cust_bill_pkg.billpkgnum = cust_credit_bill_pkg.billpkgnum
661 push @select, "($credit_sub) AS credit_amount";
665 push @select, 'cust_main.custnum', FS::UI::Web::cust_sql_fields();
668 if ( $cgi->param('salesnum') =~ /^(\d+)$/ ) {
671 my $sales = FS::sales->by_key($salesnum)
672 or die "salesnum $salesnum not found";
674 my $subsearch = $sales->cust_bill_pkg_search('', '',
675 'cust_main_sales' => ($cgi->param('cust_main_sales') ? 1 : 0),
676 'paid' => ($cgi->param('paid') ? 1 : 0),
677 'classnum' => scalar($cgi->param('classnum'))
679 $join_pkg .= " JOIN sales_pkg_class ON ( COALESCE(sales_pkg_class.classnum, 0) = COALESCE( part_pkg.classnum, 0) )";
681 my $extra_sql = $subsearch->{extra_sql};
682 $extra_sql =~ s/^WHERE//;
683 push @where, $extra_sql;
685 $cgi->param('classnum', 0) unless $cgi->param('classnum');
689 my $where = join(' AND ', @where);
690 $where &&= "WHERE $where";
693 'table' => 'cust_bill_pkg',
694 'addl_from' => "$join_pkg $join_cust",
696 'select' => join(",\n", @select ),
697 'extra_sql' => $where,
698 'order_by' => 'ORDER BY cust_bill._date, cust_bill_pkg.billpkgnum',
702 'SELECT ' . join(',', @total) .
703 " FROM cust_bill_pkg $join_pkg $join_cust
706 @peritem_desc = map {emt($_)} @peritem_desc;
707 my @peritem_sub = map {
709 if ($field =~ /_text$/) { # kludge for credit reason/username fields
710 sub {$_[0]->get($field)};
712 sub { sprintf($money_char.'%.2f', $_[0]->get($field)) }
715 my @peritem_null = map { '' } @peritem; # placeholders
716 my $peritem_align = 'r' x scalar(@peritem);
718 my $ilink = [ "${p}view/cust_bill.cgi?", 'invnum' ];
719 my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ];
721 my $pay_link = ''; #[, 'billpkgnum', ];
722 my $credit_link = [ "${p}search/cust_credit_bill_pkg.html?billpkgnum=", 'billpkgnum', ];
724 warn "\n\nQUERY:\n".Dumper($query)."\n\nCOUNT_QUERY:\n$count_query\n\n"
725 if $cgi->param('debug');