X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=ca35a443aab909645fcf98a0eba036c16aa77c22;hp=e7c0be89295f38901b503fcb597378bcba401ec6;hb=f9a181e4c2e505df84de16190ee3b75011326f3f;hpb=06187016fd78ba41fb82846b9e8d461405f316b6 diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index e7c0be892..ca35a443a 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 @@ -96,6 +106,18 @@ L and L for conversion functions. =item charged - amount of this invoice +=item invoice_terms - optional terms override for this specific invoice + +=back + +Customer info at invoice generation time + +=over 4 + +=item previous_balance + +=item billing_balance + =back Deprecated @@ -144,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 @@ -206,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 @@ -223,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, @@ -340,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. @@ -521,6 +624,7 @@ Returns all payment applications (see L) for this invoice. sub cust_bill_pay { my $self = shift; + map { $_ } #return $self->num_cust_bill_pay unless wantarray; sort { $a->_date <=> $b->_date } qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum } ); } @@ -535,6 +639,7 @@ Returns all applied credits (see L) for this invoice. sub cust_credited { my $self = shift; + map { $_ } #return $self->num_cust_credit_bill unless wantarray; sort { $a->_date <=> $b->_date } qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum } ) ; @@ -553,6 +658,7 @@ with matching pkgnum. sub cust_bill_pay_pkgnum { my( $self, $pkgnum ) = @_; + map { $_ } #return $self->num_cust_bill_pay_pkgnum($pkgnum) unless wantarray; sort { $a->_date <=> $b->_date } qsearch( 'cust_bill_pay', { 'invnum' => $self->invnum, 'pkgnum' => $pkgnum, @@ -562,6 +668,8 @@ sub cust_bill_pay_pkgnum { =item cust_credited_pkgnum PKGNUM +=item cust_credit_bill_pkgnum PKGNUM + Returns all applied credits (see L) for this invoice with matching pkgnum. @@ -569,6 +677,7 @@ with matching pkgnum. sub cust_credited_pkgnum { my( $self, $pkgnum ) = @_; + map { $_ } #return $self->num_cust_credit_bill_pkgnum($pkgnum) unless wantarray; sort { $a->_date <=> $b->_date } qsearch( 'cust_credit_bill', { 'invnum' => $self->invnum, 'pkgnum' => $pkgnum, @@ -576,6 +685,10 @@ sub cust_credited_pkgnum { ); } +sub cust_credit_bill_pkgnum { + shift->cust_credited_pkgnum(@_); +} + =item tax Returns the tax amount (see L) for this invoice. @@ -876,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', @@ -949,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'; @@ -1065,7 +1196,7 @@ sub send { } $invoice_from = $opt->{'invoice_from'}; $balance_over = $opt->{'balance_over'} if $opt->{'balance_over'}; - $notice_name = $opt=>{'notice_name'}; + $notice_name = $opt->{'notice_name'}; } else { $template = scalar(@_) ? shift : ''; if ( scalar(@_) && $_[0] ) { @@ -1159,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); @@ -1261,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 ] @@ -1307,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. @@ -1847,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; @@ -1873,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... ); } @@ -2002,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', @@ -2013,8 +2205,37 @@ 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 ... @@ -2045,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 ) = @_; @@ -2066,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"; @@ -2083,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"; @@ -2179,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(). '}' @@ -2198,6 +2433,14 @@ sub print_generic { ); my $embolden_function = $embolden_functions{$format}; + my %newline_tokens = ( 'latex' => '\\\\', + 'html' => '
', + 'template' => "\n", + ); + my $newline_token = $newline_tokens{$format}; + + warn "$me generating template variables\n" + if $DEBUG > 1; # generate template variables my $returnaddress; @@ -2251,38 +2494,79 @@ sub print_generic { } + warn "$me generating invoice data\n" + if $DEBUG > 1; + + my $agentnum = $self->cust_main->agentnum; + my %invoice_data = ( - 'company_name' => scalar( $conf->config('company_name', $self->cust_main->agentnum) ), - 'company_address' => join("\n", $conf->config('company_address', $self->cust_main->agentnum) ). "\n", - 'custnum' => $cust_main->display_custnum, + + #invoice from info + '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), - 'agent' => &$escape_function($cust_main->agent->agent), - 'agent_custid' => &$escape_function($cust_main->agent_custid), - 'payname' => &$escape_function($cust_main->payname), - 'company' => &$escape_function($cust_main->company), - 'address1' => &$escape_function($cust_main->address1), - 'address2' => &$escape_function($cust_main->address2), - 'city' => &$escape_function($cust_main->city), - 'state' => &$escape_function($cust_main->state), - 'zip' => &$escape_function($cust_main->zip), - 'fax' => &$escape_function($cust_main->fax), - 'returnaddress' => $returnaddress, - #'quantity' => 1, + 'today' => time2str($date_format_long, $today), 'terms' => $self->terms, 'template' => $template, #params{'template'}, - #'notes' => join("\n", $conf->config('invoice_latexnotes') ), - # better hang on to conf_dir for a while - 'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc", - 'page' => 1, - 'total_pages' => 1, + '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, + 'agent_custid' => &$escape_function($cust_main->agent_custid), + ( map { $_ => &$escape_function($cust_main->$_()) } qw( + payname company address1 address2 city state zip fax + )), + + #global config 'ship_enable' => $conf->exists('invoice-ship_address'), 'unitprices' => $conf->exists('invoice-unitprice'), - 'notice_name' => ($params{'notice_name'} || 'Invoice'),#escape_function? + '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", + + #these are only used when doing paged plaintext + 'page' => 1, + 'total_pages' => 1, + ); + + my $min_sdate = 999999999999; + my $max_edate = 0; + foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { + next unless $cust_bill_pkg->pkgnum > 0; + $min_sdate = $cust_bill_pkg->sdate + if length($cust_bill_pkg->sdate) && $cust_bill_pkg->sdate < $min_sdate; + $max_edate = $cust_bill_pkg->edate + if length($cust_bill_pkg->edate) && $cust_bill_pkg->edate > $max_edate; + } + + $invoice_data{'bill_period'} = ''; + $invoice_data{'bill_period'} = time2str('%e %h', $min_sdate) + . " to " . time2str('%e %h', $max_edate) + if ($max_edate != 0 && $min_sdate != 999999999999); $invoice_data{finance_section} = ''; if ( $conf->config('finance_pkgclass') ) { @@ -2290,7 +2574,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_' : ''; @@ -2333,25 +2618,31 @@ 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 #my $balance_due = $self->owed + $pr_total - $cr_total; my $balance_due = $self->owed + $pr_total; - $invoice_data{'true_previous_balance'} = sprintf("%.2f", $self->previous_balance); - $invoice_data{'balance_adjustments'} = sprintf("%.2f", $self->previous_balance - $self->billing_balance); + $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) ); + $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) ); $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); @@ -2422,30 +2713,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' => '' }; } @@ -2455,6 +2786,9 @@ sub print_generic { ) { + warn "$me adding previous balances\n" + if $DEBUG > 1; + foreach my $line_item ( $self->_items_previous ) { my $detail = { @@ -2487,9 +2821,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} ); @@ -2498,12 +2854,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; @@ -2511,8 +2876,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 => [], }; @@ -2552,10 +2928,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'}; @@ -2619,32 +3000,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,['','']; } @@ -2715,7 +3098,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}; } { @@ -2729,7 +3113,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{ @@ -2739,9 +3123,41 @@ sub print_generic { push @buf,[$self->balance_due_msg, $money_char. sprintf("%10.2f", $balance_due ) ]; } + + if ( $conf->exists('previous_balance-show_credit') + and $cust_main->balance < 0 ) { + my $credit_total = { + 'total_item' => &$embolden_function($self->credit_balance_msg), + 'total_amount' => &$embolden_function( + $other_money_char. sprintf('%.2f', -$cust_main->balance) + ), + }; + if ( $multisection ) { + $adjust_section->{'posttotal'} .= $newline_token . + $credit_total->{'total_item'} . ' ' . $credit_total->{'total_amount'}; + } + else { + push @total_items, $credit_total; + } + push @buf,['','-----------']; + push @buf,[$self->credit_balance_msg, $money_char. + sprintf("%10.2f", -$cust_main->balance ) ]; + } } 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; } @@ -2839,6 +3255,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. @@ -2858,9 +3290,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; } @@ -2886,9 +3319,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; } @@ -2923,7 +3357,7 @@ sub print_html { } $params{'format'} = 'html'; - + $self->print_generic( %params ); } @@ -2943,6 +3377,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 { @@ -2972,10 +3418,10 @@ sub _translate_old_latex_format { $line_item_line =~ s/\$(\w+)/'. \$_tr_line->{$1}. '/g; push @template, " \$OUT .= '$line_item_line';"; } - + push @template, '}', '--@]'; - + #' doh, gvim } elsif ( $line =~ /^%%TotalDetails\s*$/ ) { push @template, '[@--', @@ -3009,11 +3455,12 @@ sub _translate_old_latex_format { sub terms { my $self = shift; - #check for an invoice- specific override (eventually) + #check for an invoice-specific override + return $self->invoice_terms if $self->invoice_terms; #check for a customer- specific override - return $self->cust_main->invoice_terms - if $self->cust_main->invoice_terms; + my $cust_main = $self->cust_main; + return $cust_main->invoice_terms if $cust_main->invoice_terms; #use configured default $conf->config('invoice_default_terms') || ''; @@ -3038,7 +3485,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; } @@ -3050,11 +3497,13 @@ 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; } +sub credit_balance_msg { 'Credit Balance Remaining' } + =item invnum_date_pretty Returns a string with the invoice number and date, for example: @@ -3075,73 +3524,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); } } @@ -3150,27 +3605,699 @@ 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; + } + } + + if($conf->exists('phone_usage_class_summary')) { + # this only works with Latex + my @newlines; + my @newsections; + + # after this, we'll have only two sections per DID: + # Calls Summary and Calls Detail + foreach my $section ( @sections ) { + if($section->{'post_total'}) { + $section->{'description'} = 'Calls Summary: '.$section->{'phonenum'}; + $section->{'total_line_generator'} = sub { '' }; + $section->{'total_generator'} = sub { '' }; + $section->{'header_generator'} = sub { '' }; + $section->{'description_generator'} = ''; + push @newsections, $section; + my %calls_detail = %$section; + $calls_detail{'post_total'} = ''; + $calls_detail{'sort_weight'} = ''; + $calls_detail{'description_generator'} = sub { '' }; + $calls_detail{'header_generator'} = sub { + return ' & Date/Time & Called Number & Duration & Price' + if $format eq 'latex'; + ''; + }; + $calls_detail{'description'} = 'Calls Detail: ' + . $section->{'phonenum'}; + push @newsections, \%calls_detail; + } + } + + # after this, each usage class is collapsed/summarized into a single + # line under the Calls Summary section + foreach my $newsection ( @newsections ) { + if($newsection->{'post_total'}) { # this means Calls Summary + foreach my $section ( @sections ) { + next unless ($section->{'phonenum'} eq $newsection->{'phonenum'} + && !$section->{'post_total'}); + my $newdesc = $section->{'description'}; + my $tn = $section->{'phonenum'}; + $newdesc =~ s/$tn//g; + my $line = { ext_description => [], + pkgnum => '', + ref => '', + quantity => '', + calls => $section->{'calls'}, + section => $newsection, + duration => $section->{'duration'}, + description => $newdesc, + amount => sprintf("%.2f",$section->{'amount'}), + product_code => 'N/A', + }; + push @newlines, $line; + } + } + } + + # after this, Calls Details is populated with all CDRs + foreach my $newsection ( @newsections ) { + if(!$newsection->{'post_total'}) { # this means Calls Details + foreach my $line ( @lines ) { + next unless (scalar(@{$line->{'ext_description'}}) && + $line->{'section'}->{'phonenum'} eq $newsection->{'phonenum'} + ); + my @extdesc = @{$line->{'ext_description'}}; + my @newextdesc; + foreach my $extdesc ( @extdesc ) { + $extdesc =~ s/scriptsize/normalsize/g if $format eq 'latex'; + push @newextdesc, $extdesc; + } + $line->{'ext_description'} = \@newextdesc; + $line->{'section'} = $newsection; + push @newlines, $line; + } + } + } + + return(\@newsections, \@newlines); + } + + return(\@sections, \@lines); } @@ -3197,9 +4324,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), @@ -3223,17 +4352,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 { @@ -3244,7 +4411,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} || ''; @@ -3253,17 +4420,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; } } @@ -3272,11 +4450,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; @@ -3290,18 +4472,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; @@ -3323,19 +4524,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 = (); @@ -3344,30 +4549,64 @@ 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); + } - push @d, $cust_bill_pkg->details(%details_opt) - unless ($is_summary || $type && $type eq 'R'); + } + + unless ( $is_summary ) { + warn "$me _items_cust_bill_pkg adding details\n" + if $DEBUG > 1; + + #instead of omitting details entirely in this case (unwanted side + # effects), just omit CDRs + $details_opt{'format_function'} = sub { () } + if $type && $type eq 'R'; + + push @d, $cust_bill_pkg->details(%details_opt); + } + + warn "$me _items_cust_bill_pkg calculating amount\n" + if $DEBUG > 1; my $amount = 0; if (!$type) { $amount = $cust_bill_pkg->recur; - }elsif($type eq 'R') { + } elsif ($type eq 'R') { $amount = $cust_bill_pkg->recur - $cust_bill_pkg->usage; - }elsif($type eq 'U') { + } elsif ($type eq 'U') { $amount = $cust_bill_pkg->usage; } 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; @@ -3384,7 +4623,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; @@ -3408,6 +4650,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, @@ -3417,8 +4662,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), }; } @@ -3429,11 +4674,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 ); } } @@ -3460,7 +4710,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), }; } @@ -3480,7 +4730,7 @@ sub _items_payments { push @b, { 'description' => "Payment received ". - time2str("%x",$_->cust_pay->_date ), + time2str($date_format,$_->cust_pay->_date ), 'amount' => sprintf("%.2f", $_->amount ) }; } @@ -3597,7 +4847,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 )'; @@ -3648,8 +4898,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 @@ -3659,8 +4911,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 @@ -3670,9 +4922,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 @@ -3682,25 +4938,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 search_sql HASHREF +=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_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 _date -=item end - -Epoch date (UNIX timestamp) setting an upper bound for _date values +List reference of start date, end date, as UNIX timestamps. =item invnum_min @@ -3708,10 +4983,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 @@ -3722,40 +5009,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 )'; @@ -3779,6 +5094,7 @@ sub search_sql { } + #agent virtualization my $curuser = $FS::CurrentUser::CurrentUser; if ( $curuser->username eq 'fs_queue' && $param->{'CurrentUser'} =~ /^(\w+)$/ ) { @@ -3793,7 +5109,6 @@ sub search_sql { warn "$me WARNING: (fs_queue) can't find CurrentUser $username\n"; } } - push @search, $curuser->agentnums_sql; join(' AND ', @search );