X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=35ce48c35ebfe665d4a2414f9528c61e7aae090f;hp=bb392e83d23732ecc59ac04a1459a32a61ec5ada;hb=395cc72629d31c8dcd138acf423e66d2d73d89d2;hpb=b32f7f99fb92d3ffc5085d3abff45d48f77870cb diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index bb392e83d..35ce48c35 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,17 +1,22 @@ package FS::cust_bill; use strict; -use vars qw( @ISA $DEBUG $me $conf $money_char $date_format $rdate_format ); +use vars qw( @ISA $DEBUG $me + $money_char $date_format $rdate_format $date_format_long ); + # but NOT $conf use vars qw( $invoice_lines @buf ); #yuck use Fcntl qw(:flock); #for spool_csv -use List::Util qw(min max); +use Cwd; +use List::Util qw(min max sum); use Date::Format; +use Date::Language; use Text::Template 1.20; 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 ); @@ -36,6 +41,10 @@ use FS::part_bill_event; use FS::payby; use FS::bill_batch; use FS::cust_bill_batch; +use FS::cust_bill_pay_pkg; +use FS::cust_credit_bill_pkg; +use FS::discount_plan; +use FS::L10N; @ISA = qw( FS::cust_main_Mixin FS::Record ); @@ -44,10 +53,11 @@ $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') || '$'; - $date_format = $conf->config('date_format') || '%x'; - $rdate_format = $conf->config('date_format') || '%m/%d/%Y'; + my $conf = new FS::Conf; #global + $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 @@ -134,6 +144,8 @@ Specific use cases =item agent_invid - legacy invoice number +=item promised_date - customer promised payment date, for collection + =back =head1 METHODS @@ -162,6 +174,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 @@ -195,11 +246,11 @@ sub delete { cust_event cust_credit_bill cust_bill_pay - cust_bill_pay cust_credit_bill cust_pay_batch cust_bill_pay_batch cust_bill_pkg + cust_bill_batch )) { foreach my $linked ( $self->$table() ) { @@ -224,13 +275,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 @@ -241,15 +292,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, @@ -289,6 +369,7 @@ cust_bill-default_agent_invid is set and it has a value, invnum otherwise. sub display_invnum { my $self = shift; + my $conf = $self->conf; if ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){ return $self->agent_invid; } else { @@ -577,44 +658,109 @@ sub cust_credit_bill { shift->cust_credited(@_); } -=item cust_bill_pay_pkgnum PKGNUM +#=item cust_bill_pay_pkgnum PKGNUM +# +#Returns all payment applications (see L) for this invoice +#with matching pkgnum. +# +#=cut +# +#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, +# } +# ); +#} + +=item cust_bill_pay_pkg PKGNUM Returns all payment applications (see L) for this invoice -with matching pkgnum. +applied against the matching pkgnum. =cut -sub cust_bill_pay_pkgnum { +sub cust_bill_pay_pkg { 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, - } - ); + + qsearch({ + 'select' => 'cust_bill_pay_pkg.*', + 'table' => 'cust_bill_pay_pkg', + 'addl_from' => ' LEFT JOIN cust_bill_pay USING ( billpaynum ) '. + ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ', + 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum. + " AND cust_bill_pkg.pkgnum = $pkgnum", + }); + } -=item cust_credited_pkgnum PKGNUM +#=item cust_credited_pkgnum PKGNUM +# +#=item cust_credit_bill_pkgnum PKGNUM +# +#Returns all applied credits (see L) for this invoice +#with matching pkgnum. +# +#=cut +# +#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, +# } +# ); +#} +# +#sub cust_credit_bill_pkgnum { +# shift->cust_credited_pkgnum(@_); +#} -=item cust_credit_bill_pkgnum PKGNUM +=item cust_credit_bill_pkg PKGNUM -Returns all applied credits (see L) for this invoice -with matching pkgnum. +Returns all credit applications (see L) for this invoice +applied against the matching pkgnum. =cut -sub cust_credited_pkgnum { +sub cust_credit_bill_pkg { 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, - } - ); + + qsearch({ + 'select' => 'cust_credit_bill_pkg.*', + 'table' => 'cust_credit_bill_pkg', + 'addl_from' => ' LEFT JOIN cust_credit_bill USING ( creditbillnum ) '. + ' LEFT JOIN cust_bill_pkg USING ( billpkgnum ) ', + 'extra_sql' => ' WHERE cust_bill_pkg.invnum = '. $self->invnum. + " AND cust_bill_pkg.pkgnum = $pkgnum", + }); + } -sub cust_credit_bill_pkgnum { - shift->cust_credited_pkgnum(@_); +=item cust_bill_batch + +Returns all invoice batch records (L) for this invoice. + +=cut + +sub cust_bill_batch { + my $self = shift; + qsearch('cust_bill_batch', { 'invnum' => $self->invnum }); +} + +=item discount_plans + +Returns all discount plans (L) for this invoice, as a +hash keyed by term length. + +=cut + +sub discount_plans { + my $self = shift; + FS::discount_plan->all($self); } =item tax @@ -657,14 +803,31 @@ sub owed_pkgnum { my $balance = 0; $balance += $_->setup + $_->recur for $self->cust_bill_pkg_pkgnum($pkgnum); - $balance -= $_->amount for $self->cust_bill_pay_pkgnum($pkgnum); - $balance -= $_->amount for $self->cust_credited_pkgnum($pkgnum); + $balance -= $_->amount for $self->cust_bill_pay_pkg($pkgnum); + $balance -= $_->amount for $self->cust_credit_bill_pkg($pkgnum); $balance = sprintf( "%.2f", $balance); $balance =~ s/^\-0\.00$/0.00/; #yay ieee fp $balance; } +=item hide + +Returns true if this invoice should be hidden. See the +selfservice-hide_invoices-taxclass configuraiton setting. + +=cut + +sub hide { + my $self = shift; + my $conf = $self->conf; + my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass') + or return ''; + my @cust_bill_pkg = $self->cust_bill_pkg; + my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg; + ! grep { $_->taxclass ne $hide_taxclass } @part_pkg; +} + =item apply_payments_and_credits [ OPTION => VALUE ... ] Applies unapplied payments and credits to this invoice. @@ -679,6 +842,7 @@ If there is an error, returns the error, otherwise returns false. sub apply_payments_and_credits { my( $self, %options ) = @_; + my $conf = $self->conf; local $SIG{HUP} = 'IGNORE'; local $SIG{INT} = 'IGNORE'; @@ -827,6 +991,7 @@ sub generate_email { my $self = shift; my %args = @_; + my $conf = $self->conf; my $me = '[FS::cust_bill::generate_email]'; @@ -839,6 +1004,7 @@ sub generate_email { 'unsquelch_cdr' => $conf->exists('voip-cdr_email'), 'template' => $args{'template'}, 'notice_name' => ( $args{'notice_name'} || 'Invoice' ), + 'no_coupon' => $args{'no_coupon'}, ); my $cust_main = $self->cust_main; @@ -860,7 +1026,7 @@ sub generate_email { my $alternative = build MIME::Entity 'Type' => 'multipart/alternative', - 'Encoding' => '7bit', + #'Encoding' => '7bit', 'Disposition' => 'inline' ; @@ -888,35 +1054,61 @@ sub generate_email { $alternative->attach( 'Type' => 'text/plain', - #'Encoding' => 'quoted-printable', - 'Encoding' => '7bit', + 'Encoding' => 'quoted-printable', + #'Encoding' => '7bit', 'Data' => $data, 'Disposition' => 'inline', ); - $args{'from'} =~ /\@([\w\.\-]+)/; - my $from = $1 || 'example.com'; - my $content_id = join('.', rand()*(2**32), $$, time). "\@$from"; - my $logo; - my $agentnum = $cust_main->agentnum; - if ( defined($args{'template'}) && length($args{'template'}) - && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum ) - ) - { - $logo = 'logo_'. $args{'template'}. '.png'; + my $htmldata; + my $image = ''; + my $barcode = ''; + if ( $conf->exists('invoice_email_pdf') + and scalar($conf->config('invoice_email_pdf_note')) ) { + + $htmldata = join('
', $conf->config('invoice_email_pdf_note') ); + } else { - $logo = "logo.png"; + + $args{'from'} =~ /\@([\w\.\-]+)/; + my $from = $1 || 'example.com'; + my $content_id = join('.', rand()*(2**32), $$, time). "\@$from"; + + my $logo; + my $agentnum = $cust_main->agentnum; + if ( defined($args{'template'}) && length($args{'template'}) + && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum ) + ) + { + $logo = 'logo_'. $args{'template'}. '.png'; + } else { + $logo = "logo.png"; + } + my $image_data = $conf->config_binary( $logo, $agentnum); + + $image = build MIME::Entity + 'Type' => 'image/png', + 'Encoding' => 'base64', + 'Data' => $image_data, + 'Filename' => 'logo.png', + 'Content-ID' => "<$content_id>", + ; + + 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; + } + + $htmldata = $self->print_html({ 'cid'=>$content_id, %opt }); } - my $image_data = $conf->config_binary( $logo, $agentnum); - - my $image = build MIME::Entity - 'Type' => 'image/png', - 'Encoding' => 'base64', - 'Data' => $image_data, - 'Filename' => 'logo.png', - 'Content-ID' => "<$content_id>", - ; $alternative->attach( 'Type' => 'text/html', @@ -928,7 +1120,7 @@ sub generate_email { ' ', ' ', ' ', - $self->print_html({ 'cid'=>$content_id, %opt }), + $htmldata, ' ', '', ], @@ -936,6 +1128,7 @@ sub generate_email { #'Filename' => 'invoice.pdf', ); + my @otherparts = (); if ( $cust_main->email_csv_cdr ) { @@ -974,7 +1167,7 @@ sub generate_email { $related->add_part($alternative); - $related->add_part($image); + $related->add_part($image) if $image; my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt); @@ -990,7 +1183,11 @@ sub generate_email { # image/png $return{'content-type'} = 'multipart/related'; - $return{'mimeparts'} = [ $alternative, $image, @otherparts ]; + if ($conf->exists('invoice-barcode') && $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'; @@ -1093,6 +1290,7 @@ sub queueable_send { sub send { my $self = shift; + my $conf = $self->conf; my( $template, $invoice_from, $notice_name ); my $agentnums = ''; @@ -1169,11 +1367,12 @@ sub queueable_email { my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } ) or die "invalid invoice number: " . $opt{invnum}; - my @args = ( $opt{template} ); - push @args, $opt{invoice_from} - if exists($opt{invoice_from}) && $opt{invoice_from}; + my %args = ( 'template' => $opt{template} ); + $args{$_} = $opt{$_} + foreach grep { exists($opt{$_}) && $opt{$_} } + qw( invoice_from notice_name no_coupon ); - my $error = $self->email( @args ); + my $error = $self->email( \%args ); die $error if $error; } @@ -1181,17 +1380,21 @@ sub queueable_email { #sub email_invoice { sub email { my $self = shift; + return if $self->hide; + my $conf = $self->conf; - my( $template, $invoice_from, $notice_name ); + my( $template, $invoice_from, $notice_name, $no_coupon ); if ( ref($_[0]) ) { my $opt = shift; $template = $opt->{'template'} || ''; $invoice_from = $opt->{'invoice_from'}; $notice_name = $opt->{'notice_name'} || 'Invoice'; + $no_coupon = $opt->{'no_coupon'} || 0; } else { $template = scalar(@_) ? shift : ''; $invoice_from = shift if scalar(@_); $notice_name = 'Invoice'; + $no_coupon = 0; } $invoice_from ||= $self->_agent_invoice_from || #XXX should go away @@ -1200,8 +1403,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); @@ -1212,6 +1421,7 @@ sub email { 'subject' => $subject, 'template' => $template, 'notice_name' => $notice_name, + 'no_coupon' => $no_coupon, ) ); die "can't email invoice: $error\n" if $error; @@ -1221,6 +1431,7 @@ sub email { sub email_subject { my $self = shift; + my $conf = $self->conf; #my $template = scalar(@_) ? shift : ''; #per-template? @@ -1252,6 +1463,7 @@ I, if specified, overrides "Invoice" as the name of the sent docume sub lpr_data { my $self = shift; + my $conf = $self->conf; my( $template, $notice_name ); if ( ref($_[0]) ) { my $opt = shift; @@ -1287,6 +1499,9 @@ I, if specified, overrides "Invoice" as the name of the sent docume #sub print_invoice { sub print { my $self = shift; + return if $self->hide; + my $conf = $self->conf; + my( $template, $notice_name ); if ( ref($_[0]) ) { my $opt = shift; @@ -1326,6 +1541,9 @@ I, if specified, overrides "Invoice" as the name of the sent docume sub fax_invoice { my $self = shift; + return if $self->hide; + my $conf = $self->conf; + my( $template, $notice_name ); if ( ref($_[0]) ) { my $opt = shift; @@ -1363,14 +1581,37 @@ isn't an open batch, one will be created. sub batch_invoice { my ($self, $opt) = @_; - my $batch = FS::bill_batch->get_open_batch; + my $bill_batch = $self->get_open_bill_batch; my $cust_bill_batch = FS::cust_bill_batch->new({ - batchnum => $batch->batchnum, + batchnum => $bill_batch->batchnum, invnum => $self->invnum, }); return $cust_bill_batch->insert($opt); } +=item get_open_batch + +Returns the currently open batch as an FS::bill_batch object, creating a new +one if necessary. (A per-agent batch if invoice_print_pdf-spoolagent is +enabled) + +=cut + +sub get_open_bill_batch { + my $self = shift; + my $conf = $self->conf; + my $hashref = { status => 'O' }; + $hashref->{'agentnum'} = $conf->exists('invoice_print_pdf-spoolagent') + ? $self->cust_main->agentnum + : ''; + my $batch = qsearchs('bill_batch', $hashref); + return $batch if $batch; + $batch = FS::bill_batch->new($hashref); + my $error = $batch->insert; + die $error if $error; + return $batch; +} + =item ftp_invoice [ TEMPLATENAME ] Sends this invoice data via FTP. @@ -1381,6 +1622,7 @@ TEMPLATENAME is unused? sub ftp_invoice { my $self = shift; + my $conf = $self->conf; my $template = scalar(@_) ? shift : ''; $self->send_csv( @@ -1403,6 +1645,7 @@ TEMPLATENAME is unused? sub spool_invoice { my $self = shift; + my $conf = $self->conf; my $template = scalar(@_) ? shift : ''; $self->spool_csv( @@ -1911,7 +2154,9 @@ sub realtime_lec { } sub realtime_bop { - my( $self, $method ) = @_; + my( $self, $method ) = (shift,shift); + my $conf = $self->conf; + my %opt = @_; my $cust_main = $self->cust_main; my $balance = $cust_main->balance; @@ -1940,6 +2185,7 @@ sub realtime_bop { #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 @@ -2009,7 +2255,7 @@ sub print_text { $params{'time'} = $today if $today; $params{'template'} = $template if $template; $params{$_} = $opt{$_} - foreach grep $opt{$_}, qw( unsquealch_cdr notice_name ); + foreach grep $opt{$_}, qw( unsquelch_cdr notice_name ); $self->print_generic( %params ); } @@ -2038,6 +2284,7 @@ I, if specified, overrides "Invoice" as the name of the sent docume sub print_latex { my $self = shift; + my $conf = $self->conf; my( $today, $template, %opt ); if ( ref($_[0]) ) { %opt = %{ shift() }; @@ -2051,7 +2298,7 @@ sub print_latex { $params{'time'} = $today if $today; $params{'template'} = $template if $template; $params{$_} = $opt{$_} - foreach grep $opt{$_}, qw( unsquealch_cdr notice_name ); + foreach grep $opt{$_}, qw( unsquelch_cdr notice_name ); $template ||= $self->_agent_template; @@ -2074,6 +2321,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', @@ -2081,14 +2350,44 @@ sub print_latex { SUFFIX => '.tex', UNLINK => 0, ) or die "can't open temp file: $!\n"; + binmode($fh, ':utf8'); # language support print $fh join('', @filled_in ); 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. @@ -2113,14 +2412,16 @@ unsquelch_cdr - overrides any per customer cdr squelching when true notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required) +locale - override customer's locale + =cut #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 +# yes: fixed width/plain text printing will be borked sub print_generic { - my( $self, %params ) = @_; + my $conf = $self->conf; my $today = $params{today} ? $params{today} : time; warn "$me print_generic called on $self with suffix $params{template}\n" if $DEBUG; @@ -2139,11 +2440,14 @@ 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"; $templatefile .= "_$template" - if length($template); + if length($template) && $conf->exists($templatefile."_$template"); my @invoice_template = map "$_\n", $conf->config($templatefile) or die "cannot load config data $templatefile"; @@ -2156,12 +2460,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"; @@ -2252,15 +2562,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(). '}' @@ -2271,6 +2585,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; @@ -2324,6 +2646,9 @@ sub print_generic { } + warn "$me generating invoice data\n" + if $DEBUG > 1; + my $agentnum = $self->cust_main->agentnum; my %invoice_data = ( @@ -2331,13 +2656,14 @@ sub print_generic { #invoice from info 'company_name' => scalar( $conf->config('company_name', $agentnum) ), 'company_address' => join("\n", $conf->config('company_address', $agentnum) ). "\n", + 'company_phonenum'=> scalar( $conf->config('company_phonenum', $agentnum) ), '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? @@ -2379,6 +2705,32 @@ sub print_generic { 'total_pages' => 1, ); + + #localization + my $lh = FS::L10N->get_handle( $params{'locale'} || $cust_main->locale ); + $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) }; + my %info = FS::Locales->locale_info($cust_main->locale || 'en_US'); + # eval to avoid death for unimplemented languages + my $dh = eval { Date::Language->new($info{'name'}) } || + Date::Language->new(); # fall back to English + # prototype here to silence warnings + $invoice_data{'time2str'} = sub ($;$$) { $dh->time2str(@_) }; + # eventually use this date handle everywhere in here, too + + 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') ) { @@ -2430,24 +2782,53 @@ 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; + + # the customer's current balance as shown on the invoice before this one $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) ); + + # the change in balance from that invoice to this one $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) ); + + # the sum of amount owed on all previous invoices $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total); + + # the sum of amount owed on all invoices $invoice_data{'balance'} = sprintf("%.2f", $balance_due); + # info from customer's last invoice before this one, for some + # summary formats + $invoice_data{'last_bill'} = {}; + my $last_bill = $pr_cust_bill[-1]; + if ( $last_bill ) { + $invoice_data{'last_bill'} = { + '_date' => $last_bill->_date, #unformatted + # all we need for now + }; + } + my $summarypage = ''; if ( $conf->exists('invoice_usesummary', $agentnum) ) { $summarypage = 1; } $invoice_data{'summarypage'} = $summarypage; - #do variable substitution in notes, footer, smallfooter - foreach my $include (qw( notes footer smallfooter coupon )) { + warn "$me substituting variables in notes, footer, smallfooter\n" + if $DEBUG > 1; + + my @include = (qw( notes footer smallfooter )); + push @include, 'coupon' unless $params{'no_coupon'}; + foreach my $include (@include) { my $inc_file = $conf->key_orbase("invoice_${format}$include", $template); my @inc_src; @@ -2489,9 +2870,12 @@ sub print_generic { if ($format eq 'latex'); } - $invoice_data{'po_line'} = + # let invoices use either of these as needed + $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL') + ? $cust_main->payinfo : ''; + $invoice_data{'po_line'} = ( $cust_main->payby eq 'BILL' && $cust_main->payinfo ) - ? &$escape_function("Purchase Order #". $cust_main->payinfo) + ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo) : $nbsp; my %money_chars = ( 'latex' => '', @@ -2517,38 +2901,40 @@ sub print_generic { $invoice_data{'buf'} = \@buf; $invoice_data{'sections'} = \@sections; - my $previous_section = { 'description' => 'Previous Charges', + warn "$me generating sections\n" + if $DEBUG > 1; + + my $previous_section = { 'description' => $self->mt('Previous Charges'), 'subtotal' => $other_money_char. sprintf('%.2f', $pr_total), - 'summarized' => $summarypage ? 'Y' : '', + 'summarized' => '', #why? $summarypage ? 'Y' : '', }; - $previous_section->{posttotal} = '0 / 30 / 60/ 90 days overdue '. + $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', + my $tax_section = { 'description' => $self->mt('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->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : ''; $tax_section->{'sort_weight'} = $tax_weight; my $adjusttotal = 0; - my $adjust_section = { 'description' => 'Credits, Payments, and Adjustments', + my $adjust_section = { 'description' => + $self->mt('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->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : ''; $adjust_section->{'sort_weight'} = $adjust_weight; my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y'; @@ -2559,7 +2945,7 @@ sub print_generic { my $extra_lines = (); if ( $multisection ) { ($extra_sections, $extra_lines) = - $self->_items_extra_usage_sections($escape_function, $format) + $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}; @@ -2568,25 +2954,53 @@ sub print_generic { push @sections, $self->_items_sections( $late_sections, # this could stand a refactor $summarypage, - $escape_function, + $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, $format); + $self->_items_svc_phone_sections($escape_function_nonbsp, $format); push @{$late_sections}, @$phone_sections; push @detail_items, @$phone_lines; } - }else{ - push @sections, { 'description' => '', 'subtotal' => '' }; + if ($conf->exists('voip-cust_accountcode_cdr') && $cust_main->accountcode_cdr) { + my ($accountcode_section, $accountcode_lines) = + $self->_items_accountcode_cdr($escape_function_nonbsp,$format); + if ( scalar(@$accountcode_lines) ) { + push @{$late_sections}, $accountcode_section; + push @detail_items, @$accountcode_lines; + } + } + } else {# not multisection + # make a default section + push @sections, { 'description' => '', 'subtotal' => '', + 'no_subtotal' => 1 }; + # and calculate the finance charge total, since it won't get done otherwise. + # XXX possibly other totals? + # XXX possibly finance_pkgclass should not be used in this manner? + if ( $conf->exists('finance_pkgclass') ) { + my @finance_charges; + foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { + if ( grep { $_->section eq $invoice_data{finance_section} } + $cust_bill_pkg->cust_bill_pkg_display ) { + # I think these are always setup fees, but just to be sure... + push @finance_charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup; + } + } + $invoice_data{finance_amount} = + sprintf('%.2f', sum( @finance_charges ) || 0); + } } - unless ( $conf->exists('disable_previous_balance') + unless ( $conf->exists('disable_previous_balance', $agentnum) || $conf->exists('previous_balance-summary_only') ) { + warn "$me adding previous balances\n" + if $DEBUG > 1; + foreach my $line_item ( $self->_items_previous ) { my $detail = { @@ -2612,16 +3026,32 @@ sub print_generic { } } - - if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) { + + if ( @pr_cust_bill && !$conf->exists('disable_previous_balance', $agentnum) ) + { push @buf, ['','-----------']; - push @buf, [ 'Total Previous Balance', + push @buf, [ $self->mt('Total Previous Balance'), $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 (since last invoice)'; + push @detail_items, + { 'description' => $didsummary_desc, + 'ext_description' => [ $didsummary, $minutes ], + }; + } 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 @@ -2647,12 +3077,15 @@ sub print_generic { ); } + 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; $options{'escape_function'} = $escape_function; - $options{'format_function'} = sub { () } unless $unsquelched; + $options{'no_usage'} = 1 unless $unsquelched; $options{'unsquelched'} = $unsquelched; $options{'summary_page'} = $summarypage; $options{'skip_usage'} = @@ -2660,7 +3093,14 @@ sub print_generic { $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 => [], }; @@ -2676,6 +3116,10 @@ sub print_generic { $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ). $line_item->{'unit_amount'}; $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; + + $detail->{'sdate'} = $line_item->{'sdate'}; + $detail->{'edate'} = $line_item->{'edate'}; + $detail->{'seconds'} = $line_item->{'seconds'}; push @detail_items, $detail; push @buf, ( [ $detail->{'description'}, @@ -2688,7 +3132,7 @@ sub print_generic { if ( $section->{'description'} ) { push @buf, ( ['','-----------'], [ $section->{'description'}. ' sub-total', - $money_char. sprintf("%10.2f", $section->{'subtotal'}) + $section->{'subtotal'} # already formatted this ], [ '', '' ], [ '', '' ], @@ -2696,16 +3140,19 @@ 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', $agentnum) || $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'}; @@ -2743,7 +3190,7 @@ sub print_generic { if ( $taxtotal ) { my $total = {}; - $total->{'total_item'} = 'Sub-total'; + $total->{'total_item'} = $self->mt('Sub-total'); $total->{'total_amount'} = $other_money_char. sprintf('%.2f', $self->charged - $taxtotal ); @@ -2760,7 +3207,8 @@ sub print_generic { $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal); push @buf,['','-----------']; - push @buf,[( $conf->exists('disable_previous_balance') + push @buf,[$self->mt( + $conf->exists('disable_previous_balance', $agentnum) ? 'Total Charges' : 'Total New Charges' ), @@ -2774,21 +3222,21 @@ sub print_generic { || 'Total New Charges' if $conf->exists('previous_balance-exclude_from_total'); my $amount = $self->charged + - ( $conf->exists('disable_previous_balance') || + ( $conf->exists('disable_previous_balance', $agentnum) || $conf->exists('previous_balance-exclude_from_total') ? 0 : $pr_total ); - $total->{'total_item'} = &$embolden_function($item); + $total->{'total_item'} = &$embolden_function($self->mt($item)); $total->{'total_amount'} = &$embolden_function( $other_money_char. sprintf( '%.2f', $amount ) ); if ( $multisection ) { if ( $adjust_section->{'sort_weight'} ) { - $adjust_section->{'posttotal'} = 'Balance Forward '. $other_money_char. - sprintf("%.2f", ($self->billing_balance || 0) ); + $adjust_section->{'posttotal'} = $self->mt('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 ); + $adjust_section->{'pretotal'} = $self->mt('New charges total').' '. + $other_money_char. sprintf('%.2f', $self->charged ); } }else{ push @total_items, $total; @@ -2801,7 +3249,7 @@ sub print_generic { push @buf,['','']; } - unless ( $conf->exists('disable_previous_balance') ) { + unless ( $conf->exists('disable_previous_balance', $agentnum) ) { #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments # credits @@ -2871,6 +3319,7 @@ sub print_generic { unless $adjust_section->{sort_weight}; } + # create Balance Due message { my $total; $total->{'total_item'} = &$embolden_function($self->balance_due_msg); @@ -2892,6 +3341,26 @@ 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 ) { @@ -2911,6 +3380,26 @@ sub print_generic { if $unsquelched; } + # make a discounts-available section, even without multisection + if ( $conf->exists('discount-show_available') + and my @discounts_avail = $self->_items_discounts_avail ) { + my $discount_section = { + 'description' => $self->mt('Discounts Available'), + 'subtotal' => '', + 'no_subtotal' => 1, + }; + + push @sections, $discount_section; + push @detail_items, map { +{ + 'ref' => '', #should this be something else? + 'section' => $discount_section, + 'description' => &$escape_function( $_->{description} ), + 'amount' => $money_char . &$escape_function( $_->{amount} ), + 'ext_description' => [ &$escape_function($_->{ext_description}) || () ], + } } @discounts_avail; + } + + # All sections and items are built; now fill in templates. my @includelist = (); push @includelist, 'summary' if $summarypage; foreach my $include ( @includelist ) { @@ -2973,28 +3462,27 @@ sub print_generic { } #setup subroutine for the template - sub FS::cust_bill::_template::invoice_lines { - my $lines = shift || scalar(@FS::cust_bill::_template::buf); + $invoice_data{invoice_lines} = sub { + my $lines = shift || scalar(@buf); map { - scalar(@FS::cust_bill::_template::buf) - ? shift @FS::cust_bill::_template::buf + scalar(@buf) + ? shift @buf : [ '', '' ]; } ( 1 .. $lines ); - } + }; my $lines; my @collect; while (@buf) { push @collect, split("\n", - $text_template->fill_in( HASH => \%invoice_data, - PACKAGE => 'FS::cust_bill::_template' - ) + $text_template->fill_in( HASH => \%invoice_data ) ); - $FS::cust_bill::_template::page++; + $invoice_data{'page'}++; } map "$_\n", @collect; }else{ + # this is where we actually create the invoice warn "filling in template for invoice ". $self->invnum. "\n" if $DEBUG; warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n" @@ -3039,10 +3527,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($file.'.tex'); - unlink($lfile); + unlink($logofile); + unlink($barcodefile) if $barcodefile; $ps; } @@ -3068,10 +3556,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($file.'.tex'); - unlink($lfile); + unlink($logofile); + unlink($barcodefile) if $barcodefile; $pdf; } @@ -3106,7 +3594,7 @@ sub print_html { } $params{'format'} = 'html'; - + $self->print_generic( %params ); } @@ -3126,6 +3614,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 { @@ -3191,6 +3691,7 @@ sub _translate_old_latex_format { sub terms { my $self = shift; + my $conf = $self->conf; #check for an invoice-specific override return $self->invoice_terms if $self->invoice_terms; @@ -3219,10 +3720,11 @@ sub due_date2str { sub balance_due_msg { my $self = shift; - my $msg = 'Balance Due'; + my $msg = $self->mt('Balance Due'); return $msg unless $self->terms; if ( $self->due_date ) { - $msg .= ' - Please pay by '. $self->due_date2str($date_format); + $msg .= ' - ' . $self->mt('Please pay by'). ' '. + $self->due_date2str($date_format); } elsif ( $self->terms ) { $msg .= ' - '. $self->terms; } @@ -3231,6 +3733,7 @@ sub balance_due_msg { sub balance_due_date { my $self = shift; + my $conf = $self->conf; my $duedate = ''; if ( $conf->exists('invoice_default_terms') && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) { @@ -3239,6 +3742,11 @@ sub balance_due_date { $duedate; } +sub credit_balance_msg { + my $self = shift; + $self->mt('Credit Balance Remaining') +} + =item invnum_date_pretty Returns a string with the invoice number and date, for example: @@ -3248,7 +3756,7 @@ Returns a string with the invoice number and date, for example: sub invnum_date_pretty { my $self = shift; - 'Invoice #'. $self->invnum. ' ('. $self->_date_pretty. ')'; + $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')'; } =item _date_pretty @@ -3262,6 +3770,53 @@ sub _date_pretty { time2str($date_format, $self->_date); } +=item _items_sections LATE SUMMARYPAGE ESCAPE EXTRA_SECTIONS FORMAT + +Generate section information for all items appearing on this invoice. +This will only be called for multi-section invoices. + +For each line item (L record), this will fetch all +related display records (L) and organize +them into two groups ("early" and "late" according to whether they come +before or after the total), then into sections. A subtotal is calculated +for each section. + +Section descriptions are returned in sort weight order. Each consists +of a hash containing: + +description: the package category name, escaped +subtotal: the total charges in that section +tax_section: a flag indicating that the section contains only tax charges +summarized: same as tax_section, for some reason +sort_weight: the package category's sort weight + +If 'condense' is set on the display record, it also contains everything +returned from C<_condense_section()>, i.e. C<_condensed_foo_generator> +coderefs to generate parts of the invoice. This is not advised. + +Arguments: + +LATE: an arrayref to push the "late" section hashes onto. The "early" +group is simply returned from the method. + +SUMMARYPAGE: a flag indicating whether this is a summary-format invoice. +Turning this on has the following effects: +- Ignores display items with the 'summary' flag. +- Combines all items into the "early" group. +- Creates sections for all non-disabled package categories, even if they +have no charges on this invoice, as well as a section with no name. + +ESCAPE: an escape function to use for section titles. + +EXTRA_SECTIONS: an arrayref of additional sections to return after the +sorted list. If there are any of these, section subtotals exclude +usage charges. + +FORMAT: 'latex', 'html', or 'template' (i.e. text). Not used, but +passed through to C<_condense_section()>. + +=cut + use vars qw(%pkg_category_cache); sub _items_sections { my $self = shift; @@ -3380,7 +3935,7 @@ sub _items_sections { } } @sections; push @early, @$extra_sections if $extra_sections; - + sort { $a->{sort_weight} <=> $b->{sort_weight} } @early; } @@ -3654,6 +4209,7 @@ sub _condensed_total_line_generator { sub _items_extra_usage_sections { my $self = shift; + my $conf = $self->conf; my $escape = shift; my $format = shift; @@ -3661,6 +4217,8 @@ sub _items_extra_usage_sections { my %classnums = (); my %lines = (); + my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50; + my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} ); foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { next unless $cust_bill_pkg->pkgnum > 0; @@ -3680,8 +4238,8 @@ sub _items_extra_usage_sections { my $desc = $detail->regionname; my $description = $desc; - $description = substr($desc, 0, 50). '...' - if $format eq 'latex' && length($desc) > 50; + $description = substr($desc, 0, $maxlength). '...' + if $format eq 'latex' && length($desc) > $maxlength; $lines{$section}{$desc} ||= { description => &{$escape}($description), @@ -3742,8 +4300,156 @@ sub _items_extra_usage_sections { } +sub _did_summary { + my $self = shift; + my $end = $self->_date; + + # start at date of previous invoice + 1 second or 0 if no previous invoice + my $start = $self->scalar_sql("SELECT max(_date) FROM cust_bill WHERE custnum = ? and invnum != ?",$self->custnum,$self->invnum); + $start = 0 if !$start; + $start++; + + 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+5); + 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 + if ( $phone_inserted ) { + my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end,'billsec_sum'=>1); + $minutes = $cdrs[0]->billsec_sum if scalar(@cdrs) == 1; + } + else { + warn "WARNING: no matching h_svc_phone insert record for insert time $inserted, svcnum " . $h_cust_svc->svcnum; + } + + # 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_accountcode_cdr { + my $self = shift; + my $escape = shift; + my $format = shift; + + my $section = { 'amount' => 0, + 'calls' => 0, + 'duration' => 0, + 'sort_weight' => '', + 'phonenum' => '', + 'description' => 'Usage by Account Code', + 'post_total' => '', + 'summarized' => '', + 'header' => '', + }; + my @lines; + my %accountcodes = (); + + 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); + $section->{'header'} = join(',',@header); + + foreach my $detail ( $cust_bill_pkg->cust_bill_pkg_detail ) { + + $section->{'header'} = $detail->formatted('format' => $format) + if($detail->detail eq $section->{'header'}); + + my $accountcode = $detail->accountcode; + next unless $accountcode; + + my $amount = $detail->amount; + next unless $amount && $amount > 0; + + $accountcodes{$accountcode} ||= { + description => $accountcode, + pkgnum => '', + ref => '', + amount => 0, + calls => 0, + duration => 0, + quantity => '', + product_code => 'N/A', + section => $section, + ext_description => [ $section->{'header'} ], + detail_temp => [], + }; + + $section->{'amount'} += $amount; + $accountcodes{$accountcode}{'amount'} += $amount; + $accountcodes{$accountcode}{calls}++; + $accountcodes{$accountcode}{duration} += $detail->duration; + push @{$accountcodes{$accountcode}{detail_temp}}, $detail; + } + } + + foreach my $l ( values %accountcodes ) { + $l->{amount} = sprintf( "%.2f", $l->{amount} ); + my @sorted_detail = sort { $a->startdate <=> $b->startdate } @{$l->{detail_temp}}; + foreach my $sorted_detail ( @sorted_detail ) { + push @{$l->{ext_description}}, $sorted_detail->formatted('format'=>$format); + } + delete $l->{detail_temp}; + push @lines, $l; + } + + my @sorted_lines = sort { $a->{'description'} <=> $b->{'description'} } @lines; + + return ($section,\@sorted_lines); +} + sub _items_svc_phone_sections { my $self = shift; + my $conf = $self->conf; my $escape = shift; my $format = shift; @@ -3751,6 +4457,8 @@ sub _items_svc_phone_sections { my %classnums = (); my %lines = (); + my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50; + my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} ); $usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 }; @@ -3780,8 +4488,8 @@ sub _items_svc_phone_sections { my $desc = $detail->regionname; my $description = $desc; - $description = substr($desc, 0, 50). '...' - if $format eq 'latex' && length($desc) > 50; + $description = substr($desc, 0, $maxlength). '...' + if $format eq 'latex' && length($desc) > $maxlength; $lines{$phonenum}{$desc} ||= { description => &{$escape}($description), @@ -3886,12 +4594,91 @@ sub _items_svc_phone_sections { 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); } -sub _items { +sub _items { # seems to be unused my $self = shift; #my @display = scalar(@_) @@ -3910,6 +4697,7 @@ sub _items { sub _items_previous { my $self = shift; + my $conf = $self->conf; my $cust_main = $self->cust_main; my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance my @b = (); @@ -3918,7 +4706,7 @@ sub _items_previous { ? 'due '. $_->due_date2str($date_format) : time2str($date_format, $_->_date); push @b, { - 'description' => 'Previous Balance, Invoice #'. $_->invnum. " ($date)", + 'description' => $self->mt('Previous Balance, Invoice #'). $_->invnum. " ($date)", #'pkgpart' => 'N/A', 'pkgnum' => 'N/A', 'amount' => sprintf("%.2f", $_->owed), @@ -3940,12 +4728,39 @@ sub _items_previous { #}; } +=item _items_pkg [ OPTIONS ] + +Return line item hashes for each package item on this invoice. Nearly +equivalent to + +$self->_items_cust_bill_pkg([ $self->cust_bill_pkg ]) + +The only OPTIONS accepted is 'section', which may point to a hashref +with a key named 'condensed', which may have a true value. If it +does, this method tries to merge identical items into items with +'quantity' equal to the number of items (not the sum of their +separate quantities, for some reason). + +=cut + 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; + + 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 ) { @@ -3965,6 +4780,10 @@ sub _items_pkg { } keys %itemshash; } + + warn "$me _items_pkg returning ". scalar(@items). " items\n" + if $DEBUG > 1; + @items; } @@ -3983,23 +4802,59 @@ sub _items_tax { $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_); } +=item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS + +Takes an arrayref of L objects, and returns a +list of hashrefs describing the line items they generate on the invoice. + +OPTIONS may include: + +format: the invoice format. + +escape_function: the function used to escape strings. + +DEPRECATED? (expensive, mostly unused?) +format_function: the function used to format CDRs. + +section: a hashref containing 'description'; if this is present, +cust_bill_pkg_display records not belonging to this section are +ignored. + +multisection: a flag indicating that this is a multisection invoice, +which does something complicated. + +multilocation: a flag to display the location label for the package. + +Returns a list of hashrefs, each of which may contain: + +pkgnum, description, amount, unit_amount, quantity, _is_setup, and +ext_description, which is an arrayref of detail lines to show below +the package line. + +=cut + sub _items_cust_bill_pkg { my $self = shift; - my $cust_bill_pkg = shift; + my $conf = $self->conf; + my $cust_bill_pkgs = shift; my %opt = @_; my $format = $opt{format} || ''; my $escape_function = $opt{escape_function} || sub { shift }; my $format_function = $opt{format_function} || ''; - my $unsquelched = $opt{unsquelched} || ''; + my $no_usage = $opt{no_usage} || ''; + my $unsquelched = $opt{unsquelched} || ''; #unused my $section = $opt{section}->{description} if $opt{section}; - my $summary_page = $opt{summary_page} || ''; + my $summary_page = $opt{summary_page} || ''; #unused my $multilocation = $opt{multilocation} || ''; my $multisection = $opt{multisection} || ''; + my $discount_show_always = 0; + + my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50; 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, ($opt{skip_usage} ? () : $u ) ) { @@ -4008,11 +4863,19 @@ sub _items_cust_bill_pkg { $_->{amount} =~ s/^\-0\.00$/0.00/; $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ), push @b, { %$_ } - unless $_->{amount} == 0; + if $_->{amount} != 0 + || $discount_show_always + || ( ! $_->{_is_setup} && $_->{recur_show_zero} ) + || ( $_->{_is_setup} && $_->{setup_show_zero} ) + ; $_ = undef; } } + warn "$me _items_cust_bill_pkg considering cust_bill_pkg ". + $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n" + if $DEBUG > 1; + foreach my $display ( grep { defined($section) ? $_->section eq $section : 1 @@ -4023,39 +4886,67 @@ sub _items_cust_bill_pkg { ) { + warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ". + $display->billpkgdisplaynum. "\n" + if $DEBUG > 1; + my $type = $display->type; my $desc = $cust_bill_pkg->desc; - $desc = substr($desc, 0, 50). '...' - if $format eq 'latex' && length($desc) > 50; + $desc = substr($desc, 0, $maxlength). '...' + if $format eq 'latex' && length($desc) > $maxlength; my %details_opt = ( 'format' => $format, 'escape_function' => $escape_function, 'format_function' => $format_function, + 'no_usage' => $opt{'no_usage'}, ); 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') ) { + # start/end dates for invoice formats that do nonstandard + # things with them + my %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate'); + + if ( (!$type || $type eq 'S') + && ( $cust_bill_pkg->setup != 0 + || $cust_bill_pkg->setup_show_zero + ) + ) + { + + warn "$me _items_cust_bill_pkg adding setup\n" + if $DEBUG > 1; my $description = $desc; - $description .= ' Setup' if $cust_bill_pkg->recur != 0; + $description .= ' Setup' + if $cust_bill_pkg->recur != 0 + || $discount_show_always + || $cust_bill_pkg->recur_show_zero; my @d = (); unless ( $cust_pkg->part_pkg->hide_svc_detail || $cust_bill_pkg->hidden ) { + push @d, map &{$escape_function}($_), - $cust_pkg->h_labels_short($self->_date); + $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($desc, 0, 50). '...' - if $format eq 'latex' && length($loc) > 50; + $loc = substr($loc, 0, $maxlength). '...' + if $format eq 'latex' && length($loc) > $maxlength; push @d, &{$escape_function}($loc); } - } + + } #unless hiding service details + push @d, $cust_bill_pkg->details(%details_opt) if $cust_bill_pkg->recur == 0; @@ -4065,10 +4956,12 @@ sub _items_cust_bill_pkg { push @{ $s->{ext_description} }, @d; } else { $s = { + _is_setup => 1, description => $description, #pkgpart => $part_pkg->pkgpart, pkgnum => $cust_bill_pkg->pkgnum, amount => $cust_bill_pkg->setup, + setup_show_zero => $cust_bill_pkg->setup_show_zero, unit_amount => $cust_bill_pkg->unitsetup, quantity => $cust_bill_pkg->quantity, ext_description => \@d, @@ -4077,60 +4970,125 @@ sub _items_cust_bill_pkg { } - if ( ( $cust_bill_pkg->recur != 0 || $cust_bill_pkg->setup == 0 ) && - ( !$type || $type eq 'R' || $type eq 'U' ) + if ( ( !$type || $type eq 'R' || $type eq 'U' ) + && ( + $cust_bill_pkg->recur != 0 + || $cust_bill_pkg->setup == 0 + || $discount_show_always + || $cust_bill_pkg->recur_show_zero + ) ) { + 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($date_format, $cust_bill_pkg->sdate). - " - ". time2str($date_format, $cust_bill_pkg->edate). ")"; + unless ( + $conf->exists('disable_line_item_date_ranges') + || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1) + ) { + my $time_period; + my $date_style = $conf->config('cust_bill-line_item-date_style'); + if ( $date_style eq 'month_of' ) { + $time_period = time2str('The month of %B', $cust_bill_pkg->sdate); + } else { + $time_period = time2str($date_format, $cust_bill_pkg->sdate). + " - ". time2str($date_format, $cust_bill_pkg->edate); + } + $description .= " ($time_period)"; } my @d = (); + my @seconds = (); # for display of usage info #at least until cust_bill_pkg has "past" ranges in addition to #the "future" sdate/edate ones... see #3032 my @dates = ( $self->_date ); my $prev = $cust_bill_pkg->previous_cust_bill_pkg; push @dates, $prev->sdate if $prev; + push @dates, undef if !$prev; unless ( $cust_pkg->part_pkg->hide_svc_detail || $cust_bill_pkg->itemdesc || $cust_bill_pkg->hidden || $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) + $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($desc, 0, 50). '...' - if $format eq 'latex' && length($loc) > 50; + $loc = substr($loc, 0, $maxlength). '...' + if $format eq 'latex' && length($loc) > $maxlength; push @d, &{$escape_function}($loc); } + + # Display of seconds_since_sqlradacct: + # On the invoice, when processing @detail_items, look for a field + # named 'seconds'. This will contain total seconds for each + # service, in the same order as @ext_description. For services + # that don't support this it will show undef. + if ( $conf->exists('svc_acct-usage_seconds') + and ! $cust_bill_pkg->pkgpart_override ) { + foreach my $cust_svc ( + $cust_pkg->h_cust_svc(@dates, 'I') + ) { + + # eval because not having any part_export_usage exports + # is a fatal error, last_bill/_date because that's how + # sqlradius_hour billing does it + my $sec = eval { + $cust_svc->seconds_since_sqlradacct($dates[1] || 0, $dates[0]); + }; + push @seconds, $sec; + } + } #if svc_acct-usage_seconds + } - 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{'no_usage'} = 1 + 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; @@ -4141,14 +5099,20 @@ sub _items_cust_bill_pkg { #pkgpart => $part_pkg->pkgpart, pkgnum => $cust_bill_pkg->pkgnum, amount => $amount, + recur_show_zero => $cust_bill_pkg->recur_show_zero, unit_amount => $cust_bill_pkg->unitrecur, quantity => $cust_bill_pkg->quantity, + %item_dates, ext_description => \@d, }; + $r->{'seconds'} = \@seconds if grep {defined $_} @seconds; } } else { # $type eq 'U' + warn "$me _items_cust_bill_pkg adding usage\n" + if $DEBUG > 1; + if ( $cust_bill_pkg->hidden ) { $u->{amount} += $amount; $u->{unit_amount} += $cust_bill_pkg->unitrecur; @@ -4159,18 +5123,22 @@ sub _items_cust_bill_pkg { #pkgpart => $part_pkg->pkgpart, pkgnum => $cust_bill_pkg->pkgnum, amount => $amount, + recur_show_zero => $cust_bill_pkg->recur_show_zero, unit_amount => $cust_bill_pkg->unitrecur, quantity => $cust_bill_pkg->quantity, + %item_dates, ext_description => \@d, }; } - } } # recurring or usage with recurring charge } 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, @@ -4190,6 +5158,9 @@ sub _items_cust_bill_pkg { } + $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount + && $conf->exists('discount-show-always')); + } foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) { @@ -4198,10 +5169,16 @@ sub _items_cust_bill_pkg { $_->{amount} =~ s/^\-0\.00$/0.00/; $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ), push @b, { %$_ } - unless $_->{amount} == 0; + if $_->{amount} != 0 + || $discount_show_always + || ( ! $_->{_is_setup} && $_->{recur_show_zero} ) + || ( $_->{_is_setup} && $_->{setup_show_zero} ) } } + warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n" + if $DEBUG > 1; + @b; } @@ -4224,7 +5201,7 @@ sub _items_credits { #'description' => 'Credit ref\#'. $_->crednum. # " (". time2str("%x",$_->cust_credit->_date) .")". # $reason, - 'description' => 'Credit applied '. + 'description' => $self->mt('Credit applied').' '. time2str($date_format,$_->cust_credit->_date). $reason, 'amount' => sprintf("%.2f",$_->amount), }; @@ -4244,7 +5221,7 @@ sub _items_payments { #something more elaborate if $_->amount ne ->cust_pay->paid ? push @b, { - 'description' => "Payment received ". + 'description' => $self->mt('Payment received').' '. time2str($date_format,$_->cust_pay->_date ), 'amount' => sprintf("%.2f", $_->amount ) }; @@ -4254,6 +5231,48 @@ sub _items_payments { } +=item _items_discounts_avail + +Returns an array of line item hashrefs representing available term discounts +for this invoice. This makes the same assumptions that apply to term +discounts in general: that the package is billed monthly, at a flat rate, +with no usage charges. A prorated first month will be handled, as will +a setup fee if the discount is allowed to apply to setup fees. + +=cut + +sub _items_discounts_avail { + my $self = shift; + my $list_pkgnums = 0; # if any packages are not eligible for all discounts + + my %plans = $self->discount_plans; + + $list_pkgnums = grep { $_->list_pkgnums } values %plans; + + map { + my $months = $_; + my $plan = $plans{$months}; + + my $term_total = sprintf('%.2f', $plan->discounted_total); + my $percent = sprintf('%.0f', + 100 * (1 - $term_total / $plan->base_total) ); + my $permonth = sprintf('%.2f', $term_total / $months); + my $detail = $self->mt('discount on item'). ' '. + join(', ', map { "#$_" } $plan->pkgnums) + if $list_pkgnums; + + +{ + description => $self->mt('Save [_1]% by paying for [_2] months', + $percent, $months), + amount => $self->mt('[_1] ([_2] per month)', + $term_total, $money_char.$permonth), + ext_description => ($detail || ''), + } + } #map + sort { $b <=> $a } keys %plans; + +} + =item call_details [ OPTION => VALUE ... ] Returns an array of CSV strings representing the call details for this invoice @@ -4470,6 +5489,7 @@ Currently only supported on PostgreSQL. =cut sub due_date_sql { + my $conf = new FS::Conf; 'COALESCE( SUBSTRING( COALESCE( @@ -4538,6 +5558,11 @@ sub search_sql_where { push @search, "cust_main.agentnum = $1"; } + #agentnum + if ( $param->{'custnum'} =~ /^(\d+)$/ ) { + push @search, "cust_bill.custnum = $1"; + } + #_date if ( $param->{_date} ) { my($beginning, $ending) = @{$param->{_date}}; @@ -4609,6 +5634,15 @@ sub search_sql_where { } + #promised_date - also has an option to accept nulls + if ( $param->{promised_date} ) { + my($beginning, $ending, $null) = @{$param->{promised_date}}; + + push @search, "(( cust_bill.promised_date >= $beginning AND ". + "cust_bill.promised_date < $ending )" . + ($null ? ' OR cust_bill.promised_date IS NULL ) ' : ')'); + } + #agent virtualization my $curuser = $FS::CurrentUser::CurrentUser; if ( $curuser->username eq 'fs_queue'