X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_bill.pm;h=e56ddf72d64c495c91059a3a7f99c4b3b191eced;hb=49d30c8722afe66013eeca25b7fb2937d2f34307;hp=3fbf03bf5269b33aeb4b2014f4d8c8325c2a50f3;hpb=6d10f9863e64529026e2ff4ec144608e846f0a6a;p=freeside.git diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 3fbf03bf5..e56ddf72d 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -1,13 +1,15 @@ package FS::cust_bill; use strict; -use vars qw( @ISA $DEBUG $me $conf +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 Cwd; -use List::Util qw(min max); +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; @@ -41,6 +43,8 @@ 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 ); @@ -49,7 +53,7 @@ $me = '[FS::cust_bill]'; #ask FS::UID to run this stuff for us later FS::UID->install_callback( sub { - $conf = new FS::Conf; + 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 @@ -140,6 +144,8 @@ Specific use cases =item agent_invid - legacy invoice number +=item promised_date - customer promised payment date, for collection + =back =head1 METHODS @@ -240,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() ) { @@ -363,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 { @@ -370,6 +377,25 @@ sub display_invnum { } } +=item previous_bill + +Returns the customer's last invoice before this one. + +=cut + +sub previous_bill { + my $self = shift; + if ( !$self->get('previous_bill') ) { + $self->set('previous_bill', qsearchs({ + 'table' => 'cust_bill', + 'hashref' => { 'custnum' => $self->custnum, + '_date' => { op=>'<', value=>$self->_date } }, + 'order_by' => 'ORDER BY _date DESC LIMIT 1', + }) ); + } + $self->get('previous_bill'); +} + =item previous Returns a list consisting of the total previous balance for this customer, @@ -381,13 +407,29 @@ sub previous { my $self = shift; my $total = 0; my @cust_bill = sort { $a->_date <=> $b->_date } - grep { $_->owed != 0 && $_->_date < $self->_date } - qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) + grep { $_->owed != 0 } + qsearch( 'cust_bill', { 'custnum' => $self->custnum, + #'_date' => { op=>'<', value=>$self->_date }, + 'invnum' => { op=>'<', value=>$self->invnum }, + } ) ; foreach ( @cust_bill ) { $total += $_->owed; } $total, @cust_bill; } +=item enable_previous + +Whether to show the 'Previous Charges' section when printing this invoice. +The negation of the 'disable_previous_balance' config setting. + +=cut + +sub enable_previous { + my $self = shift; + my $agentnum = $self->cust_main->agentnum; + !$self->conf->exists('disable_previous_balance', $agentnum); +} + =item cust_bill_pkg Returns the line items (see L) for this invoice. @@ -733,6 +775,29 @@ sub cust_credit_bill_pkg { } +=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 Returns the tax amount (see L) for this invoice. @@ -781,6 +846,23 @@ sub owed_pkgnum { $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. @@ -795,6 +877,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'; @@ -943,6 +1026,7 @@ sub generate_email { my $self = shift; my %args = @_; + my $conf = $self->conf; my $me = '[FS::cust_bill::generate_email]'; @@ -977,7 +1061,7 @@ sub generate_email { my $alternative = build MIME::Entity 'Type' => 'multipart/alternative', - 'Encoding' => '7bit', + #'Encoding' => '7bit', 'Disposition' => 'inline' ; @@ -1005,47 +1089,60 @@ 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"; - } - 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>", - ; + + $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>", + ; - 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; + 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 }); } $alternative->attach( @@ -1058,7 +1155,7 @@ sub generate_email { ' ', ' ', ' ', - $self->print_html({ 'cid'=>$content_id, %opt }), + $htmldata, ' ', '', ], @@ -1066,6 +1163,7 @@ sub generate_email { #'Filename' => 'invoice.pdf', ); + my @otherparts = (); if ( $cust_main->email_csv_cdr ) { @@ -1104,7 +1202,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); @@ -1120,11 +1218,10 @@ sub generate_email { # image/png $return{'content-type'} = 'multipart/related'; - if($conf->exists('invoice-barcode')){ - $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ]; - } - else { - $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'; @@ -1228,6 +1325,7 @@ sub queueable_send { sub send { my $self = shift; + my $conf = $self->conf; my( $template, $invoice_from, $notice_name ); my $agentnums = ''; @@ -1251,14 +1349,16 @@ sub send { $balance_over = shift if scalar(@_) && $_[0] !~ /^\s*$/; } + my $cust_main = $self->cust_main; + return 'N/A' unless ! $agentnums - or grep { $_ == $self->cust_main->agentnum } @$agentnums; + or grep { $_ == $cust_main->agentnum } @$agentnums; return '' - unless $self->cust_main->total_owed_date($self->_date) > $balance_over; + unless $cust_main->total_owed_date($self->_date) > $balance_over; $invoice_from ||= $self->_agent_invoice_from || #XXX should go away - $conf->config('invoice_from', $self->cust_main->agentnum ); + $conf->config('invoice_from', $cust_main->agentnum ); my %opt = ( 'template' => $template, @@ -1266,11 +1366,12 @@ sub send { 'notice_name' => ( $notice_name || 'Invoice' ), ); - my @invoicing_list = $self->cust_main->invoicing_list; + my @invoicing_list = $cust_main->invoicing_list; #$self->email_invoice(\%opt) $self->email(\%opt) - if grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list; + if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list ) + && ! $self->invoice_noemail; #$self->print_invoice(\%opt) $self->print(\%opt) @@ -1317,6 +1418,8 @@ 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, $no_coupon ); if ( ref($_[0]) ) { @@ -1366,6 +1469,7 @@ sub email { sub email_subject { my $self = shift; + my $conf = $self->conf; #my $template = scalar(@_) ? shift : ''; #per-template? @@ -1397,6 +1501,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; @@ -1432,6 +1537,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; @@ -1452,7 +1560,10 @@ sub print { $self->batch_invoice(\%opt); } else { - do_print $self->lpr_data(\%opt); + do_print( + $self->lpr_data(\%opt), + 'agentnum' => $self->cust_main->agentnum, + ); } } @@ -1471,6 +1582,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; @@ -1508,14 +1622,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. @@ -1526,6 +1663,7 @@ TEMPLATENAME is unused? sub ftp_invoice { my $self = shift; + my $conf = $self->conf; my $template = scalar(@_) ? shift : ''; $self->send_csv( @@ -1548,6 +1686,7 @@ TEMPLATENAME is unused? sub spool_invoice { my $self = shift; + my $conf = $self->conf; my $template = scalar(@_) ? shift : ''; $self->spool_csv( @@ -1907,6 +2046,44 @@ sub print_csv { '0', # 29 | Other Taxes & Fees*** NUM* 9 ); + } elsif ( lc($opt{'format'}) eq 'oneline' ) { #name? + + my ($previous_balance) = $self->previous; + $previous_balance = sprintf('%.2f', $previous_balance); + my $totaldue = sprintf('%.2f', $self->owed + $previous_balance); + my @items = map { + $_->{pkgnum}, + $_->{description}, + $_->{amount} + } + $self->_items_pkg, #_items_nontax? no sections or anything + # with this format + $self->_items_tax; + + $csv->combine( + $cust_main->agentnum, + $cust_main->agent->agent, + $self->custnum, + $cust_main->first, + $cust_main->last, + $cust_main->company, + $cust_main->address1, + $cust_main->address2, + $cust_main->city, + $cust_main->state, + $cust_main->zip, + + # invoice fields + time2str("%x", $self->_date), + $self->invnum, + $self->charged, + $totaldue, + $previous_balance, + $self->due_date2str("%x"), + + @items, + ); + } else { $csv->combine( @@ -1946,6 +2123,10 @@ sub print_csv { } + } elsif ( lc($opt{'format'}) eq 'oneline' ) { + + #do nothing + } else { foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) { @@ -2057,6 +2238,7 @@ sub realtime_lec { sub realtime_bop { my( $self, $method ) = (shift,shift); + my $conf = $self->conf; my %opt = @_; my $cust_main = $self->cust_main; @@ -2156,7 +2338,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 ); } @@ -2185,6 +2367,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() }; @@ -2198,7 +2381,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; @@ -2250,6 +2433,7 @@ 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; @@ -2311,14 +2495,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; @@ -2559,6 +2745,7 @@ sub print_generic { #invoice info 'invnum' => $self->invnum, + '_date' => $self->_date, 'date' => time2str($date_format, $self->_date), 'today' => time2str($date_format_long, $today), 'terms' => $self->terms, @@ -2602,7 +2789,18 @@ 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 ) { @@ -2679,11 +2877,29 @@ sub print_generic { # 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'} = {}; + if ( $self->previous_bill ) { + $invoice_data{'last_bill'} = { + '_date' => $self->previous_bill->_date, #unformatted + # all we need for now + }; + } + my $summarypage = ''; if ( $conf->exists('invoice_usesummary', $agentnum) ) { $summarypage = 1; @@ -2737,9 +2953,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' => '', @@ -2768,10 +2987,10 @@ sub print_generic { warn "$me generating sections\n" if $DEBUG > 1; - my $previous_section = { 'description' => 'Previous Charges', + 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 '. join(' / ', map { $cust_main->balance_date_range(@$_) } @@ -2780,26 +2999,27 @@ sub print_generic { 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' : '', + 'tax_section' => 1, }; 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', - 'subtotal' => 0, # adjusted below - 'summarized' => $summarypage ? 'Y' : '', - }; + my $adjust_section = { + 'description' => $self->mt('Credits, Payments, and Adjustments'), + 'adjust_section' => 1, + 'subtotal' => 0, # adjusted below + }; 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'; @@ -2808,6 +3028,12 @@ sub print_generic { my $late_sections = []; my $extra_sections = []; my $extra_lines = (); + + my $default_section = { 'description' => '', + 'subtotal' => '', + 'no_subtotal' => 1, + }; + if ( $multisection ) { ($extra_sections, $extra_lines) = $self->_items_extra_usage_sections($escape_function_nonbsp, $format) @@ -2829,14 +3055,39 @@ sub print_generic { 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, $default_section; + # 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') - || $conf->exists('previous_balance-summary_only') - ) - { + # previous invoice balances in the Previous Charges section if there + # is one, otherwise in the main detail section + if ( $self->can('_items_previous') && + $self->enable_previous && + ! $conf->exists('previous_balance-summary_only') ) { warn "$me adding previous balances\n" if $DEBUG > 1; @@ -2847,8 +3098,10 @@ sub print_generic { ext_description => [], }; $detail->{'ref'} = $line_item->{'pkgnum'}; + $detail->{'pkgpart'} = $line_item->{'pkgpart'}; $detail->{'quantity'} = 1; - $detail->{'section'} = $previous_section; + $detail->{'section'} = $multisection ? $previous_section + : $default_section; $detail->{'description'} = &$escape_function($line_item->{'description'}); if ( exists $line_item->{'ext_description'} ) { @{$detail->{'ext_description'}} = map { @@ -2866,10 +3119,10 @@ sub print_generic { } } - - if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) { + + if ( @pr_cust_bill && $self->enable_previous ) { push @buf, ['','-----------']; - push @buf, [ 'Total Previous Balance', + push @buf, [ $self->mt('Total Previous Balance'), $money_char. sprintf("%10.2f", $pr_total) ]; push @buf, ['','']; } @@ -2881,10 +3134,9 @@ sub print_generic { 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 ], - } - if !$multisection; + { 'description' => $didsummary_desc, + 'ext_description' => [ $didsummary, $minutes ], + }; } foreach my $section (@sections, @$late_sections) { @@ -2925,7 +3177,7 @@ sub print_generic { $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'} = @@ -2945,6 +3197,7 @@ sub print_generic { ext_description => [], }; $detail->{'ref'} = $line_item->{'pkgnum'}; + $detail->{'pkgpart'} = $line_item->{'pkgpart'}; $detail->{'quantity'} = $line_item->{'quantity'}; $detail->{'section'} = $section; $detail->{'description'} = &$escape_function($line_item->{'description'}); @@ -2956,6 +3209,11 @@ 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'}; + $detail->{'svc_label'} = $line_item->{'svc_label'}; push @detail_items, $detail; push @buf, ( [ $detail->{'description'}, @@ -2968,7 +3226,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 ], [ '', '' ], [ '', '' ], @@ -2976,11 +3234,13 @@ sub print_generic { } } - + $invoice_data{current_less_finance} = sprintf('%.2f', $self->charged - $invoice_data{finance_amount} ); - if ( $multisection && !$conf->exists('disable_previous_balance') + # create a major section for previous balance if we have major sections, + # or if previous_section is in summary form + if ( ( $multisection && $self->enable_previous ) || $conf->exists('previous_balance-summary_only') ) { unshift @sections, $previous_section if $pr_total; @@ -3026,7 +3286,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 ); @@ -3043,37 +3303,39 @@ sub print_generic { $invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal); push @buf,['','-----------']; - push @buf,[( $conf->exists('disable_previous_balance') + push @buf,[$self->mt( + (!$self->enable_previous) ? 'Total Charges' : 'Total New Charges' ), $money_char. sprintf("%10.2f",$self->charged) ]; push @buf,['','']; + # calculate total, possibly including total owed on previous + # invoices { my $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); + my $amount = $self->charged; + if ( $self->enable_previous and !$conf->exists('previous_balance-exclude_from_total') ) { + $amount += $pr_total; + } + + $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{ + } else { push @total_items, $total; } push @buf,['','-----------']; @@ -3083,8 +3345,13 @@ sub print_generic { ]; push @buf,['','']; } - - unless ( $conf->exists('disable_previous_balance') ) { + + # if we're showing previous invoices, also show previous + # credits and payments + if ( $self->enable_previous + and $self->can('_items_credits') + and $self->can('_items_payments') ) + { #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments # credits @@ -3154,15 +3421,17 @@ sub print_generic { unless $adjust_section->{sort_weight}; } + # create Balance Due message { my $total; $total->{'total_item'} = &$embolden_function($self->balance_due_msg); $total->{'total_amount'} = &$embolden_function( - $other_money_char. sprintf('%.2f', $summarypage - ? $self->charged + - $self->billing_balance - : $self->owed + $pr_total + $other_money_char. sprintf('%.2f', #why? $summarypage + # ? $self->charged + + # $self->billing_balance + # : + $self->owed + $pr_total ) ); if ( $multisection && !$adjust_section->{sort_weight} ) { @@ -3214,6 +3483,30 @@ 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; + } + + # debugging hook: call this with 'diag' => 1 to just get a hash of + # the invoice variables + return \%invoice_data if ( $params{'diag'} ); + + # All sections and items are built; now fill in templates. my @includelist = (); push @includelist, 'summary' if $summarypage; foreach my $include ( @includelist ) { @@ -3276,28 +3569,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" @@ -3506,6 +3798,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; @@ -3534,10 +3827,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; } @@ -3546,6 +3840,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*$/ ) { @@ -3554,7 +3849,10 @@ sub balance_due_date { $duedate; } -sub credit_balance_msg { 'Credit Balance Remaining' } +sub credit_balance_msg { + my $self = shift; + $self->mt('Credit Balance Remaining') +} =item invnum_date_pretty @@ -3565,7 +3863,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 @@ -3579,6 +3877,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; @@ -3609,17 +3954,20 @@ sub _items_sections { if ( $display->post_total && !$summarypage ) { if (! $type || $type eq 'S') { $late_subtotal{$section} += $cust_bill_pkg->setup - if $cust_bill_pkg->setup != 0; + if $cust_bill_pkg->setup != 0 + || $cust_bill_pkg->setup_show_zero; } if (! $type) { $late_subtotal{$section} += $cust_bill_pkg->recur - if $cust_bill_pkg->recur != 0; + if $cust_bill_pkg->recur != 0 + || $cust_bill_pkg->recur_show_zero; } if ($type && $type eq 'R') { $late_subtotal{$section} += $cust_bill_pkg->recur - $usage - if $cust_bill_pkg->recur != 0; + if $cust_bill_pkg->recur != 0 + || $cust_bill_pkg->recur_show_zero; } if ($type && $type eq 'U') { @@ -3633,17 +3981,20 @@ sub _items_sections { if (! $type || $type eq 'S') { $subtotal{$section} += $cust_bill_pkg->setup - if $cust_bill_pkg->setup != 0; + if $cust_bill_pkg->setup != 0 + || $cust_bill_pkg->setup_show_zero; } if (! $type) { $subtotal{$section} += $cust_bill_pkg->recur - if $cust_bill_pkg->recur != 0; + if $cust_bill_pkg->recur != 0 + || $cust_bill_pkg->recur_show_zero; } if ($type && $type eq 'R') { $subtotal{$section} += $cust_bill_pkg->recur - $usage - if $cust_bill_pkg->recur != 0; + if $cust_bill_pkg->recur != 0 + || $cust_bill_pkg->recur_show_zero; } if ($type && $type eq 'U') { @@ -3971,6 +4322,7 @@ sub _condensed_total_line_generator { sub _items_extra_usage_sections { my $self = shift; + my $conf = $self->conf; my $escape = shift; my $format = shift; @@ -3978,6 +4330,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; @@ -3997,8 +4351,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), @@ -4081,7 +4435,7 @@ sub _did_summary { 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_inserted = $h_cust_svc->h_svc_x($inserted+5); my $phone_deleted; $phone_deleted = $h_cust_svc->h_svc_x($deleted) if $deleted; @@ -4114,10 +4468,13 @@ sub _did_summary { } # increment usage minutes - my @cdrs = $phone_inserted->get_cdrs('begin'=>$start,'end'=>$end); - foreach my $cdr ( @cdrs ) { - $minutes += $cdr->billsec/60; - } + 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; @@ -4130,8 +4487,82 @@ sub _did_summary { "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; @@ -4139,6 +4570,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 }; @@ -4168,8 +4601,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), @@ -4358,7 +4791,7 @@ sub _items_svc_phone_sections { } -sub _items { +sub _items { # seems to be unused my $self = shift; #my @display = scalar(@_) @@ -4377,6 +4810,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 = (); @@ -4385,7 +4819,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), @@ -4407,6 +4841,21 @@ 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 = @_; @@ -4466,65 +4915,108 @@ 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 $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 $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style + my @b = (); my ($s, $r, $u) = ( undef, undef, undef ); foreach my $cust_bill_pkg ( @$cust_bill_pkgs ) { - 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, { %$_ } - unless ( $_->{amount} == 0 && !$discount_show_always ); + if $_->{amount} != 0 + || $discount_show_always + || ( ! $_->{_is_setup} && $_->{recur_show_zero} ) + || ( $_->{_is_setup} && $_->{setup_show_zero} ) + ; $_ = undef; } } + my @cust_bill_pkg_display = $cust_bill_pkg->cust_bill_pkg_display; + + 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 } #grep { !$_->summary || !$summary_page } # bunk! grep { !$_->summary || $multisection } - $cust_bill_pkg->cust_bill_pkg_display + @cust_bill_pkg_display ) { - warn "$me _items_cust_bill_pkg considering display item $display\n" + 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 ) { @@ -4534,31 +5026,51 @@ sub _items_cust_bill_pkg { my $cust_pkg = $cust_bill_pkg->cust_pkg; - if ( $cust_bill_pkg->setup != 0 && (!$type || $type eq 'S') ) { + # which pkgpart to show for display purposes? + my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart; + + # start/end dates for invoice formats that do nonstandard + # things with them + my %item_dates = (); + %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate') + unless $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1); + + 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 = (); + my $svc_label; 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') + my @svc_labels = map &{$escape_function}($_), + $cust_pkg->h_labels_short($self->_date, undef, 'I'); + push @d, @svc_labels unless $cust_bill_pkg->pkgpart_override; #don't redisplay services + $svc_label = $svc_labels[0]; if ( $multilocation ) { my $loc = $cust_pkg->location_label; - $loc = substr($loc, 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; @@ -4569,21 +5081,28 @@ sub _items_cust_bill_pkg { push @{ $s->{ext_description} }, @d; } else { $s = { + _is_setup => 1, description => $description, - #pkgpart => $part_pkg->pkgpart, + pkgpart => $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, + svc_label => ($svc_label || ''), }; }; } - 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' ) + 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 + ) ) { @@ -4594,12 +5113,43 @@ sub _items_cust_bill_pkg { my $description = ($is_summary && $type && $type eq 'U') ? "Usage charges" : $desc; - $description .= " (" . time2str($date_format, $cust_bill_pkg->sdate). - " - ". time2str($date_format, $cust_bill_pkg->edate). - ")" - unless $conf->exists('disable_line_item_date_ranges'); + my $part_pkg = $cust_pkg->part_pkg; + + #pry be a bit more efficient to look some of this conf stuff up + # outside the loop + unless ( + $conf->exists('disable_line_item_date_ranges') + || $part_pkg->option('disable_line_item_date_ranges',1) + || ! $cust_bill_pkg->sdate + || ! $cust_bill_pkg->edate + ) { + my $time_period; + my $date_style = ''; + $date_style = $conf->config( 'cust_bill-line_item-date_style-non_monthly', + $cust_main->agentnum + ) + if $part_pkg && $part_pkg->freq !~ /^1m?$/; + $date_style ||= $conf->config( 'cust_bill-line_item-date_style', + $cust_main->agentnum + ); + if ( defined($date_style) && $date_style eq 'month_of' ) { + $time_period = time2str('The month of %B', $cust_bill_pkg->sdate); + } elsif ( defined($date_style) && $date_style eq 'X_month' ) { + my $desc = $conf->config( 'cust_bill-line_item-date_description', + $cust_main->agentnum + ); + $desc .= ' ' unless $desc =~ /\s$/; + $time_period = $desc. time2str('%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 + my $svc_label = ''; #at least until cust_bill_pkg has "past" ranges in addition to #the "future" sdate/edate ones... see #3032 @@ -4617,22 +5167,43 @@ sub _items_cust_bill_pkg { 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) + my @svc_labels = map &{$escape_function}($_), + $cust_pkg->h_labels_short(@dates, 'I'); + push @d, @svc_labels unless $cust_bill_pkg->pkgpart_override; #don't redisplay services + $svc_label = $svc_labels[0]; 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; + $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 + } unless ( $is_summary ) { @@ -4641,7 +5212,7 @@ sub _items_cust_bill_pkg { #instead of omitting details entirely in this case (unwanted side # effects), just omit CDRs - $details_opt{'format_function'} = sub { () } + $details_opt{'no_usage'} = 1 if $type && $type eq 'R'; push @d, $cust_bill_pkg->details(%details_opt); @@ -4671,13 +5242,17 @@ sub _items_cust_bill_pkg { } else { $r = { description => $description, - #pkgpart => $part_pkg->pkgpart, + pkgpart => $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, + svc_label => ($svc_label || ''), }; + $r->{'seconds'} = \@seconds if grep {defined $_} @seconds; } } else { # $type eq 'U' @@ -4692,15 +5267,16 @@ sub _items_cust_bill_pkg { } else { $u = { description => $description, - #pkgpart => $part_pkg->pkgpart, + pkgpart => $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 @@ -4729,10 +5305,10 @@ sub _items_cust_bill_pkg { } - } + $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount + && $conf->exists('discount-show-always')); - warn "$me _items_cust_bill_pkg done considering cust_bill_pkgs\n" - if $DEBUG > 1; + } foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) { if ( $_ ) { @@ -4740,10 +5316,16 @@ sub _items_cust_bill_pkg { $_->{amount} =~ s/^\-0\.00$/0.00/; $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ), push @b, { %$_ } - unless ( $_->{amount} == 0 && !$discount_show_always ); + 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; } @@ -4754,21 +5336,34 @@ sub _items_credits { my @b; #credits - foreach ( $self->cust_credited ) { + my @objects; + if ( $self->conf->exists('previous_balance-payments_since') ) { + my $date = 0; + $date = $self->previous_bill->_date if $self->previous_bill; + @objects = qsearch('cust_credit', { + 'custnum' => $self->custnum, + '_date' => {op => '>=', value => $date}, + }); + # hard to do this in the qsearch... + @objects = grep { $_->_date < $self->_date } @objects; + } else { + @objects = $self->cust_credited; + } - #something more elaborate if $_->amount ne $_->cust_credit->credited ? + foreach my $obj ( @objects ) { + my $cust_credit = $obj->isa('FS::cust_credit') ? $obj : $obj->cust_credit; - my $reason = substr($_->cust_credit->reason, 0, $trim_len); - $reason .= '...' if length($reason) < length($_->cust_credit->reason); + my $reason = substr($cust_credit->reason, 0, $trim_len); + $reason .= '...' if length($reason) < length($cust_credit->reason); $reason = " ($reason) " if $reason; push @b, { #'description' => 'Credit ref\#'. $_->crednum. # " (". time2str("%x",$_->cust_credit->_date) .")". # $reason, - 'description' => 'Credit applied '. - time2str($date_format,$_->cust_credit->_date). $reason, - 'amount' => sprintf("%.2f",$_->amount), + 'description' => $self->mt('Credit applied').' '. + time2str($date_format,$obj->_date). $reason, + 'amount' => sprintf("%.2f",$obj->amount), }; } @@ -4780,15 +5375,31 @@ sub _items_payments { my $self = shift; my @b; - #get & print payments - foreach ( $self->cust_bill_pay ) { + my $detailed = $self->conf->exists('invoice_payment_details'); + my @objects; + if ( $self->conf->exists('previous_balance-payments_since') ) { + my $date = 0; + $date = $self->previous_bill->_date if $self->previous_bill; + @objects = qsearch('cust_pay', { + 'custnum' => $self->custnum, + '_date' => {op => '>=', value => $date}, + }); + @objects = grep { $_->_date < $self->_date } @objects; + } else { + @objects = $self->cust_bill_pay; + } - #something more elaborate if $_->amount ne ->cust_pay->paid ? + foreach my $obj (@objects) { + my $cust_pay = $obj->isa('FS::cust_pay') ? $obj : $obj->cust_pay; + my $desc = $self->mt('Payment received').' '. + time2str($date_format, $cust_pay->_date ); + $desc .= $self->mt(' via ') . + $cust_pay->payby_payinfo_pretty( $self->cust_main->locale ) + if $detailed; push @b, { - 'description' => "Payment received ". - time2str($date_format,$_->cust_pay->_date ), - 'amount' => sprintf("%.2f", $_->amount ) + 'description' => $desc, + 'amount' => sprintf("%.2f", $obj->amount ) }; } @@ -4796,6 +5407,51 @@ 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; + + # discounts for non-integer months don't work anyway + $months = sprintf("%d", $months); + + +{ + 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 @@ -5012,6 +5668,7 @@ Currently only supported on PostgreSQL. =cut sub due_date_sql { + my $conf = new FS::Conf; 'COALESCE( SUBSTRING( COALESCE( @@ -5080,6 +5737,25 @@ sub search_sql_where { push @search, "cust_main.agentnum = $1"; } + #refnum + if ( $param->{'refnum'} =~ /^(\d+)$/ ) { + push @search, "cust_main.refnum = $1"; + } + + #custnum + if ( $param->{'custnum'} =~ /^(\d+)$/ ) { + push @search, "cust_bill.custnum = $1"; + } + + #customer classnum + if ( $param->{'cust_classnum'} ) { + my $classnums = $param->{'cust_classnum'}; + $classnums = [ $classnums ] if !ref($classnums); + $classnums = [ grep /^\d+$/, @$classnums ]; + push @search, 'cust_main.classnum in ('.join(',',@$classnums).')' + if @$classnums; + } + #_date if ( $param->{_date} ) { my($beginning, $ending) = @{$param->{_date}}; @@ -5151,6 +5827,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'