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;
use FS::cust_bill_batch;
use FS::cust_bill_pay_pkg;
use FS::cust_credit_bill_pkg;
+use FS::L10N;
@ISA = qw( FS::cust_main_Mixin FS::Record );
#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
cust_pay_batch
cust_bill_pay_batch
cust_bill_pkg
+ cust_bill_batch
)) {
foreach my $linked ( $self->$table() ) {
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 {
}
+=item cust_bill_batch
+
+Returns all invoice batch records (L<FS::cust_bill_batch>) for this invoice.
+
+=cut
+
+sub cust_bill_batch {
+ my $self = shift;
+ qsearch('cust_bill_batch', { 'invnum' => $self->invnum });
+}
+
=item tax
Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
sub apply_payments_and_credits {
my( $self, %options ) = @_;
+ my $conf = $self->conf;
local $SIG{HUP} = 'IGNORE';
local $SIG{INT} = 'IGNORE';
my $self = shift;
my %args = @_;
+ my $conf = $self->conf;
my $me = '[FS::cust_bill::generate_email]';
my $alternative = build MIME::Entity
'Type' => 'multipart/alternative',
- 'Encoding' => '7bit',
+ #'Encoding' => '7bit',
'Disposition' => 'inline'
;
$alternative->attach(
'Type' => 'text/plain',
- #'Encoding' => 'quoted-printable',
- 'Encoding' => '7bit',
+ 'Encoding' => 'quoted-printable',
+ #'Encoding' => '7bit',
'Data' => $data,
'Disposition' => 'inline',
);
sub send {
my $self = shift;
+ my $conf = $self->conf;
my( $template, $invoice_from, $notice_name );
my $agentnums = '';
#sub email_invoice {
sub email {
my $self = shift;
+ my $conf = $self->conf;
my( $template, $invoice_from, $notice_name, $no_coupon );
if ( ref($_[0]) ) {
sub email_subject {
my $self = shift;
+ my $conf = $self->conf;
#my $template = scalar(@_) ? shift : '';
#per-template?
sub lpr_data {
my $self = shift;
+ my $conf = $self->conf;
my( $template, $notice_name );
if ( ref($_[0]) ) {
my $opt = shift;
#sub print_invoice {
sub print {
my $self = shift;
+ my $conf = $self->conf;
my( $template, $notice_name );
if ( ref($_[0]) ) {
my $opt = shift;
sub fax_invoice {
my $self = shift;
+ my $conf = $self->conf;
my( $template, $notice_name );
if ( ref($_[0]) ) {
my $opt = shift;
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.
sub ftp_invoice {
my $self = shift;
+ my $conf = $self->conf;
my $template = scalar(@_) ? shift : '';
$self->send_csv(
sub spool_invoice {
my $self = shift;
+ my $conf = $self->conf;
my $template = scalar(@_) ? shift : '';
$self->spool_csv(
sub realtime_bop {
my( $self, $method ) = (shift,shift);
+ my $conf = $self->conf;
my %opt = @_;
my $cust_main = $self->cust_main;
$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 );
}
sub print_latex {
my $self = shift;
+ my $conf = $self->conf;
my( $today, $template, %opt );
if ( ref($_[0]) ) {
%opt = %{ shift() };
$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;
SUFFIX => '.tex',
UNLINK => 0,
) or die "can't open temp file: $!\n";
+ binmode($fh, ':utf8'); # language support
print $fh join('', @filled_in );
close $fh;
# (alignment in text invoice?) problems to change them all to '%.2f' ?
# yes: fixed width (dot matrix) text printing will be borked
sub print_generic {
-
my( $self, %params ) = @_;
+ 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;
'total_pages' => 1,
);
-
+
+ #localization
+ my $lh = FS::L10N->get_handle($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 ) {
# 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;
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' => '',
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' : '',
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 $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' : '',
};
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')
}
}
-
+
if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) {
push @buf, ['','-----------'];
- push @buf, [ 'Total Previous Balance',
+ push @buf, [ $self->mt('Total Previous Balance'),
$money_char. sprintf("%10.2f", $pr_total) ];
push @buf, ['',''];
}
if $DEBUG > 1;
my ($didsummary,$minutes) = $self->_did_summary;
- my $didsummary_desc = 'DID Activity Summary (Past 30 days)';
+ 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) {
$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'},
if ( $section->{'description'} ) {
push @buf, ( ['','-----------'],
[ $section->{'description'}. ' sub-total',
- $money_char. sprintf("%10.2f", $section->{'subtotal'})
+ $section->{'subtotal'} # already formatted this
],
[ '', '' ],
[ '', '' ],
}
}
-
+
$invoice_data{current_less_finance} =
sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
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 );
$invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
push @buf,['','-----------'];
- push @buf,[( $conf->exists('disable_previous_balance')
+ push @buf,[$self->mt(
+ $conf->exists('disable_previous_balance')
? 'Total Charges'
: 'Total New Charges'
),
? 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;
unless $adjust_section->{sort_weight};
}
+ # create Balance Due message
{
my $total;
$total->{'total_item'} = &$embolden_function($self->balance_due_msg);
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 ) {
}
#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"
sub terms {
my $self = shift;
+ my $conf = $self->conf;
#check for an invoice-specific override
return $self->invoice_terms if $self->invoice_terms;
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;
}
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*$/ ) {
$duedate;
}
-sub credit_balance_msg { 'Credit Balance Remaining' }
+sub credit_balance_msg {
+ my $self = shift;
+ $self->mt('Credit Balance Remaining')
+}
=item invnum_date_pretty
sub invnum_date_pretty {
my $self = shift;
- 'Invoice #'. $self->invnum. ' ('. $self->_date_pretty. ')';
+ $self->mt('Invoice #'). $self->invnum. ' ('. $self->_date_pretty. ')';
}
=item _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<FS::cust_bill_pkg> record), this will fetch all
+related display records (L<FS::cust_bill_pkg_display>) 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;
}
} @sections;
push @early, @$extra_sections if $extra_sections;
-
+
sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
}
sub _items_extra_usage_sections {
my $self = shift;
+ my $conf = $self->conf;
my $escape = shift;
my $format = shift;
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;
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),
sub _did_summary {
my $self = shift;
my $end = $self->_date;
- my $start = $end - 2592000; # 30 days
+
+ # 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)
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;
}
# 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;
"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;
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 };
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),
}
-sub _items {
+sub _items { # seems to be unused
my $self = shift;
#my @display = scalar(@_)
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 = ();
? '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),
#};
}
+=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 = @_;
$self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
}
+=item _items_cust_bill_pkg CUST_BILL_PKGS OPTIONS
+
+Takes an arrayref of L<FS::cust_bill_pkg> 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.
+
+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 $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_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;
}
}
+ 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
)
{
- 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,
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
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;
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,
}
- 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
+ )
)
{
$description .= " (" . time2str($date_format, $cust_bill_pkg->sdate).
" - ". time2str($date_format, $cust_bill_pkg->edate).
")"
- unless $conf->exists('disable_line_item_date_ranges');
+ unless $conf->exists('disable_line_item_date_ranges')
+ || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
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
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 ) {
#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'
#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
}
- }
+ $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 ( $_ ) {
$_->{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;
}
#'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),
};
#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 )
};
}
+=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 %terms;
+ my $list_pkgnums = 0; # if any packages are not eligible for all discounts
+
+ my ($previous_balance) = $self->previous;
+
+ foreach (qsearch('discount',{ 'months' => { op => '>', value => 1} })) {
+ $terms{$_->months} = {
+ pkgnums => [],
+ base => $previous_balance || 0, # pre-discount sum of charges
+ discounted => $previous_balance || 0, # post-discount sum
+ list_pkgnums => 0, # whether any packages are not discounted
+ }
+ }
+ foreach my $months (keys %terms) {
+ my $hash = $terms{$months};
+
+ # tricky, because packages may not all be eligible for the same discounts
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+ my $cust_pkg = $cust_bill_pkg->cust_pkg or next;
+ my $part_pkg = $cust_pkg->part_pkg or next;
+
+ next if $part_pkg->freq ne '1';
+ my $setup = $cust_bill_pkg->setup || 0;
+ my $recur = $cust_bill_pkg->recur || 0;
+ my $permonth = $part_pkg->base_recur_permonth || 0;
+
+ my ($discount) = grep { $_->months == $months }
+ map { $_->discount } $part_pkg->part_pkg_discount;
+
+ $hash->{base} += $setup + $recur + ($months - 1) * $permonth;
+ if ($discount) {
+
+ my $discountable;
+ if ( $discount->setup ) {
+ $discountable += $setup;
+ }
+ else {
+ $hash->{discounted} += $setup;
+ }
+
+ if ( $discount->percent ) {
+ $discountable += $months * $permonth;
+ $discountable -= ($discountable * $discount->percent / 100);
+ $discountable -= ($permonth - $recur); # correct for prorate
+ $hash->{discounted} += $discountable;
+ }
+ else {
+ $discountable += $recur;
+ $discountable -= $discount->amount * $recur/$permonth;
+
+ $discountable += ($months - 1) * max($permonth - $discount->amount,0);
+ }
+
+ $hash->{discounted} += $discountable;
+ push @{ $hash->{pkgnums} }, $cust_pkg->pkgnum;
+ }
+ else { #no discount
+ $hash->{discounted} += $setup + $recur + ($months - 1) * $permonth;
+ $hash->{list_pkgnums} = 1;
+ }
+ } #foreach $cust_bill_pkg
+
+ # don't show this line if no packages have discounts at this term
+ # or if there are no new charges to apply the discount to
+ delete $terms{$months} if $hash->{base} == $hash->{discounted}
+ or $hash->{base} == 0;
+
+ }
+
+ $list_pkgnums = grep { $_->{list_pkgnums} > 0 } values %terms;
+
+ foreach my $months (keys %terms) {
+ my $hash = $terms{$months};
+ my $term_total = sprintf('%.2f', $hash->{discounted});
+ # possibly shouldn't include previous balance in these?
+ my $percent = sprintf('%.0f', 100 * (1 - $term_total / $hash->{base}) );
+ my $permonth = sprintf('%.2f', $term_total / $months);
+
+ $hash->{description} = $self->mt('Save [_1]% by paying for [_2] months',
+ $percent, $months
+ );
+ $hash->{amount} = $self->mt('[_1] ([_2] per month)',
+ $term_total, $money_char.$permonth
+ );
+
+ my @detail;
+ if ( $list_pkgnums ) {
+ push @detail, $self->mt('discount on item'). ' '.
+ join(', ', map { "#$_" } @{ $hash->{pkgnums} });
+ }
+ $hash->{ext_description} = join ', ', @detail;
+ }
+
+ map { $terms{$_} } sort {$b <=> $a} keys %terms;
+}
+
=item call_details [ OPTION => VALUE ... ]
Returns an array of CSV strings representing the call details for this invoice
=cut
sub due_date_sql {
+ my $conf = new FS::Conf;
'COALESCE(
SUBSTRING(
COALESCE(
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}};