RT# 32917 - updated tax report to show pre discount estimated tax
[freeside.git] / FS / FS / Report / Tax / ByName.pm
1 package FS::Report::Tax::ByName;
2
3 use strict;
4 use vars qw($DEBUG);
5 use FS::Record qw(dbh qsearch qsearchs group_concat_sql);
6 use Date::Format qw( time2str );
7
8 use Data::Dumper;
9
10 $DEBUG = 0;
11
12 =item report OPTIONS
13
14 Constructor.  Generates a tax report using the internal tax rate system 
15 (L<FS::cust_main_county>), showing all taxes with a specified tax name,
16 broken down by state/county. Optionally, the taxes can be broken down further
17 by city/district, tax class, or package class.
18
19 Required parameters:
20
21 - beginning, ending: the date range as Unix timestamps.
22 - taxname: the name of the tax (corresponds to C<cust_bill_pkg.itemdesc>).
23 - country: the country code.
24
25 Optional parameters:
26 - agentnum: limit to this agentnum.num.
27 - breakdown: hashref of the fields to group by.  Keys can be 'city',
28 'district', 'pkgclass', or 'taxclass'; values should be true.
29 - total_only: don't run the tax group queries, only the totals queries.
30 Returns one row, except in the unlikely event you're using breakdown by
31 package class.
32 - debug: sets the debug level.  1 will warn the data collected for the report;
33 2 will also warn all of the SQL statements.
34
35 =cut
36
37 sub report {
38   my $class = shift;
39   my %opt = @_;
40
41   $DEBUG ||= $opt{debug};
42
43   my($beginning, $ending) = @opt{'beginning', 'ending'};
44
45   my ($taxname, $country, %breakdown);
46
47   # taxname can contain arbitrary punctuation; escape it properly and 
48   # include $taxname unquoted elsewhere
49   $taxname = dbh->quote($opt{'taxname'});
50
51   if ( $opt{country} =~ /^(\w\w)$/ ) {
52     $country = $1;
53   } else {
54     die "country required";
55   }
56
57   # %breakdown: short name => field identifier
58   # null classnum should remain null, not be converted to zero
59   %breakdown = (
60     'taxclass'  => 'cust_main_county.taxclass',
61     'pkgclass'  => 'COALESCE(part_fee.classnum,part_pkg.classnum)',
62     'city'      => 'cust_main_county.city',
63     'district'  => 'cust_main_county.district',
64     'state'     => 'cust_main_county.state',
65     'county'    => 'cust_main_county.county',
66   );
67   foreach (qw(taxclass pkgclass city district)) {
68     delete $breakdown{$_} unless $opt{breakdown}->{$_};
69   }
70
71   my $join_cust =     '      JOIN cust_bill     USING ( invnum  )
72                         LEFT JOIN cust_main     USING ( custnum ) ';
73
74   my $join_cust_pkg = $join_cust.
75                       ' LEFT JOIN cust_pkg      USING ( pkgnum  )
76                         LEFT JOIN part_pkg      USING ( pkgpart )
77                         LEFT JOIN part_fee      USING ( feepart ) ';
78
79   my $from_join_cust_pkg = " FROM cust_bill_pkg $join_cust_pkg "; 
80
81   # all queries MUST be linked to both cust_bill and cust_main_county
82
83   # Either or both of these can be used to link cust_bill_pkg to 
84   # cust_main_county. This one links a taxed line item (billpkgnum) to a tax rate
85   # (taxnum), and gives the amount of tax charged on that line item under that
86   # rate (as tax_amount).
87   my $pkg_tax = "SELECT SUM(amount) as tax_amount, taxnum, ".
88     "taxable_billpkgnum AS billpkgnum ".
89     "FROM cust_bill_pkg_tax_location JOIN cust_bill_pkg USING (billpkgnum) ".
90     "GROUP BY taxable_billpkgnum, taxnum";
91
92   # This one links a tax-exempted line item (billpkgnum) to a tax rate
93   # (taxnum), and gives the amount of the tax exemption.  EXEMPT_WHERE must 
94   # be replaced with an expression to further limit the tax exemptions
95   # that will be included, or "TRUE" to not limit them.
96   #
97   # Note that tax exemptions with non-null creditbillpkgnum are always
98   # excluded. Those are "negative exemptions" created by crediting a sale 
99   # that had received an exemption.
100   my $pkg_tax_exempt = "SELECT SUM(amount) AS exempt_charged, billpkgnum, taxnum ".
101     "FROM cust_tax_exempt_pkg WHERE
102       ( EXEMPT_WHERE )
103       AND cust_tax_exempt_pkg.creditbillpkgnum IS NULL
104      GROUP BY billpkgnum, taxnum";
105
106   my $where = "WHERE cust_bill._date >= $beginning AND cust_bill._date <= $ending ".
107               "AND COALESCE(cust_main_county.taxname,'Tax') = $taxname ".
108               "AND cust_main_county.country = '$country'";
109   # SELECT/GROUP clauses for first-level queries
110   my $select = "SELECT ";
111   my $group = "GROUP BY ";
112   foreach (qw(pkgclass taxclass state county city district)) {
113     if ( $breakdown{$_} ) {
114       $select .= "$breakdown{$_} AS $_, ";
115       $group  .= "$breakdown{$_}, ";
116     } else {
117       $select .= "NULL AS $_, ";
118     }
119   }
120   $select .= group_concat_sql('DISTINCT(cust_main_county.taxnum)', ',') .
121              ' AS taxnums, ';
122   $group =~ s/, $//;
123
124   # SELECT/GROUP clauses for second-level (totals) queries
125   # breakdown by package class only, if anything
126   my $select_all = "SELECT NULL AS pkgclass, ";
127   my $group_all = "";
128   if ( $breakdown{pkgclass} ) {
129     $select_all = "SELECT $breakdown{pkgclass} AS pkgclass, ";
130     $group_all = "GROUP BY $breakdown{pkgclass}";
131   }
132   $select_all .= group_concat_sql('DISTINCT(cust_main_county.taxnum)', ',') .
133                  ' AS taxnums, ';
134
135   my $agentnum;
136   if ( $opt{agentnum} and $opt{agentnum} =~ /^(\d+)$/ ) {
137     $agentnum = $1;
138     my $agent = qsearchs('agent', { 'agentnum' => $agentnum } );
139     die "agent not found" unless $agent;
140     $where .= " AND cust_main.agentnum = $agentnum";
141   }
142
143   my $nottax = 
144     '(cust_bill_pkg.pkgnum != 0 OR cust_bill_pkg.feepart IS NOT NULL)';
145
146   # one query for each column of the report
147   # plus separate queries for the totals row
148   my (%sql, %all_sql);
149
150   # SALES QUERIES (taxable sales, all types of exempt sales)
151   # -------------
152
153   # general form
154   my $exempt = "$select SUM(exempt_charged)
155     FROM cust_main_county
156     JOIN ($pkg_tax_exempt) AS pkg_tax_exempt
157     USING (taxnum)
158     JOIN cust_bill_pkg USING (billpkgnum)
159     $join_cust_pkg $where AND $nottax
160     $group";
161
162   my $all_exempt = "$select_all SUM(exempt_charged)
163     FROM cust_main_county
164     JOIN ($pkg_tax_exempt) AS pkg_tax_exempt
165     USING (taxnum)
166     JOIN cust_bill_pkg USING (billpkgnum)
167     $join_cust_pkg $where AND $nottax
168     $group_all";
169
170   # sales to tax-exempt customers
171   $sql{exempt_cust} = $exempt;
172   $sql{exempt_cust} =~ s/EXEMPT_WHERE/exempt_cust = 'Y' OR exempt_cust_taxname = 'Y'/;
173   $all_sql{exempt_cust} = $all_exempt;
174   $all_sql{exempt_cust} =~ s/EXEMPT_WHERE/exempt_cust = 'Y' OR exempt_cust_taxname = 'Y'/;
175
176   # sales of tax-exempt packages
177   $sql{exempt_pkg} = $exempt;
178   $sql{exempt_pkg} =~ s/EXEMPT_WHERE/exempt_setup = 'Y' OR exempt_recur = 'Y'/;
179   $all_sql{exempt_pkg} = $all_exempt;
180   $all_sql{exempt_pkg} =~ s/EXEMPT_WHERE/exempt_setup = 'Y' OR exempt_recur = 'Y'/;
181
182   # monthly per-customer exemptions
183   $sql{exempt_monthly} = $exempt;
184   $sql{exempt_monthly} =~ s/EXEMPT_WHERE/exempt_monthly = 'Y'/;
185   $all_sql{exempt_monthly} = $all_exempt;
186   $all_sql{exempt_monthly} =~ s/EXEMPT_WHERE/exempt_monthly = 'Y'/;
187
188   # credits applied to taxable sales
189   # Note that negative exemptions (from exempt sales being credited) are NOT
190   # counted when calculating the exempt amount. (See above.) Therefore we need
191   # to NOT include any credits against exempt sales in this amount, either.
192   # These two subqueries implement that. They have joins to cust_credit_bill
193   # and cust_bill so that credits can be filtered by application date if
194   # requested.
195
196   # Each row here is the sum of credits applied to a line item.
197   my $sales_credit =
198     "SELECT billpkgnum, SUM(cust_credit_bill_pkg.amount) AS credited
199     FROM cust_credit_bill_pkg
200     JOIN cust_credit_bill USING (creditbillnum)
201     JOIN cust_bill USING (invnum)
202     WHERE cust_bill._date >= $beginning AND cust_bill._date <= $ending
203     GROUP BY billpkgnum
204     ";
205
206   # Each row here is the sum of negative exemptions applied to a combination
207   # of line item and tax definition.
208   my $exempt_credit =
209     "SELECT cust_credit_bill_pkg.billpkgnum, taxnum,
210       0 - SUM(cust_tax_exempt_pkg.amount) AS exempt_credited
211     FROM cust_credit_bill_pkg
212     LEFT JOIN cust_tax_exempt_pkg USING (creditbillpkgnum)
213     JOIN cust_credit_bill USING (creditbillnum)
214     JOIN cust_bill USING (invnum)
215     WHERE cust_bill._date >= $beginning AND cust_bill._date <= $ending
216     GROUP BY cust_credit_bill_pkg.billpkgnum, taxnum
217     ";
218
219   # Each row here is the sum of the discounts where taxes were applied pre discount
220   my $discount = "SELECT SUM(cust_bill_pkg_discount.amount) AS discount_amount, cust_bill_pkg_discount.billpkgnum
221                   FROM cust_bill_pkg_discount
222                   LEFT JOIN cust_bill_pkg_tax_location ON (cust_bill_pkg_tax_location.taxable_billpkgnum = cust_bill_pkg_discount.billpkgnum)
223                   LEFT JOIN cust_main_county USING (taxnum)
224                   WHERE cust_main_county.charge_prediscount = 'Y'
225                   GROUP BY cust_bill_pkg_discount.billpkgnum";
226   
227   if ( $opt{credit_date} eq 'cust_credit_bill' ) {
228     $sales_credit =~ s/cust_bill._date/cust_credit_bill._date/g;
229     $exempt_credit =~ s/cust_bill._date/cust_credit_bill._date/g;
230   }
231
232   $sql{sales_credited} = "$select
233     SUM(COALESCE(credited, 0) - COALESCE(exempt_credited, 0))
234     FROM cust_main_county
235     JOIN ($pkg_tax) AS pkg_tax USING (taxnum)
236     JOIN cust_bill_pkg USING (billpkgnum)
237     LEFT JOIN ($sales_credit) AS sales_credit USING (billpkgnum)
238     LEFT JOIN ($exempt_credit) AS exempt_credit USING (billpkgnum, taxnum)
239     $join_cust_pkg $where AND $nottax
240     $group
241     ";
242
243   $all_sql{sales_credited} = "$select_all
244     SUM(COALESCE(credited, 0) - COALESCE(exempt_credited, 0))
245     FROM cust_main_county
246     JOIN ($pkg_tax) AS pkg_tax USING (taxnum)
247     JOIN cust_bill_pkg USING (billpkgnum)
248     LEFT JOIN ($sales_credit) AS sales_credit USING (billpkgnum)
249     LEFT JOIN ($exempt_credit) AS exempt_credit USING (billpkgnum, taxnum)
250     $join_cust_pkg $where AND $nottax
251     $group_all
252     ";
253
254   # also include the exempt-sales credit amount, for the credit report
255   $sql{exempt_credited} = "$select
256     SUM(COALESCE(exempt_credited, 0))
257     FROM cust_main_county
258     LEFT JOIN ($exempt_credit) AS exempt_credit USING (taxnum)
259     JOIN cust_bill_pkg USING (billpkgnum)
260     $join_cust_pkg $where AND $nottax
261     $group
262     ";
263
264   $all_sql{exempt_credited} = "$select_all
265     SUM(COALESCE(exempt_credited, 0))
266     FROM cust_main_county
267     LEFT JOIN ($exempt_credit) AS exempt_credit USING (taxnum)
268     JOIN cust_bill_pkg USING (billpkgnum)
269     $join_cust_pkg $where AND $nottax
270     $group_all
271     ";
272
273   # taxable sales
274   $sql{taxable} = "$select
275     SUM(cust_bill_pkg.setup + cust_bill_pkg.recur
276       - COALESCE(exempt_charged, 0)
277       - COALESCE(credited, 0)
278       + COALESCE(exempt_credited, 0)
279       + COALESCE(discount_amount, 0)
280     )
281     FROM cust_main_county
282     JOIN ($pkg_tax) AS pkg_tax USING (taxnum)
283     JOIN cust_bill_pkg USING (billpkgnum)
284     LEFT JOIN ($pkg_tax_exempt) AS pkg_tax_exempt USING (billpkgnum, taxnum)
285     LEFT JOIN ($sales_credit) AS sales_credit USING (billpkgnum)
286     LEFT JOIN ($exempt_credit) AS exempt_credit USING (billpkgnum, taxnum)
287     LEFT JOIN ($discount) AS pkg_discount USING (billpkgnum)
288     $join_cust_pkg $where AND $nottax 
289     $group";
290
291   $all_sql{taxable} = "$select_all
292     SUM(cust_bill_pkg.setup + cust_bill_pkg.recur
293       - COALESCE(exempt_charged, 0)
294       - COALESCE(credited, 0)
295       + COALESCE(exempt_credited, 0)
296       + COALESCE(discount_amount, 0)
297     )
298     FROM cust_main_county
299     JOIN ($pkg_tax) AS pkg_tax USING (taxnum)
300     JOIN cust_bill_pkg USING (billpkgnum)
301     LEFT JOIN ($pkg_tax_exempt) AS pkg_tax_exempt USING (billpkgnum, taxnum)
302     LEFT JOIN ($sales_credit) AS sales_credit USING (billpkgnum)
303     LEFT JOIN ($exempt_credit) AS exempt_credit USING (billpkgnum, taxnum)
304     LEFT JOIN ($discount) AS pkg_discount USING (billpkgnum)
305     $join_cust_pkg $where AND $nottax 
306     $group_all";
307
308   $sql{taxable} =~ s/EXEMPT_WHERE/TRUE/; # unrestricted
309   $all_sql{taxable} =~ s/EXEMPT_WHERE/TRUE/;
310
311   # estimated tax (taxable * rate)
312   $sql{estimated} = "$select
313     SUM(cust_main_county.tax / 100 * 
314       (cust_bill_pkg.setup + cust_bill_pkg.recur
315       - COALESCE(exempt_charged, 0)
316       - COALESCE(credited, 0)
317       + COALESCE(exempt_credited, 0)
318       + COALESCE(discount_amount, 0)
319       )
320     )
321     FROM cust_main_county
322     JOIN ($pkg_tax) AS pkg_tax USING (taxnum)
323     JOIN cust_bill_pkg USING (billpkgnum)
324     LEFT JOIN ($pkg_tax_exempt) AS pkg_tax_exempt USING (billpkgnum, taxnum)
325     LEFT JOIN ($sales_credit) AS sales_credit USING (billpkgnum)
326     LEFT JOIN ($exempt_credit) AS exempt_credit USING (billpkgnum, taxnum)
327     LEFT JOIN ($discount) AS pkg_discount USING (billpkgnum)
328     $join_cust_pkg $where AND $nottax 
329     $group";
330
331   $all_sql{estimated} = "$select_all
332     SUM(cust_main_county.tax / 100 * 
333       (cust_bill_pkg.setup + cust_bill_pkg.recur
334       - COALESCE(exempt_charged, 0)
335       - COALESCE(credited, 0)
336       + COALESCE(exempt_credited, 0)
337       + COALESCE(discount_amount, 0)
338       )
339     )
340     FROM cust_main_county
341     JOIN ($pkg_tax) AS pkg_tax USING (taxnum)
342     JOIN cust_bill_pkg USING (billpkgnum)
343     LEFT JOIN ($pkg_tax_exempt) AS pkg_tax_exempt USING (billpkgnum, taxnum)
344     LEFT JOIN ($sales_credit) AS sales_credit USING (billpkgnum)
345     LEFT JOIN ($exempt_credit) AS exempt_credit USING (billpkgnum, taxnum)
346     LEFT JOIN ($discount) AS pkg_discount USING (billpkgnum)
347     $join_cust_pkg $where AND $nottax 
348     $group_all";
349
350   $sql{estimated} =~ s/EXEMPT_WHERE/TRUE/; # unrestricted
351   $all_sql{estimated} =~ s/EXEMPT_WHERE/TRUE/;
352
353   # there isn't one for 'sales', because we calculate sales by adding up 
354   # the taxable and exempt columns.
355   
356   # TAX QUERIES (billed tax, credited tax, collected tax)
357   # -----------
358
359   # sum of billed tax:
360   # join cust_bill_pkg to cust_main_county via cust_bill_pkg_tax_location
361   my $taxfrom = " FROM cust_bill_pkg 
362                   $join_cust 
363                   LEFT JOIN cust_bill_pkg_tax_location USING ( billpkgnum )
364                   LEFT JOIN cust_main_county USING ( taxnum )";
365
366   if ( $breakdown{pkgclass} ) {
367     # If we're not grouping by package class, this is unnecessary, and
368     # probably really expensive.
369     # Remember that fees also have package classes.
370     $taxfrom .= "
371                   LEFT JOIN cust_bill_pkg AS taxable
372                     ON (cust_bill_pkg_tax_location.taxable_billpkgnum = taxable.billpkgnum)
373                   LEFT JOIN cust_pkg ON (taxable.pkgnum = cust_pkg.pkgnum)
374                   LEFT JOIN part_pkg USING (pkgpart)
375                   LEFT JOIN part_fee ON (taxable.feepart = part_fee.feepart) ";
376   }
377
378   my $istax = "cust_bill_pkg.pkgnum = 0 and cust_bill_pkg.feepart is null";
379
380   $sql{tax} = "$select COALESCE(SUM(cust_bill_pkg_tax_location.amount),0)
381                $taxfrom
382                $where AND $istax
383                $group";
384
385   $all_sql{tax} = "$select_all COALESCE(SUM(cust_bill_pkg_tax_location.amount),0)
386                $taxfrom
387                $where AND $istax
388                $group_all";
389
390   # sum of credits applied against billed tax
391   # ($creditfrom includes join of taxable item to part_pkg/part_fee if 
392   # with_pkgclass is on)
393   my $creditfrom = $taxfrom .
394     ' JOIN cust_credit_bill_pkg USING (billpkgtaxlocationnum)' .
395     ' JOIN cust_credit_bill     USING (creditbillnum)';
396   my $creditwhere = $where . 
397     ' AND billpkgtaxratelocationnum IS NULL';
398
399   # if the credit_date option is set to application date, change
400   # $creditwhere accordingly
401   if ( $opt{credit_date} eq 'cust_credit_bill' ) {
402     $creditwhere     =~ s/cust_bill._date/cust_credit_bill._date/g;
403   }
404
405   $sql{tax_credited} = "$select COALESCE(SUM(cust_credit_bill_pkg.amount),0)
406                   $creditfrom
407                   $creditwhere AND $istax
408                   $group";
409
410   $all_sql{tax_credited} = "$select_all COALESCE(SUM(cust_credit_bill_pkg.amount),0)
411                   $creditfrom
412                   $creditwhere AND $istax
413                   $group_all";
414
415   # sum of tax paid
416   # this suffers from the same ambiguity as anything else that applies 
417   # received payments to specific packages, but in reality the discrepancy
418   # should be minimal since people either pay their bill or don't.
419   # the join is on billpkgtaxlocationnum to avoid cross-producting.
420  
421   my $paidfrom = $taxfrom .
422     ' JOIN cust_bill_pay_pkg'.
423     ' ON (cust_bill_pay_pkg.billpkgtaxlocationnum ='.
424     ' cust_bill_pkg_tax_location.billpkgtaxlocationnum)';
425
426   $sql{tax_paid} = "$select COALESCE(SUM(cust_bill_pay_pkg.amount),0)
427                     $paidfrom
428                     $where AND $istax
429                     $group";
430
431   $all_sql{tax_paid} = "$select_all COALESCE(SUM(cust_bill_pay_pkg.amount),0)
432                     $paidfrom
433                     $where AND $istax
434                     $group_all";
435
436   my %data;
437   my %total;
438   # note that we use keys(%sql) here and keys(%all_sql) later. nothing
439   # obligates us to use the same set of variables for the total query 
440   # as for the individual category queries
441   foreach my $k (keys(%sql)) {
442     my $stmt = $sql{$k};
443     warn "\n".uc($k).":\n".$stmt."\n" if $DEBUG > 1;
444     my $sth = dbh->prepare($stmt);
445     # eight columns: pkgclass, taxclass, state, county, city, district
446     # taxnums (comma separated), value
447     $sth->execute 
448       or die "failed to execute $k query: ".$sth->errstr;
449     while ( my $row = $sth->fetchrow_arrayref ) {
450       my $bin = $data
451                 {$row->[0]} # pkgclass
452                 {$row->[1]  # taxclass
453                   || ($breakdown{taxclass} ? 'Unclassified' : '')}
454                 {$row->[2]} # state
455                 {$row->[3] ? $row->[3] . ' County' : ''} # county
456                 {$row->[4]} # city
457                 {$row->[5]} # district
458               ||= [];
459       push @$bin, [ $k, $row->[6], $row->[7] ];
460     }
461   }
462   warn "DATA:\n".Dumper(\%data) if $DEBUG;
463
464   foreach my $k (keys %all_sql) {
465     warn "\nTOTAL ".uc($k).":\n".$all_sql{$k}."\n" if $DEBUG;
466     my $sth = dbh->prepare($all_sql{$k});
467     # three columns: pkgclass, taxnums (comma separated), value
468     $sth->execute 
469       or die "failed to execute $k totals query: ".$sth->errstr;
470     while ( my $row = $sth->fetchrow_arrayref ) {
471       my $bin = $total{$row->[0]} ||= [];
472       push @$bin, [ $k, $row->[1], $row->[2] ];
473     }
474   }
475   warn "TOTALS:\n".Dumper(\%total) if $DEBUG > 1;
476
477   # $data{$pkgclass}{$taxclass}{$state}{$county}{$city}{$district} = [
478   #   [ 'taxable',     taxnums, amount ],
479   #   [ 'exempt_cust', taxnums, amount ],
480   #   ...
481   # ]
482   # non-requested grouping levels simply collapse into key = ''
483
484   # the much-maligned "out of taxable region"...
485   # find sales that are not linked to any tax with this name
486   # but are still inside the date range/agent criteria.
487   #
488   # This doesn't use $select_all/$group_all because we want a single number,
489   # not a breakdown by pkgclass. Unless someone needs that eventually, 
490   # in which case we'll turn it into an %all_sql query.
491   
492   my $outside_where =
493     "WHERE cust_bill._date >= $beginning AND cust_bill._date <= $ending";
494   if ( $agentnum ) {
495     $outside_where .= " AND cust_main.agentnum = $agentnum";
496   }
497   $outside_where .= "
498     AND NOT EXISTS(
499       SELECT 1 FROM cust_tax_exempt_pkg
500         JOIN cust_main_county USING (taxnum)
501         WHERE cust_tax_exempt_pkg.billpkgnum = cust_bill_pkg.billpkgnum
502           AND COALESCE(cust_main_county.taxname,'Tax') = $taxname
503           AND cust_tax_exempt_pkg.creditbillpkgnum IS NULL
504     )
505     AND NOT EXISTS(
506       SELECT 1 FROM cust_bill_pkg_tax_location
507         JOIN cust_main_county USING (taxnum)
508         WHERE cust_bill_pkg_tax_location.taxable_billpkgnum = cust_bill_pkg.billpkgnum
509           AND COALESCE(cust_main_county.taxname,'Tax') = $taxname
510     )";
511   my $sql_outside = "SELECT SUM(cust_bill_pkg.setup + cust_bill_pkg.recur)
512     FROM cust_bill_pkg
513     $join_cust_pkg
514     $outside_where
515     AND $nottax
516   ";
517   warn "\nOUT_SALES:\n$sql_outside\n" if $DEBUG;
518   my $out_sales = FS::Record->scalar_sql($sql_outside);
519
520   # and out-of-region credit applications, also (excluding those applied
521   # to out-of-region sales _or taxes_)
522   if ( $opt{credit_date} eq 'cust_credit_bill' ) {
523     $outside_where     =~ s/cust_bill._date/cust_credit_bill._date/g;
524   }
525
526   $sql_outside = "SELECT SUM(cust_credit_bill_pkg.amount)
527     FROM cust_credit_bill_pkg
528     JOIN cust_bill_pkg USING (billpkgnum)
529     $join_cust_pkg
530     JOIN cust_credit_bill USING (creditbillnum)
531     $outside_where
532     AND NOT EXISTS(
533       SELECT 1 FROM cust_bill_pkg_tax_location
534         JOIN cust_main_county USING (taxnum)
535         WHERE cust_bill_pkg_tax_location.billpkgnum = cust_bill_pkg.billpkgnum
536           AND COALESCE(cust_main_county.taxname,'Tax') = $taxname
537     )
538   ";
539   warn "\nOUT_CREDIT:\n$sql_outside\n" if $DEBUG;
540   my $out_credit = FS::Record->scalar_sql($sql_outside);
541
542   my %taxrates;
543   foreach my $tax (
544     qsearch('cust_main_county', {
545               country => $country,
546               tax => { op => '>', value => 0 }
547             }) )
548     {
549     $taxrates{$tax->taxnum} = $tax->tax;
550   }
551
552   # return the data
553   bless {
554     'opt'         => \%opt,
555     'data'        => \%data,
556     'total'       => \%total,
557     'taxrates'    => \%taxrates,
558     'out_sales'   => $out_sales,
559     'out_credit'  => $out_credit,
560   }, $class;
561 }
562
563 sub opt {
564   my $self = shift;
565   $self->{opt};
566 }
567
568 sub data {
569   my $self = shift;
570   $self->{data};
571 }
572
573 # sub fetchall_array...
574
575 sub table {
576   my $self = shift;
577   my @columns = (qw(pkgclass taxclass state county city district));
578   # taxnums, field headings, and amounts
579   my @rows;
580   my %row_template;
581
582   # de-treeify this thing
583   my $descend;
584   $descend = sub {
585     my ($tree, $level) = @_;
586     if ( ref($tree) eq 'HASH' ) {
587       foreach my $k ( sort {
588            -1*($b eq '')    # sort '' to the end
589           or  ($a eq '')    # sort '' to the end
590           or  ($a <=> $b)   # sort numbers as numbers
591           or  ($a cmp $b)   # sort alphabetics as alphabetics
592         } keys %$tree )
593       {
594         $row_template{ $columns[$level] } = $k;
595         &{ $descend }($tree->{$k}, $level + 1);
596         if ( $level == 0 ) {
597           # then insert the total row for the pkgclass
598           $row_template{'total'} = 1; # flag it as a total
599           &{ $descend }($self->{total}->{$k}, 1);
600           $row_template{'total'} = 0;
601         }
602       }
603     } elsif ( ref($tree) eq 'ARRAY' ) {
604       # then we've reached the bottom; elements of this array are arrayrefs
605       # of [ field, taxnums, amount ].
606       # start with the inherited location-element fields
607       my %this_row = %row_template;
608       my %taxnums;
609       foreach my $x (@$tree) {
610         # accumulate taxnums
611         foreach (split(',', $x->[1])) {
612           $taxnums{$_} = 1;
613         }
614         # and money values
615         $this_row{ $x->[0] } = $x->[2];
616       }
617       # store combined taxnums
618       $this_row{taxnums} = join(',', sort { $a cmp $b } keys %taxnums);
619       # and calculate row totals
620       $this_row{sales} = sprintf('%.2f',
621                           $this_row{taxable} +
622                           $this_row{sales_credited} +
623                           $this_row{exempt_cust} +
624                           $this_row{exempt_pkg} + 
625                           $this_row{exempt_monthly}
626                         );
627       $this_row{credits} = sprintf('%.2f',
628                           $this_row{sales_credited} +
629                           $this_row{exempt_credited} +
630                           $this_row{tax_credited}
631                         );
632       # and give it a label
633       if ( $this_row{total} ) {
634         $this_row{label} = 'Total';
635       } else {
636         $this_row{label} = join(', ', grep $_,
637                             $this_row{taxclass},
638                             $this_row{state},
639                             $this_row{county}, # already has ' County' suffix
640                             $this_row{city},
641                             $this_row{district}
642                            );
643       }
644       # and indicate the tax rate, if any
645       my $rate;
646       foreach (keys %taxnums) {
647         $rate ||= $self->{taxrates}->{$_};
648         if ( $rate != $self->{taxrates}->{$_} ) {
649           $rate = 'variable';
650           last;
651         }
652       }
653       if ( $rate eq 'variable' ) {
654         $this_row{rate} = 'variable';
655       } elsif ( $rate > 0 ) {
656         $this_row{rate} = sprintf('%.2f', $rate);
657       }
658       push @rows, \%this_row;
659     }
660   };
661
662   &{ $descend }($self->{data}, 0);
663
664   warn "TABLE:\n".Dumper(\@rows) if $self->{opt}->{debug};
665   return @rows;
666 }
667
668 sub taxrates {
669   my $self = shift;
670   $self->{taxrates}
671 }
672
673 sub title {
674   my $self = shift;
675   my $string = '';
676   if ( $self->{opt}->{agentnum} ) {
677     my $agent = qsearchs('agent', { agentnum => $self->{opt}->{agentnum} });
678     $string .= $agent->agent . ' ';
679   }
680   $string .= 'Tax Report: '; # XXX localization
681   if ( $self->{opt}->{beginning} ) {
682     $string .= time2str('%h %o %Y ', $self->{opt}->{beginning});
683   }
684   $string .= 'through ';
685   if ( $self->{opt}->{ending} and $self->{opt}->{ending} < 4294967295 ) {
686     $string .= time2str('%h %o %Y', $self->{opt}->{ending});
687   } else {
688     $string .= 'now';
689   }
690   $string .= ' - ' . $self->{opt}->{taxname};
691   return $string;
692 }
693
694 1;