X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=84ef089629a911650e2a6da68c8d8bb34cedb0b1;hp=493bc097bdbd5b65e75ee2a86388320b10c33f41;hb=bc03d12fffd22153b5035bc021450387bacc17b8;hpb=bdf4497fd8d3778e9cc0f8b90dd8a742f3a84158 diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 493bc097b..84ef08962 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,7 +1,8 @@ package FS::cust_bill; use strict; -use vars qw( @ISA $DEBUG $me $conf $money_char ); +use vars qw( @ISA $DEBUG $me $conf + $money_char $date_format $rdate_format $date_format_long ); use vars qw( $invoice_lines @buf ); #yuck use Fcntl qw(:flock); #for spool_csv use List::Util qw(min max); @@ -11,6 +12,8 @@ use File::Temp 0.14; use String::ShellQuote; use HTML::Entities; use Locale::Country; +use Storable qw( freeze thaw ); +use GD::Barcode; use FS::UID qw( datasrc ); use FS::Misc qw( send_email send_fax generate_ps generate_pdf do_print ); use FS::Record qw( qsearch qsearchs dbh ); @@ -19,6 +22,7 @@ use FS::cust_main; use FS::cust_statement; use FS::cust_bill_pkg; use FS::cust_bill_pkg_display; +use FS::cust_bill_pkg_detail; use FS::cust_credit; use FS::cust_pay; use FS::cust_pkg; @@ -32,6 +36,9 @@ use FS::cust_bill_pay; use FS::cust_bill_pay_batch; use FS::part_bill_event; use FS::payby; +use FS::bill_batch; +use FS::cust_bill_batch; +use Cwd; @ISA = qw( FS::cust_main_Mixin FS::Record ); @@ -41,7 +48,10 @@ $me = '[FS::cust_bill]'; #ask FS::UID to run this stuff for us later FS::UID->install_callback( sub { $conf = new FS::Conf; - $money_char = $conf->config('money_char') || '$'; + $money_char = $conf->config('money_char') || '$'; + $date_format = $conf->config('date_format') || '%x'; #/YY + $rdate_format = $conf->config('date_format') || '%m/%d/%Y'; #/YYYY + $date_format_long = $conf->config('date_format_long') || '%b %o, %Y'; } ); =head1 NAME @@ -156,6 +166,45 @@ sub cust_unlinked_msg { Adds this invoice to the database ("Posts" the invoice). If there is an error, returns the error, otherwise returns false. +=cut + +sub insert { + my $self = shift; + warn "$me insert called\n" if $DEBUG; + + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my $error = $self->SUPER::insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + if ( $self->get('cust_bill_pkg') ) { + foreach my $cust_bill_pkg ( @{$self->get('cust_bill_pkg')} ) { + $cust_bill_pkg->invnum($self->invnum); + my $error = $cust_bill_pkg->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "can't create invoice line item: $error"; + } + } + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; + +} + =item delete This method now works but you probably shouldn't use it. Instead, apply a @@ -218,13 +267,13 @@ sub delete { } -=item replace OLD_RECORD +=item replace [ OLD_RECORD ] -Replaces the OLD_RECORD with this one in the database. If there is an error, -returns the error, otherwise returns false. +You can, but probably shouldn't modify invoices... -Only printed may be changed. printed is normally updated by calling the -collect method of a customer object (see L). +Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not +supplied, replaces this record. If there is an error, returns the error, +otherwise returns false. =cut @@ -235,15 +284,44 @@ collect method of a customer object (see L). sub replace_check { my( $new, $old ) = ( shift, shift ); - return "Can't change custnum!" unless $old->custnum == $new->custnum; + return "Can't modify closed invoice" if $old->closed =~ /^Y/i; #return "Can't change _date!" unless $old->_date eq $new->_date; - return "Can't change _date!" unless $old->_date == $new->_date; - return "Can't change charged!" unless $old->charged == $new->charged - || $old->charged == 0; + return "Can't change _date" unless $old->_date == $new->_date; + return "Can't change charged" unless $old->charged == $new->charged + || $old->charged == 0 + || $new->{'Hash'}{'cc_surcharge_replace_hack'}; ''; } + +=item add_cc_surcharge + +Giant hack + +=cut + +sub add_cc_surcharge { + my ($self, $pkgnum, $amount) = (shift, shift, shift); + + my $error; + my $cust_bill_pkg = new FS::cust_bill_pkg({ + 'invnum' => $self->invnum, + 'pkgnum' => $pkgnum, + 'setup' => $amount, + }); + $error = $cust_bill_pkg->insert; + return $error if $error; + + $self->{'Hash'}{'cc_surcharge_replace_hack'} = 1; + $self->charged($self->charged+$amount); + $error = $self->replace; + return $error if $error; + + $self->apply_payments_and_credits; +} + + =item check Checks all fields to make sure this is a valid invoice. If there is an error, @@ -352,11 +430,24 @@ this invoice. sub cust_pkg { my $self = shift; - my @cust_pkg = map { $_->cust_pkg } $self->cust_bill_pkg; + my @cust_pkg = map { $_->pkgnum > 0 ? $_->cust_pkg : () } + $self->cust_bill_pkg; my %saw = (); grep { ! $saw{$_->pkgnum}++ } @cust_pkg; } +=item no_auto + +Returns true if any of the packages (or their definitions) corresponding to the +line items for this invoice have the no_auto flag set. + +=cut + +sub no_auto { + my $self = shift; + grep { $_->no_auto || $_->part_pkg->no_auto } $self->cust_pkg; +} + =item open_cust_bill_pkg Returns the open line items for this invoice. @@ -898,6 +989,19 @@ sub generate_email { 'Filename' => 'logo.png', 'Content-ID' => "<$content_id>", ; + + my $barcode; + if($conf->exists('invoice-barcode')){ + my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from"; + $barcode = build MIME::Entity + 'Type' => 'image/png', + 'Encoding' => 'base64', + 'Data' => $self->invoice_barcode(0), + 'Filename' => 'barcode.png', + 'Content-ID' => "<$barcode_content_id>", + ; + $opt{'barcode_cid'} = $barcode_content_id; + } $alternative->attach( 'Type' => 'text/html', @@ -971,7 +1075,12 @@ sub generate_email { # image/png $return{'content-type'} = 'multipart/related'; - $return{'mimeparts'} = [ $alternative, $image, @otherparts ]; + if($conf->exists('invoice-barcode')){ + $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ]; + } + else { + $return{'mimeparts'} = [ $alternative, $image, @otherparts ]; + } $return{'type'} = 'multipart/alternative'; #Content-Type of first part... #$return{'disposition'} = 'inline'; @@ -1181,8 +1290,14 @@ sub email { my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list; - #better to notify this person than silence - @invoicing_list = ($invoice_from) unless @invoicing_list; + if ( ! @invoicing_list ) { #no recipients + if ( $conf->exists('cust_bill-no_recipients-error') ) { + die 'No recipients for customer #'. $self->custnum; + } else { + #default: better to notify this person than silence + @invoicing_list = ($invoice_from); + } + } my $subject = $self->email_subject($template); @@ -1283,7 +1398,13 @@ sub print { 'notice_name' => $notice_name, ); - do_print $self->lpr_data(\%opt); + if($conf->exists('invoice_print_pdf')) { + # Add the invoice to the current batch. + $self->batch_invoice(\%opt); + } + else { + do_print $self->lpr_data(\%opt); + } } =item fax_invoice HASHREF | [ TEMPLATE ] @@ -1329,6 +1450,23 @@ sub fax_invoice { } +=item batch_invoice [ HASHREF ] + +Place this invoice into the open batch (see C). If there +isn't an open batch, one will be created. + +=cut + +sub batch_invoice { + my ($self, $opt) = @_; + my $batch = FS::bill_batch->get_open_batch; + my $cust_bill_batch = FS::cust_bill_batch->new({ + batchnum => $batch->batchnum, + invnum => $self->invnum, + }); + return $cust_bill_batch->insert($opt); +} + =item ftp_invoice [ TEMPLATENAME ] Sends this invoice data via FTP. @@ -1869,7 +2007,8 @@ sub realtime_lec { } sub realtime_bop { - my( $self, $method ) = @_; + my( $self, $method ) = (shift,shift); + my %opt = @_; my $cust_main = $self->cust_main; my $balance = $cust_main->balance; @@ -1895,6 +2034,15 @@ sub realtime_bop { $cust_main->realtime_bop($method, $amount, 'description' => $description, 'invnum' => $self->invnum, +#this didn't do what we want, it just calls apply_payments_and_credits +# 'apply' => 1, + 'apply_to_invoice' => 1, + %opt, + #what we want: + #this changes application behavior: auto payments + #triggered against a specific invoice are now applied + #to that invoice instead of oldest open. + #seem okay to me... ); } @@ -2024,6 +2172,28 @@ sub print_latex { close $lh; $params{'logo_file'} = $lh->filename; + if($conf->exists('invoice-barcode')){ + my $png_file = $self->invoice_barcode($dir); + my $eps_file = $png_file; + $eps_file =~ s/\.png$/.eps/g; + $png_file =~ /(barcode.*png)/; + $png_file = $1; + $eps_file =~ /(barcode.*eps)/; + $eps_file = $1; + + my $curr_dir = cwd(); + chdir($dir); + # after painfuly long experimentation, it was determined that sam2p won't + # accept : and other chars in the path, no matter how hard I tried to + # escape them, hence the chdir (and chdir back, just to be safe) + system('sam2p', '-j:quiet', $png_file, 'EPS:', $eps_file ) == 0 + or die "sam2p failed: $!\n"; + unlink($png_file); + chdir($curr_dir); + + $params{'barcode_file'} = $eps_file; + } + my @filled_in = $self->print_generic( %params ); my $fh = new File::Temp( TEMPLATE => 'invoice.'. $self->invnum. '.XXXXXXXX', @@ -2035,10 +2205,39 @@ sub print_latex { close $fh; $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename; - return ($1, $params{'logo_file'}); + return ($1, $params{'logo_file'}, $params{'barcode_file'}); } +=item invoice_barcode DIR_OR_FALSE + +Generates an invoice barcode PNG. If DIR_OR_FALSE is a true value, +it is taken as the temp directory where the PNG file will be generated and the +PNG file name is returned. Otherwise, the PNG image itself is returned. + +=cut + +sub invoice_barcode { + my ($self, $dir) = (shift,shift); + + my $gdbar = new GD::Barcode('Code39',$self->invnum); + die "can't create barcode: " . $GD::Barcode::errStr unless $gdbar; + my $gd = $gdbar->plot(Height => 30); + + if($dir) { + my $bh = new File::Temp( TEMPLATE => 'barcode.'. $self->invnum. '.XXXXXXXX', + DIR => $dir, + SUFFIX => '.png', + UNLINK => 0, + ) or die "can't open temp file: $!\n"; + print $bh $gd->png or die "cannot write barcode to file: $!\n"; + my $png_file = $bh->filename; + close $bh; + return $png_file; + } + return $gd->png; +} + =item print_generic OPTION => VALUE ... Internal method - returns a filled-in template for this invoice as a scalar. @@ -2067,6 +2266,7 @@ notice_name - overrides "Invoice" as the name of the sent document (templates fr #what's with all the sprintf('%10.2f')'s in here? will it cause any # (alignment in text invoice?) problems to change them all to '%.2f' ? +# yes: fixed width (dot matrix) text printing will be borked sub print_generic { my( $self, %params ) = @_; @@ -2088,6 +2288,9 @@ sub print_generic { 'template' => [ '{', '}' ], ); + warn "$me print_generic creating template\n" + if $DEBUG > 1; + #create the template my $template = $params{template} ? $params{template} : $self->_agent_template; my $templatefile = "invoice_$format"; @@ -2105,12 +2308,18 @@ sub print_generic { @invoice_template = _translate_old_latex_format(@invoice_template); } + warn "$me print_generic creating T:T object\n" + if $DEBUG > 1; + my $text_template = new Text::Template( TYPE => 'ARRAY', SOURCE => \@invoice_template, DELIMITERS => $delimiters{$format}, ); + warn "$me print_generic compiling T:T object\n" + if $DEBUG > 1; + $text_template->compile() or die "Can't compile $templatefile: $Text::Template::ERROR\n"; @@ -2201,15 +2410,19 @@ sub print_generic { my $nbsp = $nbsps{$format}; my %escape_functions = ( 'latex' => \&_latex_escape, - 'html' => \&encode_entities, + 'html' => \&_html_escape_nbsp,#\&encode_entities, 'template' => sub { shift }, ); my $escape_function = $escape_functions{$format}; + my $escape_function_nonbsp = ($format eq 'html') + ? \&_html_escape : $escape_function; - my %date_formats = ( 'latex' => '%b %o, %Y', - 'html' => '%b %o, %Y', + my %date_formats = ( 'latex' => $date_format_long, + 'html' => $date_format_long, 'template' => '%s', ); + $date_formats{'html'} =~ s/ / /g; + my $date_format = $date_formats{$format}; my %embolden_functions = ( 'latex' => sub { return '\textbf{'. shift(). '}' @@ -2220,6 +2433,8 @@ sub print_generic { ); my $embolden_function = $embolden_functions{$format}; + warn "$me generating template variables\n" + if $DEBUG > 1; # generate template variables my $returnaddress; @@ -2273,23 +2488,28 @@ sub print_generic { } + warn "$me generating invoice data\n" + if $DEBUG > 1; + + my $agentnum = $self->cust_main->agentnum; + my %invoice_data = ( #invoice from info - 'company_name' => scalar( $conf->config('company_name', $self->cust_main->agentnum) ), - 'company_address' => join("\n", $conf->config('company_address', $self->cust_main->agentnum) ). "\n", + 'company_name' => scalar( $conf->config('company_name', $agentnum) ), + 'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n", 'returnaddress' => $returnaddress, 'agent' => &$escape_function($cust_main->agent->agent), #invoice info 'invnum' => $self->invnum, 'date' => time2str($date_format, $self->_date), - 'today' => time2str('%b %o, %Y', $today), + 'today' => time2str($date_format_long, $today), 'terms' => $self->terms, 'template' => $template, #params{'template'}, 'notice_name' => ($params{'notice_name'} || 'Invoice'),#escape_function? 'current_charges' => sprintf("%.2f", $self->charged), - 'duedate' => $self->due_date2str('%m/%d/%Y'), #date_format? + 'duedate' => $self->due_date2str($rdate_format), #date_format? #customer info 'custnum' => $cust_main->display_custnum, @@ -2303,7 +2523,21 @@ sub print_generic { 'unitprices' => $conf->exists('invoice-unitprice'), 'smallernotes' => $conf->exists('invoice-smallernotes'), 'smallerfooter' => $conf->exists('invoice-smallerfooter'), + 'balance_due_below_line' => $conf->exists('balance_due_below_line'), + #layout info -- would be fancy to calc some of this and bury the template + # here in the code + 'topmargin' => scalar($conf->config('invoice_latextopmargin', $agentnum)), + 'headsep' => scalar($conf->config('invoice_latexheadsep', $agentnum)), + 'textheight' => scalar($conf->config('invoice_latextextheight', $agentnum)), + 'extracouponspace' => scalar($conf->config('invoice_latexextracouponspace', $agentnum)), + 'couponfootsep' => scalar($conf->config('invoice_latexcouponfootsep', $agentnum)), + 'verticalreturnaddress' => $conf->exists('invoice_latexverticalreturnaddress', $agentnum), + 'addresssep' => scalar($conf->config('invoice_latexaddresssep', $agentnum)), + 'amountenclosedsep' => scalar($conf->config('invoice_latexcouponamountenclosedsep', $agentnum)), + 'coupontoaddresssep' => scalar($conf->config('invoice_latexcoupontoaddresssep', $agentnum)), + 'addcompanytoaddress' => $conf->exists('invoice_latexcouponaddcompanytoaddress', $agentnum), + # better hang on to conf_dir for a while (for old templates) 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc", @@ -2319,7 +2553,8 @@ sub print_generic { qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') }); $invoice_data{finance_section} = $pkg_class->categoryname; } - $invoice_data{finance_amount} = '0.00'; + $invoice_data{finance_amount} = '0.00'; + $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion my $countrydefault = $conf->config('countrydefault') || 'US'; my $prefix = $cust_main->has_ship_address ? 'ship_' : ''; @@ -2362,6 +2597,12 @@ sub print_generic { $invoice_data{'logo_file'} = $params{'logo_file'} if $params{'logo_file'}; + $invoice_data{'barcode_file'} = $params{'barcode_file'} + if $params{'barcode_file'}; + $invoice_data{'barcode_img'} = $params{'barcode_img'} + if $params{'barcode_img'}; + $invoice_data{'barcode_cid'} = $params{'barcode_cid'} + if $params{'barcode_cid'}; my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance # my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits @@ -2372,15 +2613,15 @@ sub print_generic { $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total); $invoice_data{'balance'} = sprintf("%.2f", $balance_due); - my $agentnum = $self->cust_main->agentnum; - my $summarypage = ''; if ( $conf->exists('invoice_usesummary', $agentnum) ) { $summarypage = 1; } $invoice_data{'summarypage'} = $summarypage; - #do variable substitution in notes, footer, smallfooter + warn "$me substituting variables in notes, footer, smallfooter\n" + if $DEBUG > 1; + foreach my $include (qw( notes footer smallfooter coupon )) { my $inc_file = $conf->key_orbase("invoice_${format}$include", $template); @@ -2451,30 +2692,70 @@ sub print_generic { $invoice_data{'buf'} = \@buf; $invoice_data{'sections'} = \@sections; + warn "$me generating sections\n" + if $DEBUG > 1; + my $previous_section = { 'description' => 'Previous Charges', 'subtotal' => $other_money_char. sprintf('%.2f', $pr_total), 'summarized' => $summarypage ? 'Y' : '', }; + $previous_section->{posttotal} = '0 / 30 / 60/ 90 days overdue '. + join(' / ', map { $cust_main->balance_date_range(@$_) } + $self->_prior_month30s + ) + if $conf->exists('invoice_include_aging'); my $taxtotal = 0; my $tax_section = { 'description' => 'Taxes, Surcharges, and Fees', 'subtotal' => $taxtotal, # adjusted below 'summarized' => $summarypage ? 'Y' : '', }; + my $tax_weight = _pkg_category($tax_section->{description}) + ? _pkg_category($tax_section->{description})->weight + : 0; + $tax_section->{'summarized'} = $summarypage && !$tax_weight ? 'Y' : ''; + $tax_section->{'sort_weight'} = $tax_weight; + my $adjusttotal = 0; my $adjust_section = { 'description' => 'Credits, Payments, and Adjustments', 'subtotal' => 0, # adjusted below 'summarized' => $summarypage ? 'Y' : '', }; + my $adjust_weight = _pkg_category($adjust_section->{description}) + ? _pkg_category($adjust_section->{description})->weight + : 0; + $adjust_section->{'summarized'} = $summarypage && !$adjust_weight ? 'Y' : ''; + $adjust_section->{'sort_weight'} = $adjust_weight; my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y'; my $multisection = $conf->exists('invoice_sections', $cust_main->agentnum); + $invoice_data{'multisection'} = $multisection; my $late_sections = []; + my $extra_sections = []; + my $extra_lines = (); if ( $multisection ) { + ($extra_sections, $extra_lines) = + $self->_items_extra_usage_sections($escape_function_nonbsp, $format) + if $conf->exists('usage_class_as_a_section', $cust_main->agentnum); + + push @$extra_sections, $adjust_section if $adjust_section->{sort_weight}; + + push @detail_items, @$extra_lines if $extra_lines; push @sections, - $self->_items_sections( $late_sections, $summarypage, $escape_function ); + $self->_items_sections( $late_sections, # this could stand a refactor + $summarypage, + $escape_function_nonbsp, + $extra_sections, + $format, #bah + ); + if ($conf->exists('svc_phone_sections')) { + my ($phone_sections, $phone_lines) = + $self->_items_svc_phone_sections($escape_function_nonbsp, $format); + push @{$late_sections}, @$phone_sections; + push @detail_items, @$phone_lines; + } }else{ push @sections, { 'description' => '', 'subtotal' => '' }; } @@ -2484,6 +2765,9 @@ sub print_generic { ) { + warn "$me adding previous balances\n" + if $DEBUG > 1; + foreach my $line_item ( $self->_items_previous ) { my $detail = { @@ -2516,9 +2800,31 @@ sub print_generic { $money_char. sprintf("%10.2f", $pr_total) ]; push @buf, ['','']; } + + if ( $conf->exists('svc_phone-did-summary') ) { + warn "$me adding DID summary\n" + if $DEBUG > 1; + + my ($didsummary,$minutes) = $self->_did_summary; + my $didsummary_desc = 'DID Activity Summary (Past 30 days)'; + push @detail_items, + { 'description' => $didsummary_desc, + 'ext_description' => [ $didsummary, $minutes ], + } + if !$multisection; + } foreach my $section (@sections, @$late_sections) { + warn "$me adding section \n". Dumper($section) + if $DEBUG > 1; + + # begin some normalization + $section->{'subtotal'} = $section->{'amount'} + if $multisection + && !exists($section->{subtotal}) + && exists($section->{amount}); + $invoice_data{finance_amount} = sprintf('%.2f', $section->{'subtotal'} ) if ( $invoice_data{finance_section} && $section->{'description'} eq $invoice_data{finance_section} ); @@ -2527,12 +2833,21 @@ sub print_generic { sprintf('%.2f', $section->{'subtotal'}) if $multisection; + # continue some normalization + $section->{'amount'} = $section->{'subtotal'} + if $multisection; + + if ( $section->{'description'} ) { push @buf, ( [ &$escape_function($section->{'description'}), '' ], [ '', '' ], ); } + warn "$me setting options\n" + if $DEBUG > 1; + + my $multilocation = scalar($cust_main->cust_location); #too expensive? my %options = (); $options{'section'} = $section if $multisection; $options{'format'} = $format; @@ -2540,8 +2855,19 @@ sub print_generic { $options{'format_function'} = sub { () } unless $unsquelched; $options{'unsquelched'} = $unsquelched; $options{'summary_page'} = $summarypage; + $options{'skip_usage'} = + scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections; + $options{'multilocation'} = $multilocation; + $options{'multisection'} = $multisection; + + warn "$me searching for line items\n" + if $DEBUG > 1; foreach my $line_item ( $self->_items_pkg(%options) ) { + + warn "$me adding line item $line_item\n" + if $DEBUG > 1; + my $detail = { ext_description => [], }; @@ -2581,10 +2907,15 @@ sub print_generic { $invoice_data{current_less_finance} = sprintf('%.2f', $self->charged - $invoice_data{finance_amount} ); - if ( $multisection && !$conf->exists('disable_previous_balance') ) { + if ( $multisection && !$conf->exists('disable_previous_balance') + || $conf->exists('previous_balance-summary_only') ) + { unshift @sections, $previous_section if $pr_total; } + warn "$me adding taxes\n" + if $DEBUG > 1; + foreach my $tax ( $self->_items_tax ) { $taxtotal += $tax->{'amount'}; @@ -2648,32 +2979,34 @@ sub print_generic { { my $total = {}; - $total->{'total_item'} = &$embolden_function('Total'); + my $item = 'Total'; + $item = $conf->config('previous_balance-exclude_from_total') + || 'Total New Charges' + if $conf->exists('previous_balance-exclude_from_total'); + my $amount = $self->charged + + ( $conf->exists('disable_previous_balance') || + $conf->exists('previous_balance-exclude_from_total') + ? 0 + : $pr_total + ); + $total->{'total_item'} = &$embolden_function($item); $total->{'total_amount'} = - &$embolden_function( - $other_money_char. - sprintf( '%.2f', - $self->charged + ( $conf->exists('disable_previous_balance') - ? 0 - : $pr_total - ) - ) - ); + &$embolden_function( $other_money_char. sprintf( '%.2f', $amount ) ); if ( $multisection ) { - $adjust_section->{'pretotal'} = 'New charges total '. $other_money_char. - sprintf('%.2f', $self->charged ); + if ( $adjust_section->{'sort_weight'} ) { + $adjust_section->{'posttotal'} = 'Balance Forward '. $other_money_char. + sprintf("%.2f", ($self->billing_balance || 0) ); + } else { + $adjust_section->{'pretotal'} = 'New charges total '. $other_money_char. + sprintf('%.2f', $self->charged ); + } }else{ push @total_items, $total; } push @buf,['','-----------']; - push @buf,['Total Charges', + push @buf,[$item, $money_char. - sprintf( '%10.2f', $self->charged + - ( $conf->exists('disable_previous_balance') - ? 0 - : $pr_total - ) - ) + sprintf( '%10.2f', $amount ) ]; push @buf,['','']; } @@ -2744,7 +3077,8 @@ sub print_generic { if ( $multisection ) { $adjust_section->{'subtotal'} = $other_money_char. sprintf('%.2f', $adjusttotal); - push @sections, $adjust_section; + push @sections, $adjust_section + unless $adjust_section->{sort_weight}; } { @@ -2758,7 +3092,7 @@ sub print_generic { : $self->owed + $pr_total ) ); - if ( $multisection ) { + if ( $multisection && !$adjust_section->{sort_weight} ) { $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '. $total->{'total_amount'}; }else{ @@ -2771,6 +3105,18 @@ sub print_generic { } if ( $multisection ) { + if ($conf->exists('svc_phone_sections')) { + my $total; + $total->{'total_item'} = &$embolden_function($self->balance_due_msg); + $total->{'total_amount'} = + &$embolden_function( + $other_money_char. sprintf('%.2f', $self->owed + $pr_total) + ); + my $last_section = pop @sections; + $last_section->{'posttotal'} = $total->{'total_item'}. ' '. + $total->{'total_amount'}; + push @sections, $last_section; + } push @sections, @$late_sections if $unsquelched; } @@ -2868,6 +3214,22 @@ sub print_generic { } } +# helper routine for generating date ranges +sub _prior_month30s { + my $self = shift; + my @ranges = ( + [ 1, 2592000 ], # 0-30 days ago + [ 2592000, 5184000 ], # 30-60 days ago + [ 5184000, 7776000 ], # 60-90 days ago + [ 7776000, 0 ], # 90+ days ago + ); + + map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '', + $_->[1] ? $self->_date - $_->[1] - 1 : '', + ] } + @ranges; +} + =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ] Returns an postscript invoice, as a scalar. @@ -2887,9 +3249,10 @@ I, if specified, overrides "Invoice" as the name of the sent docume sub print_ps { my $self = shift; - my ($file, $lfile) = $self->print_latex(@_); + my ($file, $logofile, $barcodefile) = $self->print_latex(@_); my $ps = generate_ps($file); - unlink($lfile); + unlink($logofile); + unlink($barcodefile); $ps; } @@ -2915,9 +3278,10 @@ I, if specified, overrides "Invoice" as the name of the sent docume sub print_pdf { my $self = shift; - my ($file, $lfile) = $self->print_latex(@_); + my ($file, $logofile, $barcodefile) = $self->print_latex(@_); my $pdf = generate_pdf($file); - unlink($lfile); + unlink($logofile); + unlink($barcodefile); $pdf; } @@ -2952,7 +3316,7 @@ sub print_html { } $params{'format'} = 'html'; - + $self->print_generic( %params ); } @@ -2972,6 +3336,18 @@ sub _latex_escape { $value; } +sub _html_escape { + my $value = shift; + encode_entities($value); + $value; +} + +sub _html_escape_nbsp { + my $value = _html_escape(shift); + $value =~ s/ +/ /g; + $value; +} + #utility methods for print_* sub _translate_old_latex_format { @@ -3068,7 +3444,7 @@ sub balance_due_msg { my $msg = 'Balance Due'; return $msg unless $self->terms; if ( $self->due_date ) { - $msg .= ' - Please pay by '. $self->due_date2str('%x'); + $msg .= ' - Please pay by '. $self->due_date2str($date_format); } elsif ( $self->terms ) { $msg .= ' - '. $self->terms; } @@ -3080,7 +3456,7 @@ sub balance_due_date { my $duedate = ''; if ( $conf->exists('invoice_default_terms') && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) { - $duedate = time2str("%m/%d/%Y", $self->_date + ($1*86400) ); + $duedate = time2str($rdate_format, $self->_date + ($1*86400) ); } $duedate; } @@ -3105,73 +3481,79 @@ Returns a string with the date, for example: "3/20/2008" sub _date_pretty { my $self = shift; - time2str('%x', $self->_date); + time2str($date_format, $self->_date); } +use vars qw(%pkg_category_cache); sub _items_sections { my $self = shift; my $late = shift; my $summarypage = shift; my $escape = shift; + my $extra_sections = shift; + my $format = shift; - my %s = (); - my %l = (); + my %subtotal = (); + my %late_subtotal = (); my %not_tax = (); foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { - my $usage = $cust_bill_pkg->usage; foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) { next if ( $display->summary && $summarypage ); - my $desc = $display->section; - my $type = $display->type; + my $section = $display->section; + my $type = $display->type; - if ( $cust_bill_pkg->pkgnum > 0 ) { - $not_tax{$desc} = 1; - } + $not_tax{$section} = 1 + unless $cust_bill_pkg->pkgnum == 0; if ( $display->post_total && !$summarypage ) { if (! $type || $type eq 'S') { - $l{$desc} += $cust_bill_pkg->setup - if ( $cust_bill_pkg->setup != 0 ); + $late_subtotal{$section} += $cust_bill_pkg->setup + if $cust_bill_pkg->setup != 0; } if (! $type) { - $l{$desc} += $cust_bill_pkg->recur - if ( $cust_bill_pkg->recur != 0 ); + $late_subtotal{$section} += $cust_bill_pkg->recur + if $cust_bill_pkg->recur != 0; } if ($type && $type eq 'R') { - $l{$desc} += $cust_bill_pkg->recur - $usage - if ( $cust_bill_pkg->recur != 0 ); + $late_subtotal{$section} += $cust_bill_pkg->recur - $usage + if $cust_bill_pkg->recur != 0; } if ($type && $type eq 'U') { - $l{$desc} += $usage; + $late_subtotal{$section} += $usage + unless scalar(@$extra_sections); } } else { + + next if $cust_bill_pkg->pkgnum == 0 && ! $section; + if (! $type || $type eq 'S') { - $s{$desc} += $cust_bill_pkg->setup - if ( $cust_bill_pkg->setup != 0 ); + $subtotal{$section} += $cust_bill_pkg->setup + if $cust_bill_pkg->setup != 0; } if (! $type) { - $s{$desc} += $cust_bill_pkg->recur - if ( $cust_bill_pkg->recur != 0 ); + $subtotal{$section} += $cust_bill_pkg->recur + if $cust_bill_pkg->recur != 0; } if ($type && $type eq 'R') { - $s{$desc} += $cust_bill_pkg->recur - $usage - if ( $cust_bill_pkg->recur != 0 ); + $subtotal{$section} += $cust_bill_pkg->recur - $usage + if $cust_bill_pkg->recur != 0; } if ($type && $type eq 'U') { - $s{$desc} += $usage; + $subtotal{$section} += $usage + unless scalar(@$extra_sections); } } @@ -3180,27 +3562,620 @@ sub _items_sections { } - my %cache = map { $_->categoryname => $_ } - qsearch( 'pkg_category', {disabled => 'Y'} ); - $cache{$_->categoryname} = $_ - foreach qsearch( 'pkg_category', {disabled => ''} ); + %pkg_category_cache = (); push @$late, map { { 'description' => &{$escape}($_), - 'subtotal' => $l{$_}, + 'subtotal' => $late_subtotal{$_}, 'post_total' => 1, + 'sort_weight' => ( _pkg_category($_) + ? _pkg_category($_)->weight + : 0 + ), + ((_pkg_category($_) && _pkg_category($_)->condense) + ? $self->_condense_section($format) + : () + ), } } - sort { $cache{$a}->weight <=> $cache{$b}->weight } keys %l; - - map { { 'description' => &{$escape}($_), - 'subtotal' => $s{$_}, - 'summarized' => $not_tax{$_} ? '' : 'Y', - 'tax_section' => $not_tax{$_} ? '' : 'Y', - } } - sort { $cache{$a}->weight <=> $cache{$b}->weight } - ( $summarypage - ? ( grep { exists($s{$_}) || !$cache{$_}->disabled } keys %cache ) - : ( keys %s ) - ); + sort _sectionsort keys %late_subtotal; + + my @sections; + if ( $summarypage ) { + @sections = grep { exists($subtotal{$_}) || ! _pkg_category($_)->disabled } + map { $_->categoryname } qsearch('pkg_category', {}); + push @sections, '' if exists($subtotal{''}); + } else { + @sections = keys %subtotal; + } + + my @early = map { { 'description' => &{$escape}($_), + 'subtotal' => $subtotal{$_}, + 'summarized' => $not_tax{$_} ? '' : 'Y', + 'tax_section' => $not_tax{$_} ? '' : 'Y', + 'sort_weight' => ( _pkg_category($_) + ? _pkg_category($_)->weight + : 0 + ), + ((_pkg_category($_) && _pkg_category($_)->condense) + ? $self->_condense_section($format) + : () + ), + } + } @sections; + push @early, @$extra_sections if $extra_sections; + + sort { $a->{sort_weight} <=> $b->{sort_weight} } @early; + +} + +#helper subs for above + +sub _sectionsort { + _pkg_category($a)->weight <=> _pkg_category($b)->weight; +} + +sub _pkg_category { + my $categoryname = shift; + $pkg_category_cache{$categoryname} ||= + qsearchs( 'pkg_category', { 'categoryname' => $categoryname } ); +} + +my %condensed_format = ( + 'label' => [ qw( Description Qty Amount ) ], + 'fields' => [ + sub { shift->{description} }, + sub { shift->{quantity} }, + sub { my($href, %opt) = @_; + ($opt{dollar} || ''). $href->{amount}; + }, + ], + 'align' => [ qw( l r r ) ], + 'span' => [ qw( 5 1 1 ) ], # unitprices? + 'width' => [ qw( 10.7cm 1.4cm 1.6cm ) ], # don't like this +); + +sub _condense_section { + my ( $self, $format ) = ( shift, shift ); + ( 'condensed' => 1, + map { my $method = "_condensed_$_"; $_ => $self->$method($format) } + qw( description_generator + header_generator + total_generator + total_line_generator + ) + ); +} + +sub _condensed_generator_defaults { + my ( $self, $format ) = ( shift, shift ); + return ( \%condensed_format, ' ', ' ', ' ', sub { shift } ); +} + +my %html_align = ( + 'c' => 'center', + 'l' => 'left', + 'r' => 'right', +); + +sub _condensed_header_generator { + my ( $self, $format ) = ( shift, shift ); + + my ( $f, $prefix, $suffix, $separator, $column ) = + _condensed_generator_defaults($format); + + if ($format eq 'latex') { + $prefix = "\\hline\n\\rule{0pt}{2.5ex}\n\\makebox[1.4cm]{}&\n"; + $suffix = "\\\\\n\\hline"; + $separator = "&\n"; + $column = + sub { my ($d,$a,$s,$w) = @_; + return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}"; + }; + } elsif ( $format eq 'html' ) { + $prefix = ''; + $suffix = ''; + $separator = ''; + $column = + sub { my ($d,$a,$s,$w) = @_; + return qq!$d!; + }; + } + + sub { + my @args = @_; + my @result = (); + + foreach (my $i = 0; $f->{label}->[$i]; $i++) { + push @result, + &{$column}( map { $f->{$_}->[$i] } qw(label align span width) ); + } + + $prefix. join($separator, @result). $suffix; + }; + +} + +sub _condensed_description_generator { + my ( $self, $format ) = ( shift, shift ); + + my ( $f, $prefix, $suffix, $separator, $column ) = + _condensed_generator_defaults($format); + + my $money_char = '$'; + if ($format eq 'latex') { + $prefix = "\\hline\n\\multicolumn{1}{c}{\\rule{0pt}{2.5ex}~} &\n"; + $suffix = '\\\\'; + $separator = " & \n"; + $column = + sub { my ($d,$a,$s,$w) = @_; + return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{\\textbf{$d}}}"; + }; + $money_char = '\\dollar'; + }elsif ( $format eq 'html' ) { + $prefix = '">'; + $suffix = ''; + $separator = ''; + $column = + sub { my ($d,$a,$s,$w) = @_; + return qq!$d!; + }; + #$money_char = $conf->config('money_char') || '$'; + $money_char = ''; # this is madness + } + + sub { + #my @args = @_; + my $href = shift; + my @result = (); + + foreach (my $i = 0; $f->{label}->[$i]; $i++) { + my $dollar = ''; + $dollar = $money_char if $i == scalar(@{$f->{label}})-1; + push @result, + &{$column}( &{$f->{fields}->[$i]}($href, 'dollar' => $dollar), + map { $f->{$_}->[$i] } qw(align span width) + ); + } + + $prefix. join( $separator, @result ). $suffix; + }; + +} + +sub _condensed_total_generator { + my ( $self, $format ) = ( shift, shift ); + + my ( $f, $prefix, $suffix, $separator, $column ) = + _condensed_generator_defaults($format); + my $style = ''; + + if ($format eq 'latex') { + $prefix = "& "; + $suffix = "\\\\\n"; + $separator = " & \n"; + $column = + sub { my ($d,$a,$s,$w) = @_; + return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}"; + }; + }elsif ( $format eq 'html' ) { + $prefix = ''; + $suffix = ''; + $separator = ''; + $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;'; + $column = + sub { my ($d,$a,$s,$w) = @_; + return qq!$d!; + }; + } + + + sub { + my @args = @_; + my @result = (); + + # my $r = &{$f->{fields}->[$i]}(@args); + # $r .= ' Total' unless $i; + + foreach (my $i = 0; $f->{label}->[$i]; $i++) { + push @result, + &{$column}( &{$f->{fields}->[$i]}(@args). ($i ? '' : ' Total'), + map { $f->{$_}->[$i] } qw(align span width) + ); + } + + $prefix. join( $separator, @result ). $suffix; + }; + +} + +=item total_line_generator FORMAT + +Returns a coderef used for generation of invoice total line items for this +usage_class. FORMAT is either html or latex + +=cut + +# should not be used: will have issues with hash element names (description vs +# total_item and amount vs total_amount -- another array of functions? + +sub _condensed_total_line_generator { + my ( $self, $format ) = ( shift, shift ); + + my ( $f, $prefix, $suffix, $separator, $column ) = + _condensed_generator_defaults($format); + my $style = ''; + + if ($format eq 'latex') { + $prefix = "& "; + $suffix = "\\\\\n"; + $separator = " & \n"; + $column = + sub { my ($d,$a,$s,$w) = @_; + return "\\multicolumn{$s}{$a}{\\makebox[$w][$a]{$d}}"; + }; + }elsif ( $format eq 'html' ) { + $prefix = ''; + $suffix = ''; + $separator = ''; + $style = 'border-top: 3px solid #000000;border-bottom: 3px solid #000000;'; + $column = + sub { my ($d,$a,$s,$w) = @_; + return qq!$d!; + }; + } + + + sub { + my @args = @_; + my @result = (); + + foreach (my $i = 0; $f->{label}->[$i]; $i++) { + push @result, + &{$column}( &{$f->{fields}->[$i]}(@args), + map { $f->{$_}->[$i] } qw(align span width) + ); + } + + $prefix. join( $separator, @result ). $suffix; + }; + +} + +#sub _items_extra_usage_sections { +# my $self = shift; +# my $escape = shift; +# +# my %sections = (); +# +# my %usage_class = map{ $_->classname, $_ } qsearch('usage_class', {}); +# foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) +# { +# next unless $cust_bill_pkg->pkgnum > 0; +# +# foreach my $section ( keys %usage_class ) { +# +# my $usage = $cust_bill_pkg->usage($section); +# +# next unless $usage && $usage > 0; +# +# $sections{$section} ||= 0; +# $sections{$section} += $usage; +# +# } +# +# } +# +# map { { 'description' => &{$escape}($_), +# 'subtotal' => $sections{$_}, +# 'summarized' => '', +# 'tax_section' => '', +# } +# } +# sort {$usage_class{$a}->weight <=> $usage_class{$b}->weight} keys %sections; +# +#} + +sub _items_extra_usage_sections { + my $self = shift; + my $escape = shift; + my $format = shift; + + my %sections = (); + my %classnums = (); + my %lines = (); + + my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} ); + foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { + next unless $cust_bill_pkg->pkgnum > 0; + + foreach my $classnum ( keys %usage_class ) { + my $section = $usage_class{$classnum}->classname; + $classnums{$section} = $classnum; + + foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail($classnum) ) { + my $amount = $detail->amount; + next unless $amount && $amount > 0; + + $sections{$section} ||= { 'subtotal'=>0, 'calls'=>0, 'duration'=>0 }; + $sections{$section}{amount} += $amount; #subtotal + $sections{$section}{calls}++; + $sections{$section}{duration} += $detail->duration; + + my $desc = $detail->regionname; + my $description = $desc; + $description = substr($desc, 0, 50). '...' + if $format eq 'latex' && length($desc) > 50; + + $lines{$section}{$desc} ||= { + description => &{$escape}($description), + #pkgpart => $part_pkg->pkgpart, + pkgnum => $cust_bill_pkg->pkgnum, + ref => '', + amount => 0, + calls => 0, + duration => 0, + #unit_amount => $cust_bill_pkg->unitrecur, + quantity => $cust_bill_pkg->quantity, + product_code => 'N/A', + ext_description => [], + }; + + $lines{$section}{$desc}{amount} += $amount; + $lines{$section}{$desc}{calls}++; + $lines{$section}{$desc}{duration} += $detail->duration; + + } + } + } + + my %sectionmap = (); + foreach (keys %sections) { + my $usage_class = $usage_class{$classnums{$_}}; + $sectionmap{$_} = { 'description' => &{$escape}($_), + 'amount' => $sections{$_}{amount}, #subtotal + 'calls' => $sections{$_}{calls}, + 'duration' => $sections{$_}{duration}, + 'summarized' => '', + 'tax_section' => '', + 'sort_weight' => $usage_class->weight, + ( $usage_class->format + ? ( map { $_ => $usage_class->$_($format) } + qw( description_generator header_generator total_generator total_line_generator ) + ) + : () + ), + }; + } + + my @sections = sort { $a->{sort_weight} <=> $b->{sort_weight} } + values %sectionmap; + + my @lines = (); + foreach my $section ( keys %lines ) { + foreach my $line ( keys %{$lines{$section}} ) { + my $l = $lines{$section}{$line}; + $l->{section} = $sectionmap{$section}; + $l->{amount} = sprintf( "%.2f", $l->{amount} ); + #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} ); + push @lines, $l; + } + } + + return(\@sections, \@lines); + +} + +sub _did_summary { + my $self = shift; + my $end = $self->_date; + my $start = $end - 2592000; # 30 days + my $cust_main = $self->cust_main; + my @pkgs = $cust_main->all_pkgs; + my($num_activated,$num_deactivated,$num_portedin,$num_portedout,$minutes) + = (0,0,0,0,0); + my @seen = (); + foreach my $pkg ( @pkgs ) { + my @h_cust_svc = $pkg->h_cust_svc($end); + foreach my $h_cust_svc ( @h_cust_svc ) { + next if grep {$_ eq $h_cust_svc->svcnum} @seen; + next unless $h_cust_svc->part_svc->svcdb eq 'svc_phone'; + + my $inserted = $h_cust_svc->date_inserted; + my $deleted = $h_cust_svc->date_deleted; + my $phone_inserted = $h_cust_svc->h_svc_x($inserted); + my $phone_deleted; + $phone_deleted = $h_cust_svc->h_svc_x($deleted) if $deleted; + +# DID either activated or ported in; cannot be both for same DID simultaneously + if ($inserted >= $start && $inserted <= $end && $phone_inserted + && (!$phone_inserted->lnp_status + || $phone_inserted->lnp_status eq '' + || $phone_inserted->lnp_status eq 'native')) { + $num_activated++; + } + else { # this one not so clean, should probably move to (h_)svc_phone + my $phone_portedin = qsearchs( 'h_svc_phone', + { 'svcnum' => $h_cust_svc->svcnum, + 'lnp_status' => 'portedin' }, + FS::h_svc_phone->sql_h_searchs($end), + ); + $num_portedin++ if $phone_portedin; + } + +# DID either deactivated or ported out; cannot be both for same DID simultaneously + if($deleted >= $start && $deleted <= $end && $phone_deleted + && (!$phone_deleted->lnp_status + || $phone_deleted->lnp_status ne 'portingout')) { + $num_deactivated++; + } + elsif($deleted >= $start && $deleted <= $end && $phone_deleted + && $phone_deleted->lnp_status + && $phone_deleted->lnp_status eq 'portingout') { + $num_portedout++; + } + + # increment usage minutes + my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end); + foreach my $cdr ( @cdrs ) { + $minutes += $cdr->billsec/60; + } + + # don't look at this service again + push @seen, $h_cust_svc->svcnum; + } + } + + $minutes = sprintf("%d", $minutes); + ("Activated: $num_activated Ported-In: $num_portedin Deactivated: " + . "$num_deactivated Ported-Out: $num_portedout ", + "Total Minutes: $minutes"); +} + +sub _items_svc_phone_sections { + my $self = shift; + my $escape = shift; + my $format = shift; + + my %sections = (); + my %classnums = (); + my %lines = (); + + my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} ); + $usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 }; + + foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { + next unless $cust_bill_pkg->pkgnum > 0; + + my @header = $cust_bill_pkg->details_header; + next unless scalar(@header); + + foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) { + + my $phonenum = $detail->phonenum; + next unless $phonenum; + + my $amount = $detail->amount; + next unless $amount && $amount > 0; + + $sections{$phonenum} ||= { 'amount' => 0, + 'calls' => 0, + 'duration' => 0, + 'sort_weight' => -1, + 'phonenum' => $phonenum, + }; + $sections{$phonenum}{amount} += $amount; #subtotal + $sections{$phonenum}{calls}++; + $sections{$phonenum}{duration} += $detail->duration; + + my $desc = $detail->regionname; + my $description = $desc; + $description = substr($desc, 0, 50). '...' + if $format eq 'latex' && length($desc) > 50; + + $lines{$phonenum}{$desc} ||= { + description => &{$escape}($description), + #pkgpart => $part_pkg->pkgpart, + pkgnum => '', + ref => '', + amount => 0, + calls => 0, + duration => 0, + #unit_amount => '', + quantity => '', + product_code => 'N/A', + ext_description => [], + }; + + $lines{$phonenum}{$desc}{amount} += $amount; + $lines{$phonenum}{$desc}{calls}++; + $lines{$phonenum}{$desc}{duration} += $detail->duration; + + my $line = $usage_class{$detail->classnum}->classname; + $sections{"$phonenum $line"} ||= + { 'amount' => 0, + 'calls' => 0, + 'duration' => 0, + 'sort_weight' => $usage_class{$detail->classnum}->weight, + 'phonenum' => $phonenum, + 'header' => [ @header ], + }; + $sections{"$phonenum $line"}{amount} += $amount; #subtotal + $sections{"$phonenum $line"}{calls}++; + $sections{"$phonenum $line"}{duration} += $detail->duration; + + $lines{"$phonenum $line"}{$desc} ||= { + description => &{$escape}($description), + #pkgpart => $part_pkg->pkgpart, + pkgnum => '', + ref => '', + amount => 0, + calls => 0, + duration => 0, + #unit_amount => '', + quantity => '', + product_code => 'N/A', + ext_description => [], + }; + + $lines{"$phonenum $line"}{$desc}{amount} += $amount; + $lines{"$phonenum $line"}{$desc}{calls}++; + $lines{"$phonenum $line"}{$desc}{duration} += $detail->duration; + push @{$lines{"$phonenum $line"}{$desc}{ext_description}}, + $detail->formatted('format' => $format); + + } + } + + my %sectionmap = (); + my $simple = new FS::usage_class { format => 'simple' }; #bleh + foreach ( keys %sections ) { + my @header = @{ $sections{$_}{header} || [] }; + my $usage_simple = + new FS::usage_class { format => 'usage_'. (scalar(@header) || 6). 'col' }; + my $summary = $sections{$_}{sort_weight} < 0 ? 1 : 0; + my $usage_class = $summary ? $simple : $usage_simple; + my $ending = $summary ? ' usage charges' : ''; + my %gen_opt = (); + unless ($summary) { + $gen_opt{label} = [ map{ &{$escape}($_) } @header ]; + } + $sectionmap{$_} = { 'description' => &{$escape}($_. $ending), + 'amount' => $sections{$_}{amount}, #subtotal + 'calls' => $sections{$_}{calls}, + 'duration' => $sections{$_}{duration}, + 'summarized' => '', + 'tax_section' => '', + 'phonenum' => $sections{$_}{phonenum}, + 'sort_weight' => $sections{$_}{sort_weight}, + 'post_total' => $summary, #inspire pagebreak + ( + ( map { $_ => $usage_class->$_($format, %gen_opt) } + qw( description_generator + header_generator + total_generator + total_line_generator + ) + ) + ), + }; + } + + my @sections = sort { $a->{phonenum} cmp $b->{phonenum} || + $a->{sort_weight} <=> $b->{sort_weight} + } + values %sectionmap; + + my @lines = (); + foreach my $section ( keys %lines ) { + foreach my $line ( keys %{$lines{$section}} ) { + my $l = $lines{$section}{$line}; + $l->{section} = $sectionmap{$section}; + $l->{amount} = sprintf( "%.2f", $l->{amount} ); + #$l->{unit_amount} = sprintf( "%.2f", $l->{unit_amount} ); + push @lines, $l; + } + } + + return(\@sections, \@lines); } @@ -3227,9 +4202,11 @@ sub _items_previous { my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance my @b = (); foreach ( @pr_cust_bill ) { + my $date = $conf->exists('invoice_show_prior_due_date') + ? 'due '. $_->due_date2str($date_format) + : time2str($date_format, $_->_date); push @b, { - 'description' => 'Previous Balance, Invoice #'. $_->invnum. - ' ('. time2str('%x',$_->_date). ')', + 'description' => 'Previous Balance, Invoice #'. $_->invnum. " ($date)", #'pkgpart' => 'N/A', 'pkgnum' => 'N/A', 'amount' => sprintf("%.2f", $_->owed), @@ -3253,17 +4230,55 @@ sub _items_previous { sub _items_pkg { my $self = shift; + my %options = @_; + + warn "$me _items_pkg searching for all package line items\n" + if $DEBUG > 1; + my @cust_bill_pkg = grep { $_->pkgnum } $self->cust_bill_pkg; - $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_); + + warn "$me _items_pkg filtering line items\n" + if $DEBUG > 1; + my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_); + + if ($options{section} && $options{section}->{condensed}) { + + warn "$me _items_pkg condensing section\n" + if $DEBUG > 1; + + my %itemshash = (); + local $Storable::canonical = 1; + foreach ( @items ) { + my $item = { %$_ }; + delete $item->{ref}; + delete $item->{ext_description}; + my $key = freeze($item); + $itemshash{$key} ||= 0; + $itemshash{$key} ++; # += $item->{quantity}; + } + @items = sort { $a->{description} cmp $b->{description} } + map { my $i = thaw($_); + $i->{quantity} = $itemshash{$_}; + $i->{amount} = + sprintf( "%.2f", $i->{quantity} * $i->{amount} );#unit_amount + $i; + } + keys %itemshash; + } + + warn "$me _items_pkg returning ". scalar(@items). " items\n" + if $DEBUG > 1; + + @items; } sub _taxsort { - return 0 unless $a cmp $b; - return -1 if $b eq 'Tax'; - return 1 if $a eq 'Tax'; - return -1 if $b eq 'Other surcharges'; - return 1 if $a eq 'Other surcharges'; - $a cmp $b; + return 0 unless $a->itemdesc cmp $b->itemdesc; + return -1 if $b->itemdesc eq 'Tax'; + return 1 if $a->itemdesc eq 'Tax'; + return -1 if $b->itemdesc eq 'Other surcharges'; + return 1 if $a->itemdesc eq 'Other surcharges'; + $a->itemdesc cmp $b->itemdesc; } sub _items_tax { @@ -3274,7 +4289,7 @@ sub _items_tax { sub _items_cust_bill_pkg { my $self = shift; - my $cust_bill_pkg = shift; + my $cust_bill_pkgs = shift; my %opt = @_; my $format = $opt{format} || ''; @@ -3283,17 +4298,28 @@ sub _items_cust_bill_pkg { my $unsquelched = $opt{unsquelched} || ''; my $section = $opt{section}->{description} if $opt{section}; my $summary_page = $opt{summary_page} || ''; + my $multilocation = $opt{multilocation} || ''; + my $multisection = $opt{multisection} || ''; + my $discount_show_always = 0; my @b = (); my ($s, $r, $u) = ( undef, undef, undef ); - foreach my $cust_bill_pkg ( @$cust_bill_pkg ) + foreach my $cust_bill_pkg ( @$cust_bill_pkgs ) { - foreach ( $s, $r, $u ) { + warn "$me _items_cust_bill_pkg considering cust_bill_pkg $cust_bill_pkg\n" + if $DEBUG > 1; + + $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount + && $conf->exists('discount-show-always')); + + foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) { if ( $_ && !$cust_bill_pkg->hidden ) { $_->{amount} = sprintf( "%.2f", $_->{amount} ), + $_->{amount} =~ s/^\-0\.00$/0.00/; $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ), - push @b, { %$_ }; + push @b, { %$_ } + unless ( $_->{amount} == 0 && !$discount_show_always ); $_ = undef; } } @@ -3302,11 +4328,15 @@ sub _items_cust_bill_pkg { ? $_->section eq $section : 1 } - grep { $_->summary || !$summary_page } + #grep { !$_->summary || !$summary_page } # bunk! + grep { !$_->summary || $multisection } $cust_bill_pkg->cust_bill_pkg_display ) { + warn "$me _items_cust_bill_pkg considering display item $display\n" + if $DEBUG > 1; + my $type = $display->type; my $desc = $cust_bill_pkg->desc; @@ -3320,18 +4350,37 @@ sub _items_cust_bill_pkg { if ( $cust_bill_pkg->pkgnum > 0 ) { + warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n" + if $DEBUG > 1; + my $cust_pkg = $cust_bill_pkg->cust_pkg; if ( $cust_bill_pkg->setup != 0 && (!$type || $type eq 'S') ) { + warn "$me _items_cust_bill_pkg adding setup\n" + if $DEBUG > 1; + my $description = $desc; $description .= ' Setup' if $cust_bill_pkg->recur != 0; my @d = (); - push @d, map &{$escape_function}($_), - $cust_pkg->h_labels_short($self->_date) - unless $cust_pkg->part_pkg->hide_svc_detail - || $cust_bill_pkg->hidden; + unless ( $cust_pkg->part_pkg->hide_svc_detail + || $cust_bill_pkg->hidden ) + { + + push @d, map &{$escape_function}($_), + $cust_pkg->h_labels_short($self->_date, undef, 'I') + unless $cust_bill_pkg->pkgpart_override; #don't redisplay services + + if ( $multilocation ) { + my $loc = $cust_pkg->location_label; + $loc = substr($loc, 0, 50). '...' + if $format eq 'latex' && length($loc) > 50; + push @d, &{$escape_function}($loc); + } + + } + push @d, $cust_bill_pkg->details(%details_opt) if $cust_bill_pkg->recur == 0; @@ -3353,19 +4402,23 @@ sub _items_cust_bill_pkg { } - if ( $cust_bill_pkg->recur != 0 && + if ( ( $cust_bill_pkg->recur != 0 || $cust_bill_pkg->setup == 0 || + ($discount_show_always && $cust_bill_pkg->recur == 0) ) && ( !$type || $type eq 'R' || $type eq 'U' ) ) { + warn "$me _items_cust_bill_pkg adding recur/usage\n" + if $DEBUG > 1; + my $is_summary = $display->summary; my $description = ($is_summary && $type && $type eq 'U') ? "Usage charges" : $desc; - unless ( $conf->exists('disable_line_item_date_ranges') ) { - $description .= " (" . time2str("%x", $cust_bill_pkg->sdate). - " - ". time2str("%x", $cust_bill_pkg->edate). ")"; - } + $description .= " (" . time2str($date_format, $cust_bill_pkg->sdate). + " - ". time2str($date_format, $cust_bill_pkg->edate). + ")" + unless $conf->exists('disable_line_item_date_ranges'); my @d = (); @@ -3374,18 +4427,43 @@ sub _items_cust_bill_pkg { my @dates = ( $self->_date ); my $prev = $cust_bill_pkg->previous_cust_bill_pkg; push @dates, $prev->sdate if $prev; + push @dates, undef if !$prev; - push @d, map &{$escape_function}($_), - $cust_pkg->h_labels_short(@dates) - #$cust_bill_pkg->edate, - #$cust_bill_pkg->sdate) - unless $cust_pkg->part_pkg->hide_svc_detail + unless ( $cust_pkg->part_pkg->hide_svc_detail || $cust_bill_pkg->itemdesc || $cust_bill_pkg->hidden - || $is_summary && $type && $type eq 'U'; + || $is_summary && $type && $type eq 'U' ) + { + + warn "$me _items_cust_bill_pkg adding service details\n" + if $DEBUG > 1; + + push @d, map &{$escape_function}($_), + $cust_pkg->h_labels_short(@dates, 'I') + #$cust_bill_pkg->edate, + #$cust_bill_pkg->sdate) + unless $cust_bill_pkg->pkgpart_override; #don't redisplay services + + warn "$me _items_cust_bill_pkg done adding service details\n" + if $DEBUG > 1; + + if ( $multilocation ) { + my $loc = $cust_pkg->location_label; + $loc = substr($loc, 0, 50). '...' + if $format eq 'latex' && length($loc) > 50; + push @d, &{$escape_function}($loc); + } + + } + + warn "$me _items_cust_bill_pkg adding details\n" + if $DEBUG > 1; push @d, $cust_bill_pkg->details(%details_opt) unless ($is_summary || $type && $type eq 'R'); + + warn "$me _items_cust_bill_pkg calculating amount\n" + if $DEBUG > 1; my $amount = 0; if (!$type) { @@ -3398,6 +4476,9 @@ sub _items_cust_bill_pkg { if ( !$type || $type eq 'R' ) { + warn "$me _items_cust_bill_pkg adding recur\n" + if $DEBUG > 1; + if ( $cust_bill_pkg->hidden ) { $r->{amount} += $amount; $r->{unit_amount} += $cust_bill_pkg->unitrecur; @@ -3414,7 +4495,10 @@ sub _items_cust_bill_pkg { }; } - } elsif ( $amount ) { # && $type eq 'U' + } else { # $type eq 'U' + + warn "$me _items_cust_bill_pkg adding usage\n" + if $DEBUG > 1; if ( $cust_bill_pkg->hidden ) { $u->{amount} += $amount; @@ -3438,6 +4522,9 @@ sub _items_cust_bill_pkg { } else { #pkgnum tax or one-shot line item (??) + warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n" + if $DEBUG > 1; + if ( $cust_bill_pkg->setup != 0 ) { push @b, { 'description' => $desc, @@ -3447,8 +4534,8 @@ sub _items_cust_bill_pkg { if ( $cust_bill_pkg->recur != 0 ) { push @b, { 'description' => "$desc (". - time2str("%x", $cust_bill_pkg->sdate). ' - '. - time2str("%x", $cust_bill_pkg->edate). ')', + time2str($date_format, $cust_bill_pkg->sdate). ' - '. + time2str($date_format, $cust_bill_pkg->edate). ')', 'amount' => sprintf("%.2f", $cust_bill_pkg->recur), }; } @@ -3459,11 +4546,16 @@ sub _items_cust_bill_pkg { } - foreach ( $s, $r, $u ) { - if ( $_ ) { + warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n" + if $DEBUG > 1; + + foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) { + if ( $_ ) { $_->{amount} = sprintf( "%.2f", $_->{amount} ), + $_->{amount} =~ s/^\-0\.00$/0.00/; $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ), - push @b, { %$_ }; + push @b, { %$_ } + unless ( $_->{amount} == 0 && !$discount_show_always ); } } @@ -3490,7 +4582,7 @@ sub _items_credits { # " (". time2str("%x",$_->cust_credit->_date) .")". # $reason, 'description' => 'Credit applied '. - time2str("%x",$_->cust_credit->_date). $reason, + time2str($date_format,$_->cust_credit->_date). $reason, 'amount' => sprintf("%.2f",$_->amount), }; } @@ -3510,7 +4602,7 @@ sub _items_payments { push @b, { 'description' => "Payment received ". - time2str("%x",$_->cust_pay->_date ), + time2str($date_format,$_->cust_pay->_date ), 'amount' => sprintf("%.2f", $_->amount ) }; } @@ -3627,7 +4719,7 @@ sub re_X { my $distinct = ''; my $orderby = 'ORDER BY cust_bill._date'; - my $extra_sql = ' WHERE '. FS::cust_bill->search_sql(\%param); + my $extra_sql = ' WHERE '. FS::cust_bill->search_sql_where(\%param); my $addl_from = 'LEFT JOIN cust_main USING ( custnum )'; @@ -3678,8 +4770,10 @@ Returns an SQL fragment to retreive the amount owed (charged minus credited and =cut sub owed_sql { - my $class = shift; - 'charged - '. $class->paid_sql. ' - '. $class->credited_sql; + my ($class, $start, $end) = @_; + 'charged - '. + $class->paid_sql($start, $end). ' - '. + $class->credited_sql($start, $end); } =item net_sql @@ -3689,8 +4783,8 @@ Returns an SQL fragment to retreive the net amount (charged minus credited). =cut sub net_sql { - my $class = shift; - 'charged - '. $class->credited_sql; + my ($class, $start, $end) = @_; + 'charged - '. $class->credited_sql($start, $end); } =item paid_sql @@ -3700,9 +4794,13 @@ Returns an SQL fragment to retreive the amount paid against this invoice. =cut sub paid_sql { - #my $class = shift; + my ($class, $start, $end) = @_; + $start &&= "AND cust_bill_pay._date <= $start"; + $end &&= "AND cust_bill_pay._date > $end"; + $start = '' unless defined($start); + $end = '' unless defined($end); "( SELECT COALESCE(SUM(amount),0) FROM cust_bill_pay - WHERE cust_bill.invnum = cust_bill_pay.invnum )"; + WHERE cust_bill.invnum = cust_bill_pay.invnum $start $end )"; } =item credited_sql @@ -3712,25 +4810,44 @@ Returns an SQL fragment to retreive the amount credited against this invoice. =cut sub credited_sql { - #my $class = shift; + my ($class, $start, $end) = @_; + $start &&= "AND cust_credit_bill._date <= $start"; + $end &&= "AND cust_credit_bill._date > $end"; + $start = '' unless defined($start); + $end = '' unless defined($end); "( SELECT COALESCE(SUM(amount),0) FROM cust_credit_bill - WHERE cust_bill.invnum = cust_credit_bill.invnum )"; + WHERE cust_bill.invnum = cust_credit_bill.invnum $start $end )"; +} + +=item due_date_sql + +Returns an SQL fragment to retrieve the due date of an invoice. +Currently only supported on PostgreSQL. + +=cut + +sub due_date_sql { +'COALESCE( + SUBSTRING( + COALESCE( + cust_bill.invoice_terms, + cust_main.invoice_terms, + \''.($conf->config('invoice_default_terms') || '').'\' + ), E\'Net (\\\\d+)\' + )::INTEGER, 0 +) * 86400 + cust_bill._date' } -=item search_sql HASHREF +=item search_sql_where HASHREF Class method which returns an SQL WHERE fragment to search for parameters specified in HASHREF. Valid parameters are =over 4 -=item begin - -Epoch date (UNIX timestamp) setting a lower bound for _date values - -=item end +=item _date -Epoch date (UNIX timestamp) setting an upper bound for _date values +List reference of start date, end date, as UNIX timestamps. =item invnum_min @@ -3738,10 +4855,22 @@ Epoch date (UNIX timestamp) setting an upper bound for _date values =item agentnum +=item charged + +List reference of charged limits (exclusive). + =item owed +List reference of charged limits (exclusive). + +=item open + +flag, return open invoices only + =item net +flag, return net invoices only + =item days =item newest_percust @@ -3752,40 +4881,68 @@ Note: validates all passed-in data; i.e. safe to use with unchecked CGI params. =cut -sub search_sql { +sub search_sql_where { my($class, $param) = @_; if ( $DEBUG ) { - warn "$me search_sql called with params: \n". + warn "$me search_sql_where called with params: \n". join("\n", map { " $_: ". $param->{$_} } keys %$param ). "\n"; } my @search = (); - if ( $param->{'begin'} =~ /^(\d+)$/ ) { - push @search, "cust_bill._date >= $1"; + #agentnum + if ( $param->{'agentnum'} =~ /^(\d+)$/ ) { + push @search, "cust_main.agentnum = $1"; } - if ( $param->{'end'} =~ /^(\d+)$/ ) { - push @search, "cust_bill._date < $1"; + + #_date + if ( $param->{_date} ) { + my($beginning, $ending) = @{$param->{_date}}; + + push @search, "cust_bill._date >= $beginning", + "cust_bill._date < $ending"; } + + #invnum if ( $param->{'invnum_min'} =~ /^(\d+)$/ ) { push @search, "cust_bill.invnum >= $1"; } if ( $param->{'invnum_max'} =~ /^(\d+)$/ ) { push @search, "cust_bill.invnum <= $1"; } - if ( $param->{'agentnum'} =~ /^(\d+)$/ ) { - push @search, "cust_main.agentnum = $1"; + + #charged + if ( $param->{charged} ) { + my @charged = ref($param->{charged}) + ? @{ $param->{charged} } + : ($param->{charged}); + + push @search, map { s/^charged/cust_bill.charged/; $_; } + @charged; } - push @search, '0 != '. FS::cust_bill->owed_sql - if $param->{'open'}; + my $owed_sql = FS::cust_bill->owed_sql; + + #owed + if ( $param->{owed} ) { + my @owed = ref($param->{owed}) + ? @{ $param->{owed} } + : ($param->{owed}); + push @search, map { s/^owed/$owed_sql/; $_; } + @owed; + } + #open/net flags + push @search, "0 != $owed_sql" + if $param->{'open'}; push @search, '0 != '. FS::cust_bill->net_sql if $param->{'net'}; + #days push @search, "cust_bill._date < ". (time-86400*$param->{'days'}) if $param->{'days'}; + #newest_percust if ( $param->{'newest_percust'} ) { #$distinct = 'DISTINCT ON ( cust_bill.custnum )'; @@ -3809,6 +4966,7 @@ sub search_sql { } + #agent virtualization my $curuser = $FS::CurrentUser::CurrentUser; if ( $curuser->username eq 'fs_queue' && $param->{'CurrentUser'} =~ /^(\w+)$/ ) { @@ -3823,7 +4981,6 @@ sub search_sql { warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n"; } } - push @search, $curuser->agentnums_sql; join(' AND ', @search );