);
# but NOT $conf
use vars qw( $invoice_lines @buf ); #yuck
-use List::Util qw(sum);
+use List::Util qw(sum); #can't import first, it conflicts with cust_main.first
use Date::Format;
use Date::Language;
+use Time::Local qw( timelocal );
use Text::Template 1.20;
use File::Temp 0.14;
+use Archive::Zip qw( :ERROR_CODES :CONSTANTS );
+use IO::Scalar;
use HTML::Entities;
-use Locale::Country;
use Cwd;
use FS::UID;
-use FS::Record qw( qsearch qsearchs );
+use FS::Misc qw( send_email );
+use FS::Record qw( qsearch qsearchs dbh );
use FS::Conf;
use FS::Misc qw( generate_ps generate_pdf );
use FS::pkg_category;
$template ||= $self->_agent_template
if $self->can('_agent_template');
+ #the new way
+ $self->set('mode', $params{mode})
+ if $params{mode};
+
my $pkey = $self->primary_key;
my $tmp_template = $self->table. '.'. $self->$pkey. '.XXXXXXXX';
=item template
-Dprecated. Used as a suffix for a configuration template. Please
+Deprecated. Used as a suffix for a configuration template. Please
don't use this, it deprecated in favor of more flexible alternatives.
=back
if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
#change this to a die when the old code is removed
- # it's been almost ten years, changing it to a die.
- die "old-style invoice template $templatefile; ".
+ # it's been almost ten years, changing it to a die on the next release.
+ warn "old-style invoice template $templatefile; ".
"patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
#$old_latex = 'true';
#@invoice_template = _translate_old_latex_format(@invoice_template);
'notice_name' => $notice_name, # escape?
'current_charges' => sprintf("%.2f", $self->charged),
'duedate' => $self->due_date2str('rdate'), #date_format?
+ 'duedate_long' => $self->due_date2str('long'),
#customer info
'custnum' => $cust_main->display_custnum,
)),
#global config
- 'ship_enable' => $conf->exists('invoice-ship_address'),
+ 'ship_enable' => $cust_main->invoice_ship_address || $conf->exists('invoice-ship_address'),
'unitprices' => $conf->exists('invoice-unitprice'),
'smallernotes' => $conf->exists('invoice-smallernotes'),
'smallerfooter' => $conf->exists('invoice-smallerfooter'),
if ( $cust_main->country eq $countrydefault ) {
$invoice_data{'country'} = '';
} else {
- $invoice_data{'country'} = &$escape_function(code2country($cust_main->country));
+ $invoice_data{'country'} = &$escape_function($cust_main->bill_country_full);
}
my @address = ();
my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
# my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
#my $balance_due = $self->owed + $pr_total - $cr_total;
- my $balance_due = $self->owed + $pr_total;
+ my $balance_due = $self->owed;
+ if ( $self->enable_previous ) {
+ $balance_due += $pr_total;
+ }
+ # otherwise the previous balance is not shown, so including it in the
+ # balance due is just confusing
# the sum of amount owed on all invoices
# (this is used in the summary & on the payment coupon)
$invoice_data{'balance'} = sprintf("%.2f", $balance_due);
- # info from customer's last invoice before this one, for some
- # summary formats
- $invoice_data{'last_bill'} = {};
+ # flag telling this invoice to have a first-page summary
+ my $summarypage = '';
if ( $self->custnum && $self->invnum ) {
+ # XXX should be an FS::cust_bill method to set the defaults, instead
+ # of checking the type here
+ # info from customer's last invoice before this one, for some
+ # summary formats
+ $invoice_data{'last_bill'} = {};
+
my $last_bill = $self->previous_bill;
if ( $last_bill ) {
# "balance_date_range" unfortunately is unsuitable for this, since it
# cares about application dates. We want to know the sum of all
# _top-level transactions_ dated before the last invoice.
- my @sql = (
- 'SELECT SUM(charged) FROM cust_bill WHERE _date <= ? AND custnum = ?',
- 'SELECT -1*SUM(amount) FROM cust_credit WHERE _date <= ? AND custnum = ?',
- 'SELECT -1*SUM(paid) FROM cust_pay WHERE _date <= ? AND custnum = ?',
- 'SELECT SUM(refund) FROM cust_refund WHERE _date <= ? AND custnum = ?',
- );
+ #
+ # still do this for the "Previous Balance" line of the summary block
+ my @sql =
+ map "$_ WHERE _date <= ? AND custnum = ?", (
+ "SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill",
+ "SELECT -1 * COALESCE( SUM(amount), 0 ) FROM cust_credit",
+ "SELECT -1 * COALESCE( SUM(paid), 0 ) FROM cust_pay",
+ "SELECT COALESCE( SUM(refund), 0 ) FROM cust_refund",
+ );
# the customer's current balance immediately after generating the last
# bill
my $last_bill_balance = $last_bill->charged;
foreach (@sql) {
- #warn "$_\n";
my $delta = FS::Record->scalar_sql(
$_,
$last_bill->_date - 1,
$self->custnum,
);
- #warn "$delta\n";
$last_bill_balance += $delta;
}
# longer stored in the database)
$invoice_data{'true_previous_balance'} = $last_bill_balance;
- # the change in balance from immediately after that invoice
- # to immediately before this one
- my $before_this_bill_balance = 0;
+ # Now, get all applications of credits/payments dated on or after the
+ # previous bill, to invoices before the current bill. (The
+ # credit/payment date restriction prevents these from intersecting
+ # the "Previous Balance" set.)
+ # These are "adjustments". The past due balance will be shown as
+ # Previous Balance - Adjustments.
+ my $adjustments = 0;
+ @sql = map {
+ "SELECT COALESCE(SUM(y.amount),0) FROM $_ JOIN cust_bill USING (invnum)
+ WHERE cust_bill._date < ?
+ AND x._date >= ?
+ AND cust_bill.custnum = ?"
+ } "cust_credit AS x JOIN cust_credit_bill y USING (crednum)",
+ "cust_pay AS x JOIN cust_bill_pay y USING (paynum)"
+ ;
foreach (@sql) {
- #warn "$_\n";
my $delta = FS::Record->scalar_sql(
$_,
- $self->_date - 1,
+ $self->_date,
+ $last_bill->_date,
$self->custnum,
);
- #warn "$delta\n";
- $before_this_bill_balance += $delta;
+ $adjustments += $delta;
}
- $invoice_data{'balance_adjustments'} =
- sprintf("%.2f", $last_bill_balance - $before_this_bill_balance);
+ $invoice_data{'balance_adjustments'} = sprintf("%.2f", $adjustments);
warn sprintf("BALANCE ADJUSTMENTS: %.2f\n\n",
$invoice_data{'balance_adjustments'}
# ($pr_total is used elsewhere but not as $previous_balance)
$invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
- $invoice_data{'last_bill'} = {
- '_date' => $last_bill->_date, #unformatted
- };
+ $invoice_data{'last_bill'}{'_date'} = $last_bill->_date; #unformatted
my (@payments, @credits);
# for formats that itemize previous payments
foreach my $cust_pay ( qsearch('cust_pay', {
$invoice_data{'previous_payments'} = [];
$invoice_data{'previous_credits'} = [];
}
- } # if this is an invoice
+
+ if ( $conf->config_bool('invoice_usesummary', $agentnum) ) {
+ $invoice_data{'summarypage'} = $summarypage = 1;
+ }
- my $summarypage = '';
- if ( $conf->exists('invoice_usesummary', $agentnum) ) {
- $summarypage = 1;
- }
- $invoice_data{'summarypage'} = $summarypage;
+ } # if this is an invoice
warn "$me substituting variables in notes, footer, smallfooter\n"
if $DEBUG > 1;
my @include = ( [ $tc, 'notes' ],
[ 'invoice_', 'footer' ],
[ 'invoice_', 'smallfooter', ],
+ [ 'invoice_', 'watermark' ],
);
push @include, [ $tc, 'coupon', ]
unless $params{'no_coupon'};
foreach my $i (@include) {
+ # load the configuration for this sub-template
+
my($base, $include) = @$i;
my $inc_file = $conf->key_orbase("$base$format$include", $template);
- my @inc_src;
-
- if ( $conf->exists($inc_file, $agentnum)
- && length( $conf->config($inc_file, $agentnum) ) ) {
-
- @inc_src = $conf->config($inc_file, $agentnum);
-
- } else {
-
- $inc_file = $conf->key_orbase("${base}latex$include", $template);
-
- my $convert_map = $convert_maps{$format}{$include};
- @inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
- s/--\@\]/$delimiters{$format}[1]/g;
- $_;
- }
- &$convert_map( $conf->config($inc_file, $agentnum) );
+ my @inc_src = $conf->config($inc_file, $agentnum);
+ if (!@inc_src) {
+ my $converter = $convert_maps{$format}{$include};
+ if ( $converter ) {
+ # then attempt to convert LaTeX to the requested format
+ $inc_file = $conf->key_orbase($base.'latex'.$include, $template);
+ @inc_src = &$converter( $conf->config($inc_file, $agentnum) );
+ foreach (@inc_src) {
+ # this isn't included in the convert_maps
+ my ($open, $close) = @{ $delimiters{$format} };
+ s/\[\@--/$open/g;
+ s/--\@\]/$close/g;
+ }
+ }
+ } # else @inc_src is empty and that's fine
- }
+ # make a Text::Template out of it
my $inc_tt = new Text::Template (
TYPE => 'ARRAY',
die $error;
}
+ # fill in variables
+
$invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data );
$invoice_data{$include} =~ s/\n+$//
warn "$me generating sections\n"
if $DEBUG > 1;
- my $taxtotal = 0;
- my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
- 'subtotal' => $taxtotal, # adjusted below
- 'tax_section' => 1,
- };
- my $tax_weight = _pkg_category($tax_section->{description})
- ? _pkg_category($tax_section->{description})->weight
- : 0;
- $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
- $tax_section->{'sort_weight'} = $tax_weight;
-
- my $adjusttotal = 0;
- 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'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
- $adjust_section->{'sort_weight'} = $adjust_weight;
-
my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
- my $multisection = $conf->exists($tc.'sections', $cust_main->agentnum) ||
- $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
- $invoice_data{'multisection'} = $multisection;
+ my $multisection = $self->has_sections;
+ if ( $multisection ) {
+ $invoice_data{multisection} = $conf->config($tc.'sections_method') || 1;
+ }
+ my $section_with_taxes = 1
+ if $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum);
my $late_sections;
my $extra_sections = [];
my $extra_lines = ();
$previous_section = $default_section;
}
+ 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'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
+ # Note: 'sort_weight' here is actually a flag telling whether there is an
+ # explicit package category for the adjust section. If so, certain behavior
+ # happens.
+ $adjust_section->{'sort_weight'} = $adjust_weight;
+
+
if ( $multisection ) {
($extra_sections, $extra_lines) =
$self->_items_extra_usage_sections($escape_function_nonbsp, $format)
# start setting up summary subtotals
my @summary_subtotals;
my $method = $conf->config('summary_subtotals_method');
- if ( $method and $method ne $conf->config($tc.'sections_method') ) {
+ if ( ( ref($self) ne 'FS::quotation' ) and $method and $method ne $conf->config($tc.'sections_method') ) {
# then re-section them by the correct method
my %section_method = ( by_category => 1 );
if ( $conf->config('summary_subtotals_method') eq 'location' ) {
}
} else {
# subtotal sectioning is the same as for the actual invoice sections
- @summary_subtotals = @sections;
+ @summary_subtotals = grep $_->{subtotal}, @sections;
}
# Hereafter, push sections to both @sections and @summary_subtotals
if ( $invoice_data{finance_section} &&
$section->{'description'} eq $invoice_data{finance_section} );
- $section->{'subtotal'} = $other_money_char.
- sprintf('%.2f', $section->{'subtotal'})
- if $multisection;
+ if ( $multisection ) {
+
+ if ( ref($section->{'subtotal'}) ) {
- # continue some normalization
- $section->{'amount'} = $section->{'subtotal'}
- if $multisection;
+ $section->{'subtotal'} =
+ sprintf("$other_money_char%.2f to $other_money_char%.2f",
+ $section->{'subtotal'}[0],
+ $section->{'subtotal'}[1]
+ );
+
+ } else {
+
+ $section->{'subtotal'} = $other_money_char.
+ sprintf('%.2f', $section->{'subtotal'})
+
+ }
+ # continue some normalization
+ $section->{'amount'} = $section->{'subtotal'}
+
+ }
if ( $section->{'description'} ) {
push @buf, ( [ &$escape_function($section->{'description'}), '' ],
my %options = ();
$options{'section'} = $section if $multisection;
+ $options{'section_with_taxes'} = 1
+ if $multisection
+ && $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum);
$options{'format'} = $format;
$options{'escape_function'} = $escape_function;
$options{'no_usage'} = 1 unless $unsquelched;
$options{'skip_usage'} =
scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
$options{'preref_callback'} = $params{'preref_callback'};
+ $options{'disable_line_item_date_ranges'} =
+ $conf->exists('disable_line_item_date_ranges');
warn "$me searching for line items\n"
if $DEBUG > 1;
+ my %section_tax_lines;
+ my %seen_tax_lines;
+
foreach my $line_item ( $self->_items_pkg(%options),
$self->_items_fee(%options) ) {
join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
if $DEBUG > 1;
+ push @buf, ( [ $line_item->{'description'},
+ $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+ ],
+ map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
+ );
+
$line_item->{'ref'} = $line_item->{'pkgnum'};
$line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()?
$line_item->{'section'} = $section;
$line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'};
}
$line_item->{'ext_description'} ||= [];
-
+
+ if ( $section_with_taxes && ref $line_item->{pkg_tax} ) {
+ for my $line_tax ( @{$ line_item->{pkg_tax} } ) {
+
+ # It is rarely possible for the same tax record to be presented here
+ # multiple times. See cust_bill_pkg::_pkg_tax_list for more info
+ next if $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} };
+ $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} } = 1;
+
+ $section_tax_lines{ $line_tax->{taxname} } += $line_tax->{amount};
+ }
+ }
+
push @detail_items, $line_item;
- push @buf, ( [ $line_item->{'description'},
- $money_char. sprintf("%10.2f", $line_item->{'amount'}),
- ],
- map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
- );
+ }
+
+ # If conf flag invoice_sections_with_taxes:
+ # - Add @detail_items for taxes into each section
+ # - Update section subtotal to include taxes
+ if ( $section_with_taxes && %section_tax_lines ) {
+ for my $taxname ( keys %section_tax_lines ) {
+
+ push @detail_items, {
+ section => $section,
+ amount => sprintf($money_char."%.2f",$section_tax_lines{$taxname}),
+ description => &$escape_function($taxname),
+ };
+
+ # Append taxes to total. If line format resembles "$5.00 to $12.00"
+ # append to the second value.
+ if ($section->{subtotal} =~ /to/) {
+ my @subtotal = split /\s/, $section->{subtotal};
+ $subtotal[2] =~ s/[^\d\.]//g;
+ $subtotal[2] = sprintf(
+ $money_char."%.2f",
+ ( $subtotal[2] + $section_tax_lines{$taxname} )
+ );
+ $section->{subtotal} = join ' ', @subtotal;
+ } else {
+ $section->{subtotal} =~ s/[^\d\.]//g;
+ $section->{subtotal} = sprintf(
+ $money_char . "%.2f",
+ ( $section->{subtotal} + $section_tax_lines{$taxname} )
+ );
+ }
+
+ }
}
if ( $section->{'description'} ) {
warn "$me adding taxes\n"
if $DEBUG > 1;
+ # create a tax section if we don't yet have one
+ my $tax_description = 'Taxes, Surcharges, and Fees';
+ my $tax_section =
+ List::Util::first { $_->{description} eq $tax_description } @sections;
+ if (!$tax_section) {
+ $tax_section = { 'description' => $tax_description };
+ push @sections, $tax_section if $multisection;
+ }
+ $tax_section->{tax_section} = 1; # mark this section as containing taxes
+ # if this is an existing tax section, we're merging the tax items into it.
+ # grab the taxtotal that's already there, strip the money symbol if any
+ my $taxtotal = $tax_section->{'subtotal'} || 0;
+ $taxtotal =~ s/^\Q$other_money_char\E//;
+
+ # this does nothing
+ #my $tax_weight = _pkg_category($tax_section->{description})
+ # ? _pkg_category($tax_section->{description})->weight
+ # : 0;
+ #$tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
+ #$tax_section->{'sort_weight'} = $tax_weight;
+
my @items_tax = $self->_items_tax;
foreach my $tax ( @items_tax ) {
];
}
-
+
if ( @items_tax ) {
my $total = {};
$total->{'total_item'} = $self->mt('Sub-total');
$other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
if ( $multisection ) {
- $tax_section->{'subtotal'} = $other_money_char.
- sprintf('%.2f', $taxtotal);
- $tax_section->{'pretotal'} = 'New charges sub-total '.
- $total->{'total_amount'};
- if ( $taxtotal ) {
- push @sections, $tax_section;
- push @summary_subtotals, $tax_section;
+ if ( $taxtotal > 0 ) {
+ # there are taxes, so prepare the section to be displayed.
+ # $taxtotal already includes any line items that were already in the
+ # section (fees, taxes that are charged as packages for some reason).
+ # also set 'summarized' to false so that this isn't a summary-only
+ # section.
+ $tax_section->{'subtotal'} = $other_money_char.
+ sprintf('%.2f', $taxtotal);
+ $tax_section->{'pretotal'} = 'New charges sub-total '.
+ $total->{'total_amount'};
+ $tax_section->{'description'} = $self->mt($tax_description);
+ $tax_section->{'summarized'} = '';
+
+ if ( $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum) ) {
+
+ # remove tax section if taxes are itemized within other sections
+ @sections = grep{ $_ ne $tax_section } @sections;
+
+ } elsif ( !grep $tax_section, @sections ) {
+
+ # append it if it's not already there
+ push @sections, $tax_section;
+ push @summary_subtotals, $tax_section;
+
+ }
+
}
+
} else {
unshift @total_items, $total;
}
}
$invoice_data{'taxtotal'} = sprintf('%.2f', $taxtotal);
- push @buf,['','-----------'];
- push @buf,[$self->mt(
- (!$self->enable_previous)
- ? 'Total Charges'
- : 'Total New Charges'
- ),
- $money_char. sprintf("%10.2f",$self->charged) ];
- push @buf,['',''];
-
-
###
# Totals
###
);
my $embolden_function = $embolden_functions{$format};
- if ( $self->can('_items_total') ) { # quotations
-
- $self->_items_total(\@total_items);
+ if ( $multisection ) {
- foreach ( @total_items ) {
- $_->{'total_item'} = &$embolden_function( $_->{'total_item'} );
- $_->{'total_amount'} = &$embolden_function( $other_money_char.
- $_->{'total_amount'}
- );
+ if ( $adjust_section->{'sort_weight'} ) {
+ $adjust_section->{'posttotal'} = $self->mt('Balance Forward').' '.
+ $other_money_char. sprintf("%.2f", ($self->billing_balance || 0) );
+ } else{
+ $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
+ $other_money_char. sprintf('%.2f', $self->charged );
}
- } else { #normal invoice case
+ }
+
+ if ( $self->can('_items_total') ) { # should always be true now
- # 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;
- if ( $self->enable_previous and !$conf->exists('previous_balance-exclude_from_total') ) {
- $amount += $pr_total;
- }
+ # even for multisection, need plain text version
- $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'} = $self->mt('Balance Forward').' '.
- $other_money_char. sprintf("%.2f", ($self->billing_balance || 0) );
+ my @new_total_items = $self->_items_total;
+
+ push @buf,['','-----------'];
+
+ foreach ( @new_total_items ) {
+ my ($item, $amount) = ($_->{'total_item'}, $_->{'total_amount'});
+ $_->{'total_item'} = &$embolden_function( $item );
+
+ if ( ref($amount) ) {
+ $_->{'total_amount'} = &$embolden_function(
+ $other_money_char.$amount->[0]. ' to '.
+ $other_money_char.$amount->[1]
+ );
} else {
- $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
- $other_money_char. sprintf('%.2f', $self->charged );
- }
- } else {
- push @total_items, $total;
+ $_->{'total_amount'} = &$embolden_function( $other_money_char.$amount );
+ }
+
+ # but if it's multisection, don't append to @total_items. the adjust
+ # section has all this stuff
+ push @total_items, $_ if !$multisection;
+ push @buf, [ $item, $money_char.sprintf('%10.2f',$amount) ];
}
- push @buf,['','-----------'];
- push @buf,[$item,
- $money_char.
- sprintf( '%10.2f', $amount )
- ];
- push @buf,['',''];
+
+ push @buf, [ '', '' ];
# if we're showing previous invoices, also show previous
# credits and payments
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
my $credittotal = 0;
foreach my $credit (
- $self->_items_credits( 'template' => $template, 'trim_len' => 60 )
+ $self->_items_credits( 'template' => $template, 'trim_len' => 40 )
) {
my $total;
$total->{'total_item'} = &$escape_function($credit->{'description'});
$credittotal += $credit->{'amount'};
$total->{'total_amount'} = $minus.$other_money_char.$credit->{'amount'};
- $adjusttotal += $credit->{'amount'};
if ( $multisection ) {
push @detail_items, {
ext_description => [],
$total->{'total_item'} = &$escape_function($payment->{'description'});
$paymenttotal += $payment->{'amount'};
$total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
- $adjusttotal += $payment->{'amount'};
if ( $multisection ) {
push @detail_items, {
ext_description => [],
if ( $multisection ) {
$adjust_section->{'subtotal'} = $other_money_char.
- sprintf('%.2f', $adjusttotal);
+ sprintf('%.2f', $credittotal + $paymenttotal);
+
+ #why this? because {sort_weight} forces the adjust_section to appear
+ #in @extra_sections instead of @sections. obviously.
push @sections, $adjust_section
unless $adjust_section->{sort_weight};
# do not summarize; adjustments there are shown according to
if ( $multisection && !$adjust_section->{sort_weight} ) {
$adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
$total->{'total_amount'};
- }else{
+ } else {
push @total_items, $total;
}
push @buf,['','-----------'];
# usage subtotals
if ( $conf->exists('usage_class_summary')
and $self->can('_items_usage_class_summary') ) {
- my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function);
+ my @usage_subtotals = $self->_items_usage_class_summary(escape => $escape_function, 'money_char' => $other_money_char);
if ( @usage_subtotals ) {
unshift @sections, $usage_subtotals[0]->{section}; # do not summarize
unshift @detail_items, @usage_subtotals;
# invoice history "section" (not really a section)
# not to be included in any subtotals, completely independent of
# everything...
- if ( $conf->exists('previous_invoice_history') ) {
+ if ( $conf->exists('previous_invoice_history') and $cust_main->isa('FS::cust_main') ) {
my %history;
my %monthorder;
foreach my $cust_bill ( $cust_main->cust_bill ) {
die "no invoice_lines() functions in template?"
if ( $format eq 'template' && !$wasfunc );
- if ($format eq 'template') {
+ if ( $invoice_lines ) {
+ $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
+ $invoice_data{'total_pages'}++
+ if scalar(@buf) % $invoice_lines;
+ }
- if ( $invoice_lines ) {
- $invoice_data{'total_pages'} = int( scalar(@buf) / $invoice_lines );
- $invoice_data{'total_pages'}++
- if scalar(@buf) % $invoice_lines;
+ #setup subroutine for the template
+ $invoice_data{invoice_lines} = sub {
+ my $lines = shift || scalar(@buf);
+ map {
+ scalar(@buf)
+ ? shift @buf
+ : [ '', '' ];
}
+ ( 1 .. $lines );
+ };
- #setup subroutine for the template
- $invoice_data{invoice_lines} = sub {
- my $lines = shift || scalar(@buf);
- map {
- scalar(@buf)
- ? shift @buf
- : [ '', '' ];
- }
- ( 1 .. $lines );
- };
+ if ($format eq 'template') {
my $lines;
my @collect;
} else { # this is where we actually create the invoice
+ if ( $params{no_addresses} ) {
+ delete $invoice_data{$_} foreach qw(
+ payname company address1 address2 city state zip country
+ );
+ $invoice_data{returnaddress} = '~';
+ }
+
warn "filling in template for invoice ". $self->invnum. "\n"
if $DEBUG;
warn join("\n", map " $_ => ". $invoice_data{$_}, keys %invoice_data). "\n"
(@template);
}
+=item terms
+
+=cut
+
sub terms {
my $self = shift;
my $conf = $self->conf;
my $cust_main = $self->cust_main;
return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
+ my $agentnum = '';
+ if ( $cust_main ) {
+ $agentnum = $cust_main->agentnum;
+ } elsif ( my $prospect_main = $self->prospect_main ) {
+ $agentnum = $prospect_main->agentnum;
+ }
+
#use configured default
- $conf->config('invoice_default_terms') || '';
+ $conf->config('invoice_default_terms', $agentnum) || '';
}
+=item due_date
+
+=cut
+
sub due_date {
my $self = shift;
my $duedate = '';
if ( $self->terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
$duedate = $self->_date() + ( $1 * 86400 );
+ } elsif ( $self->terms =~ /^End of Month$/ ) {
+ my ($mon,$year) = (localtime($self->_date) )[4,5];
+ $mon++;
+ until ( $mon < 12 ) { $mon -= 12; $year++; }
+ my $nextmonth_first = timelocal(0,0,0,1,$mon,$year);
+ $duedate = $nextmonth_first - 86400;
}
$duedate;
}
+=item due_date2str
+
+=cut
+
sub due_date2str {
my $self = shift;
$self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
}
+=item balance_due_msg
+
+=cut
+
sub balance_due_msg {
my $self = shift;
my $msg = $self->mt('Balance Due');
- return $msg unless $self->terms;
- if ( $self->due_date ) {
- $msg .= ' - ' . $self->mt('Please pay by'). ' '.
- $self->due_date2str('short');
- } elsif ( $self->terms ) {
- $msg .= ' - '. $self->terms;
+ return $msg unless $self->terms; # huh?
+ if ( !$self->conf->exists('invoice_show_prior_due_date')
+ || $self->has_sections ) {
+ # if enabled, the due date is shown with Total New Charges (see
+ # _items_total) and not here
+ # (yes, or if invoice_sections is enabled; this is just for compatibility)
+ if ( $self->due_date ) {
+ my $please_pay_by =
+ $self->conf->config('invoice_pay_by_msg', $self->agentnum)
+ || 'Please pay by [_1]';
+ $msg .= ' - ' . $self->mt($please_pay_by, $self->due_date2str('short')).
+ ' '
+ unless $self->conf->config_bool('invoice_omit_due_date',$self->agentnum);
+ } elsif ( $self->terms ) {
+ $msg .= ' - '. $self->mt($self->terms);
+ }
}
$msg;
}
+=item balance_due_date
+
+=cut
+
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*$/ ) {
+ my $terms = $self->terms;
+ if ( $terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
$duedate = $self->time2str_local('rdate', $self->_date + ($1*86400) );
}
$duedate;
time2str($date_format, $self->_date);
}
+=item email HASHREF
+
+Emails this template.
+
+Options are passed as a hashref. Available options:
+
+=over 4
+
+=item from
+
+If specified, overrides the default From: address.
+
+=item notice_name
+
+If specified, overrides the name of the sent document ("Invoice" or "Quotation")
+
+=item template
+
+(Deprecated) If specified, is the name of a suffix for alternate template files.
+
+=back
+
+Options accepted by generate_email can also be used.
+
+=cut
+
+sub email {
+ my $self = shift;
+ my $opt = shift || {};
+ if ($opt and !ref($opt)) {
+ die ref($self). '->email called with positional parameters';
+ }
+
+ return if $self->hide;
+
+ my $error = send_email(
+ $self->generate_email(
+ 'subject' => $self->email_subject($opt->{template}),
+ %$opt, # template, etc.
+ )
+ );
+
+ die "can't email: $error\n" if $error;
+}
+
+=item generate_email OPTION => VALUE ...
+
+Options:
+
+=over 4
+
+=item from
+
+sender address, required
+
+=item template
+
+alternate template name, optional
+
+=item subject
+
+email subject, optional
+
+=item notice_name
+
+notice name instead of "Invoice", optional
+
+=back
+
+Returns an argument list to be passed to L<FS::Misc::send_email>.
+
+=cut
+
+use MIME::Entity;
+use Encode;
+
+sub generate_email {
+
+ my $self = shift;
+ my %args = @_;
+ my $conf = $self->conf;
+
+ my $me = '[FS::Template_Mixin::generate_email]';
+
+ my %return = (
+ 'from' => $args{'from'},
+ 'subject' => ($args{'subject'} || $self->email_subject),
+ 'custnum' => $self->custnum,
+ 'msgtype' => 'invoice',
+ );
+
+ $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email');
+
+ my $cust_main = $self->cust_main;
+
+ if (ref($args{'to'}) eq 'ARRAY') {
+ $return{'to'} = $args{'to'};
+ } elsif ( $cust_main ) {
+ $return{'to'} = [ $cust_main->invoicing_list_emailonly ];
+ }
+
+ my $tc = $self->template_conf;
+
+ my @text; # array of lines
+ my $html; # a big string
+ my @related_parts; # will contain the text/HTML alternative, and images
+ my $related; # will contain the multipart/related object
+
+ if ( $conf->exists($tc. 'email_pdf') ) {
+ if ( my $msgnum = $conf->config($tc.'email_pdf_msgnum') ) {
+
+ warn "$me using '${tc}email_pdf_msgnum' in multipart message"
+ if $DEBUG;
+
+ my $msg_template = FS::msg_template->by_key($msgnum)
+ or die "${tc}email_pdf_msgnum $msgnum not found\n";
+ my %prepared = $msg_template->prepare(
+ cust_main => $self->cust_main,
+ object => $self
+ );
+
+ @text = split(/(?=\n)/, $prepared{'text_body'});
+ $html = $prepared{'html_body'};
+
+ } elsif ( my @note = $conf->config($tc.'email_pdf_note') ) {
+
+ warn "$me using '${tc}email_pdf_note' in multipart message"
+ if $DEBUG;
+ @text = $conf->config($tc.'email_pdf_note');
+ $html = join('<BR>', @text);
+
+ } # else use the plain text invoice
+ }
+
+ if (!@text) {
+
+ if ( $conf->config($tc.'template') ) {
+
+ warn "$me generating plain text invoice"
+ if $DEBUG;
+
+ # 'print_text' argument is no longer used
+ @text = map Encode::encode_utf8($_), $self->print_text(\%args);
+
+ } else {
+
+ warn "$me no plain text version exists; sending empty message body"
+ if $DEBUG;
+
+ }
+
+ }
+
+ my $text_part = build MIME::Entity (
+ 'Type' => 'text/plain',
+ 'Encoding' => 'quoted-printable',
+ 'Charset' => 'UTF-8',
+ #'Encoding' => '7bit',
+ 'Data' => \@text,
+ 'Disposition' => 'inline',
+ );
+
+ if (!$html) {
+
+ if ( $conf->exists($tc.'html') ) {
+ warn "$me generating HTML invoice"
+ if $DEBUG;
+
+ $args{'from'} =~ /\@([\w\.\-]+)/;
+ my $from = $1 || 'example.com';
+ my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+
+ my $logo;
+ my $agentnum = $cust_main ? $cust_main->agentnum
+ : $self->prospect_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);
+
+ push @related_parts, build MIME::Entity
+ 'Type' => 'image/png',
+ 'Encoding' => 'base64',
+ 'Data' => $image_data,
+ 'Filename' => 'logo.png',
+ 'Content-ID' => "<$content_id>",
+ ;
+
+ if ( ref($self) eq 'FS::cust_bill' && $conf->exists('invoice-barcode') ) {
+ my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+ push @related_parts, build MIME::Entity
+ 'Type' => 'image/png',
+ 'Encoding' => 'base64',
+ 'Data' => $self->invoice_barcode(0),
+ 'Filename' => 'barcode.png',
+ 'Content-ID' => "<$barcode_content_id>",
+ ;
+ $args{'barcode_cid'} = $barcode_content_id;
+ }
+
+ $html = $self->print_html({ 'cid'=>$content_id, %args });
+ }
+
+ }
+
+ if ( $html ) {
+
+ warn "$me creating HTML/text multipart message"
+ if $DEBUG;
+
+ $return{'nobody'} = 1;
+
+ my $alternative = build MIME::Entity
+ 'Type' => 'multipart/alternative',
+ #'Encoding' => '7bit',
+ 'Disposition' => 'inline'
+ ;
+
+ if ( @text ) {
+ $alternative->add_part($text_part);
+ }
+
+ $alternative->attach(
+ 'Type' => 'text/html',
+ 'Encoding' => 'quoted-printable',
+ 'Data' => [ '<html>',
+ ' <head>',
+ ' <title>',
+ ' '. encode_entities($return{'subject'}),
+ ' </title>',
+ ' </head>',
+ ' <body bgcolor="#e8e8e8">',
+ Encode::encode_utf8($html),
+ ' </body>',
+ '</html>',
+ ],
+ 'Disposition' => 'inline',
+ #'Filename' => 'invoice.pdf',
+ );
+
+ unshift @related_parts, $alternative;
+
+ $related = build MIME::Entity 'Type' => 'multipart/related',
+ 'Encoding' => '7bit';
+
+ #false laziness w/Misc::send_email
+ $related->head->replace('Content-type',
+ $related->mime_type.
+ '; boundary="'. $related->head->multipart_boundary. '"'.
+ '; type=multipart/alternative'
+ );
+
+ $related->add_part($_) foreach @related_parts;
+
+ }
+
+ my @otherparts = ();
+ if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
+
+ if ( $conf->config('voip-cdr_email_attach') eq 'zip' ) {
+
+ my $data = join('', map "$_\n",
+ $self->call_details(prepend_billed_number=>1)
+ );
+
+ my $zip = new Archive::Zip;
+ my $file = $zip->addString( $data, 'usage-'.$self->invnum.'.csv' );
+ $file->desiredCompressionMethod( COMPRESSION_DEFLATED );
+
+ my $zipdata = '';
+ my $SH = IO::Scalar->new(\$zipdata);
+ my $status = $zip->writeToFileHandle($SH);
+ die "Error zipping CDR attachment: $!" unless $status == AZ_OK;
+
+ push @otherparts, build MIME::Entity
+ 'Type' => 'application/zip',
+ 'Encoding' => 'base64',
+ 'Data' => $zipdata,
+ 'Disposition' => 'attachment',
+ 'Filename' => 'usage-'. $self->invnum. '.zip',
+ ;
+
+ } else { # } elsif ( $conf->config('voip-cdr_email_attach') eq 'csv' ) {
+
+ push @otherparts, build MIME::Entity
+ 'Type' => 'text/csv',
+ 'Encoding' => '7bit',
+ 'Data' => [ map { "$_\n" }
+ $self->call_details('prepend_billed_number' => 1)
+ ],
+ 'Disposition' => 'attachment',
+ 'Filename' => 'usage-'. $self->invnum. '.csv',
+ ;
+
+ }
+
+ }
+
+ if ( $conf->exists($tc.'email_pdf') ) {
+
+ #attaching pdf too:
+ # multipart/mixed
+ # multipart/related
+ # multipart/alternative
+ # text/plain
+ # text/html
+ # image/png
+ # application/pdf
+
+ my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
+ push @otherparts, $pdf;
+ }
+
+ if (@otherparts) {
+ $return{'content-type'} = 'multipart/mixed'; # of the outer container
+ if ( $html ) {
+ $return{'mimeparts'} = [ $related, @otherparts ];
+ $return{'type'} = 'multipart/related'; # of the first part
+ } else {
+ $return{'mimeparts'} = [ $text_part, @otherparts ];
+ $return{'type'} = 'text/plain';
+ }
+ } elsif ( $html ) { # no PDF or CSV, strip the outer container
+ $return{'mimeparts'} = \@related_parts;
+ $return{'content-type'} = 'multipart/related';
+ $return{'type'} = 'multipart/alternative';
+ } else { # no HTML either
+ $return{'body'} = \@text;
+ $return{'content-type'} = 'text/plain';
+ }
+
+ %return;
+
+}
+
+=item mimebuild_pdf
+
+Returns a list suitable for passing to MIME::Entity->build(), representing
+this invoice as PDF attachment.
+
+=cut
+
+sub mimebuild_pdf {
+ my $self = shift;
+ (
+ 'Type' => 'application/pdf',
+ 'Encoding' => 'base64',
+ 'Data' => [ $self->print_pdf(@_) ],
+ 'Disposition' => 'attachment',
+ 'Filename' => 'invoice-'. $self->invnum. '.pdf',
+ );
+}
+
+=item postal_mail_fsinc
+
+Sends this invoice to the Freeside Internet Services, Inc. print and mail
+service.
+
+=cut
+
+use CAM::PDF;
+use IO::Socket::SSL;
+use LWP::UserAgent;
+use HTTP::Request::Common qw( POST );
+use JSON::XS;
+use MIME::Base64;
+sub postal_mail_fsinc {
+ my ( $self, %opt ) = @_;
+
+ my $url = 'https://ws.freeside.biz/print';
+
+ my $cust_main = $self->cust_main;
+ my $agentnum = $cust_main->agentnum;
+ my $bill_location = $cust_main->bill_location;
+
+ die "Extra charges for international mailing; contact support\@freeside.biz to enable\n"
+ if $bill_location->country ne 'US';
+
+ my $conf = new FS::Conf;
+
+ my @company_address = $conf->config('company_address', $agentnum);
+ my ( $company_address1, $company_address2, $company_city, $company_state, $company_zip );
+ if ( $company_address[2] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
+ $company_address1 = $company_address[0];
+ $company_address2 = $company_address[1];
+ $company_city = $1;
+ $company_state = $2;
+ $company_zip = $3;
+ } elsif ( $company_address[1] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) {
+ $company_address1 = $company_address[0];
+ $company_address2 = '';
+ $company_city = $1;
+ $company_state = $2;
+ $company_zip = $3;
+ } else {
+ die "Unparsable company_address; contact support\@freeside.biz\n";
+ }
+ $company_city =~ s/,$//;
+
+ my $file = $self->print_pdf(%opt, 'no_addresses' => 1);
+ my $pages = CAM::PDF->new($file)->numPages;
+
+ my $ua = LWP::UserAgent->new(
+ 'ssl_opts' => {
+ verify_hostname => 0,
+ SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
+ SSL_version => 'SSLv3',
+ }
+ );
+ my $response = $ua->request( POST $url, [
+ 'support-key' => scalar($conf->config('support-key')),
+ 'file' => encode_base64($file),
+ 'pages' => $pages,
+
+ #from:
+ 'company_name' => scalar( $conf->config('company_name', $agentnum) ),
+ 'company_address1' => $company_address1,
+ 'company_address2' => $company_address2,
+ 'company_city' => $company_city,
+ 'company_state' => $company_state,
+ 'company_zip' => $company_zip,
+ 'company_country' => 'US',
+ 'company_phonenum' => scalar($conf->config('company_phonenum', $agentnum)),
+ 'company_email' => scalar($conf->config('invoice_from', $agentnum)),
+
+ #to:
+ 'name' => ( $cust_main->payname
+ && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/
+ ? $cust_main->payname
+ : $cust_main->contact_firstlast
+ ),
+ 'company' => $cust_main->company,
+ 'address1' => $bill_location->address1,
+ 'address2' => $bill_location->address2,
+ 'city' => $bill_location->city,
+ 'state' => $bill_location->state,
+ 'zip' => $bill_location->zip,
+ 'country' => $bill_location->country,
+ ]);
+
+ die "Print connection error: ". $response->message.
+ ' ('. $response->as_string. ")\n"
+ unless $response->is_success;
+
+ local $@;
+ my $content = eval { decode_json($response->content) };
+ die "Print JSON error : $@\n" if $@;
+
+ die $content->{error}."\n"
+ if $content->{error};
+
+ #TODO: store this so we can query for a status later
+ warn "Invoice printed, ID ". $content->{id}. "\n";
+
+ $content->{id};
+}
+
=item _items_sections OPTIONS
Generate section information for all items appearing on this invoice.
foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
next if ( $display->summary && $opt{summary} );
- my $section = $display->section;
+ #my $section = $display->section;
+ #false laziness with the method, but for efficiency inside this loop
+ my $section = $display->get('section');
+ if ( !$section && !$cust_bill_pkg->hidden ) {
+ $section = $cust_bill_pkg->get('categoryname'); #cust_bill->cust_bill_pkg added it (XXX quotations / quotation_section)
+ }
+
my $type = $display->type;
# Set $section = undef if we're sectioning by location and this
# line item _has_ a location (i.e. isn't a fee).
foreach my $sectionname (keys %{ $s->{$locationnum} }) {
my $section = {
'subtotal' => $s->{$locationnum}{$sectionname},
- 'post_total' => $post_total,
'sort_weight' => 0,
};
if ( $locationnum ) {
} else {
$section->{'category'} = $sectionname;
$section->{'description'} = &{ $escape }($sectionname);
- if ( _pkg_category($_) ) {
- $section->{'sort_weight'} = _pkg_category($_)->weight;
- if ( _pkg_category($_)->condense ) {
+ if ( _pkg_category($sectionname) ) {
+ $section->{'sort_weight'} = _pkg_category($sectionname)->weight;
+ if ( _pkg_category($sectionname)->condense ) {
$section = { %$section, $self->_condense_section($opt{format}) };
}
}
$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).
+OPTIONS are passed through to _items_cust_bill_pkg, and should include
+'format' and 'escape_function' at minimum.
+
+To produce items for a specific invoice section, OPTIONS should include
+'section', a hashref containing 'category' and/or 'locationnum' keys.
+
+'section' may also contain a key named 'condensed'. If this is present
+and has a true value, _items_pkg will try 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
my $self = shift;
my %options = @_;
my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg;
+ my $escape_function = $options{escape_function};
+
+ my $locale = $self->cust_main->locale;
+
my @items;
foreach my $cust_bill_pkg (@cust_bill_pkg) {
# cache this, so we don't look it up again in every section
my %base_invnums; # invnum => invoice date
foreach ($cust_bill_pkg->cust_bill_pkg_fee) {
if ($_->base_invnum) {
+ # XXX what if base_bill has been voided?
my $base_bill = FS::cust_bill->by_key($_->base_invnum);
my $base_date = $self->time2str_local('short', $base_bill->_date)
if $base_bill;
}
foreach (sort keys(%base_invnums)) {
next if $_ == $self->invnum;
+ # per convention, we must escape ext_description lines
push @ext_desc,
- $self->mt('from invoice \\#[_1] on [_2]', $_, $base_invnums{$_});
+ &{$escape_function}(
+ $self->mt('from invoice #[_1] on [_2]', $_, $base_invnums{$_})
+ );
}
+ my $desc = $part_fee->itemdesc_locale($locale);
+ # but not escape the base description line
+
+ my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+ if $options{section_with_taxes};
+
push @items,
{ feepart => $cust_bill_pkg->feepart,
amount => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
- description => $part_fee->itemdesc_locale($self->cust_main->locale),
- ext_description => \@ext_desc
+ description => $desc,
+ pkg_tax => \@pkg_tax,
+ ext_description => \@ext_desc,
# sdate/edate?
};
}
preref_callback: coderef run for each line item, code should return HTML to be
displayed before that line item (quotations only)
+section_with_taxes: Look up and include applied taxes for each record
+
Returns a list of hashrefs, each of which may contain:
pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and
}
my $summary_page = $opt{summary_page} || ''; #unused
my $multisection = defined($category) || defined($locationnum);
- my $discount_show_always = 0;
+ # this variable is the value of the config setting, not whether it applies
+ # to this particular line item.
+ my $discount_show_always = $conf->exists('discount-show-always');
- my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
+ my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 40;
my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
- # and location labels
+
+ my $agentnum = $self->agentnum;
+
+ # for location labels: use default location on the invoice date
+ my $default_locationnum;
+ if ( $conf->exists('invoice-all_pkg_addresses') ) {
+ $default_locationnum = 0; # treat them all as non-default
+ } elsif ( $self->custnum ) {
+ my $h_cust_main;
+ my @h_search = FS::h_cust_main->sql_h_search($self->_date);
+ $h_cust_main = qsearchs({
+ 'table' => 'h_cust_main',
+ 'hashref' => { custnum => $self->custnum },
+ 'extra_sql' => $h_search[1],
+ 'addl_from' => $h_search[3],
+ }) || $cust_main;
+ $default_locationnum = $h_cust_main->ship_locationnum;
+ } elsif ( $self->prospectnum ) {
+ my $cust_location = qsearchs('cust_location',
+ { prospectnum => $self->prospectnum,
+ disabled => '' });
+ $default_locationnum = $cust_location->locationnum if $cust_location;
+ }
my @b = (); # accumulator for the line item hashes that we'll return
- my ($s, $r, $u, $d) = ( undef, undef, undef );
+ my ($s, $r, $u, $d) = ( undef, undef, undef, undef );
# the 'current' line item hashes for setup, recur, usage, discount
foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
{
# if the current line item is waiting to go out, and the one we're about
# to start is not bundled, then push out the current one and start a new
# one.
- foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) , $d ) {
+ foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
if ( $_ && !$cust_bill_pkg->hidden ) {
$_->{amount} = sprintf( "%.2f", $_->{amount} );
$_->{amount} =~ s/^\-0\.00$/0.00/;
if (exists($_->{unit_amount})) {
$_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
}
- push @b, { %$_ }
- if $_->{amount} != 0
- || $discount_show_always
- || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
- || ( $_->{_is_setup} && $_->{setup_show_zero} )
+ push @b, { %$_ };
+ # we already decided to create this display line; don't reconsider it
+ # now.
+ # if $_->{amount} != 0
+ # || $discount_show_always
+ # || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+ # || ( $_->{_is_setup} && $_->{setup_show_zero} )
;
$_ = undef;
}
'no_usage' => $opt{'no_usage'},
);
+ my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+ if $opt{section_with_taxes};
+
if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
+ # XXX this should be pulled out into quotation_pkg
warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
if $DEBUG > 1;
# quotation_pkgs are never fees, so don't worry about the case where
# part_pkg is undefined
+ my @details = $cust_bill_pkg->details;
+
# and I guess they're never bundled either?
- if ( $cust_bill_pkg->setup != 0 ) {
+ if (( $cust_bill_pkg->setup != 0 ) || ( $cust_bill_pkg->setup_show_zero )) {
my $description = $desc;
$description .= ' Setup'
if $cust_bill_pkg->recur != 0
|| $discount_show_always
|| $cust_bill_pkg->recur_show_zero;
- push @b, {
+ #push @b, {
+ # keep it consistent, please
+ $s = {
'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref
'description' => $description,
'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
+ 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup),
+ 'quantity' => $cust_bill_pkg->quantity,
+ 'pkg_tax' => \@pkg_tax,
+ 'ext_description' => \@details,
'preref_html' => ( $opt{preref_callback}
? &{ $opt{preref_callback} }( $cust_bill_pkg )
: ''
),
};
}
- if ( $cust_bill_pkg->recur != 0 ) {
- push @b, {
+ if (( $cust_bill_pkg->recur != 0 ) || ( $cust_bill_pkg->recur_show_zero )) {
+ #push @b, {
+ $r = {
'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref
'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
- 'preref_html' => ( $opt{preref_callback}
+ 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur),
+ 'quantity' => $cust_bill_pkg->quantity,
+ 'pkg_tax' => \@pkg_tax,
+ 'ext_description' => \@details,
+ 'preref_html' => ( $opt{preref_callback}
? &{ $opt{preref_callback} }( $cust_bill_pkg )
: ''
),
if ( (!$type || $type eq 'S')
&& ( $cust_bill_pkg->setup != 0
|| $cust_bill_pkg->setup_show_zero
+ || ($discount_show_always and $cust_bill_pkg->unitsetup > 0)
)
)
{
warn "$me _items_cust_bill_pkg adding setup\n"
if $DEBUG > 1;
+ # append the word 'Setup' to the setup line if there's going to be
+ # a recur line for the same package (i.e. not a one-time charge)
+ # XXX localization
my $description = $desc;
$description .= ' Setup'
if $cust_bill_pkg->recur != 0
- || $discount_show_always
+ || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
|| $cust_bill_pkg->recur_show_zero;
- $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
- $self->agentnum )
+ my $disable_date_ranges =
+ $opt{disable_line_item_date_ranges}
+ || $part_pkg->option('disable_line_item_date_ranges', 1);
+
+ $description .= $cust_bill_pkg->time_period_pretty(
+ $part_pkg,
+ $agentnum,
+ disable_date_ranges => $disable_date_ranges,
+ )
if $part_pkg->is_prepaid #for prepaid, "display the validity period
# triggered by the recurring charge freq
# (RT#26274)
# always pass the svc_label through to the template, even if
# not displaying it as an ext_description
my @svc_labels = map &{$escape_function}($_),
- $cust_pkg->h_labels_short($self->_date, undef, 'I');
-
+ $cust_pkg->h_labels_short($self->_date,
+ undef,
+ 'I',
+ $self->conf->{locale},
+ );
$svc_label = $svc_labels[0];
unless ( $cust_pkg->part_pkg->hide_svc_detail
push @d, @svc_labels
unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
- my $lnum = $cust_main ? $cust_main->ship_locationnum
- : $self->prospect_main->locationnum;
# show the location label if it's not the customer's default
# location, and we're not grouping items by location already
- if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
+ if ( $cust_pkg->locationnum != $default_locationnum
+ and !defined($locationnum) ) {
my $loc = $cust_pkg->location_label;
$loc = substr($loc, 0, $maxlength). '...'
if $format eq 'latex' && length($loc) > $maxlength;
setup_show_zero => $cust_bill_pkg->setup_show_zero,
unit_amount => $cust_bill_pkg->unitsetup,
quantity => $cust_bill_pkg->quantity,
+ pkg_tax => \@pkg_tax,
ext_description => \@d,
svc_label => ($svc_label || ''),
locationnum => $cust_pkg->locationnum, # sure, why not?
}
+ # should we show a recur line?
+ # if type eq 'S', then NO, because we've been told not to.
+ # otherwise, show the recur line if:
+ # - there's a recurring charge
+ # - or recur_show_zero is on
+ # - or there's a positive unitrecur (so it's been discounted to zero)
+ # and discount-show-always is on
if ( ( !$type || $type eq 'R' || $type eq 'U' )
&& (
$cust_bill_pkg->recur != 0
- || $cust_bill_pkg->setup == 0
- || $discount_show_always
+ || !defined($s)
+ || ($discount_show_always and $cust_bill_pkg->unitrecur > 0)
|| $cust_bill_pkg->recur_show_zero
)
)
$description = $self->mt('Usage charges');
}
- my $part_pkg = $cust_pkg->part_pkg;
+ my $disable_date_ranges =
+ $opt{disable_line_item_date_ranges}
+ || $part_pkg->option('disable_line_item_date_ranges', 1);
- $description .= $cust_bill_pkg->time_period_pretty( $part_pkg,
- $self->agentnum );
+ $description .= $cust_bill_pkg->time_period_pretty(
+ $part_pkg,
+ $agentnum,
+ disable_date_ranges => $disable_date_ranges,
+ );
my @d = ();
my @seconds = (); # for display of usage info
push @dates, undef if !$prev;
my @svc_labels = map &{$escape_function}($_),
- $cust_pkg->h_labels_short(@dates, 'I');
+ $cust_pkg->h_labels_short(@dates,
+ 'I',
+ $self->conf->{locale});
$svc_label = $svc_labels[0];
# show service labels, unless...
warn "$me _items_cust_bill_pkg done adding service details\n"
if $DEBUG > 1;
- my $lnum = $cust_main ? $cust_main->ship_locationnum
- : $self->prospect_main->locationnum;
# show the location label if it's not the customer's default
# location, and we're not grouping items by location already
- if ( $cust_pkg->locationnum != $lnum and !defined($locationnum) ) {
+ if ( $cust_pkg->locationnum != $default_locationnum
+ and !defined($locationnum) ) {
my $loc = $cust_pkg->location_label;
$loc = substr($loc, 0, $maxlength). '...'
if $format eq 'latex' && length($loc) > $maxlength;
recur_show_zero => $cust_bill_pkg->recur_show_zero,
unit_amount => $unit_amount,
quantity => $cust_bill_pkg->quantity,
+ pkg_tax => \@pkg_tax,
%item_dates,
ext_description => \@d,
svc_label => ($svc_label || ''),
amount => $amount,
usage_item => 1,
recur_show_zero => $cust_bill_pkg->recur_show_zero,
+ pkg_tax => \@pkg_tax,
%item_dates,
ext_description => \@d,
locationnum => $cust_pkg->locationnum,
} # recurring or usage with recurring charge
- # decide whether to show active discounts here
- if (
- # case 1: we are showing a single line for the package
- ( !$type )
- # case 2: we are showing a setup line for a package that has
- # no base recurring fee
- or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 )
- # case 3: we are showing a recur line for a package that has
- # a base recurring fee
- or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
- ) {
-
- my @discounts = $cust_bill_pkg->cust_bill_pkg_discount;
- # special case: if there are old "discount details" on this line
- # item, don't show discount line items
- if ( FS::cust_bill_pkg_detail->count(
- "detail LIKE 'Includes discount%' AND billpkgnum = " .
- $cust_bill_pkg->billpkgnum
- ) > 0 ) {
- @discounts = ();
- }
- if( @discounts ) {
- warn "$me _items_cust_bill_pkg including discounts for ".
- $cust_bill_pkg->billpkgnum."\n"
- if $DEBUG;
- my $discount_amount = sum( map {$_->amount} @discounts );
- my $orig_amount = $cust_bill_pkg->setup + $cust_bill_pkg->recur
- + $discount_amount;
- # if multiple discounts apply to the same package, how to display
- # them? ext_description lines, apparently
- if ( $d and $cust_bill_pkg->hidden ) {
- $d->{amount} += $discount_amount;
- $d->{orig_amount} += $orig_amount;
- } else {
- my @ext;
- # make a placeholder for the original price, if necessary
- # (if unit prices are enabled, it won't be necessary)
- push @ext, '' if !$conf->exists('invoice-unitprice');
- $d = {
- _is_discount => 1,
- description => $self->mt('Discount included'),
- amount => $discount_amount,
- orig_amount => $orig_amount,
- ext_description => \@ext,
- };
- foreach my $cust_bill_pkg_discount (@discounts) {
- my $def = $cust_bill_pkg_discount->cust_pkg_discount->discount;
- push @ext, &{$escape_function}( $def->description );
- }
- }
-
- # update the placeholder to show the original price in the
- # first ext_description line
- if ( !$conf->exists('invoice-unitprice') ) {
- $d->{ext_description}->[0] =
- sprintf('Original price: %.2f', $d->{orig_amount});
- }
- } # if there are any discounts
- } # if this is an appropriate place to show discounts
-
} else { # taxes and fees
warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
} # if quotation / package line item / other line item
- } # foreach $display
+ # decide whether to show active discounts here
+ if (
+ # case 1: we are showing a single line for the package
+ ( !$type )
+ # case 2: we are showing a setup line for a package that has
+ # no base recurring fee
+ or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 )
+ # case 3: we are showing a recur line for a package that has
+ # a base recurring fee
+ or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
+ ) {
+
+ my $item_discount = $cust_bill_pkg->_item_discount;
+ if ( $item_discount ) {
+ # $item_discount->{amount} is negative
+
+ if ( $d and $cust_bill_pkg->hidden ) {
+ $d->{amount} += $item_discount->{amount};
+ } else {
+ $d = $item_discount;
+ $_ = &{$escape_function}($_) foreach @{ $d->{ext_description} };
+ }
+
+ # update the active line (before the discount) to show the
+ # original price (whether this is a hidden line or not)
+ #
+ # quotation discounts keep track of setup and recur; invoice
+ # discounts currently don't
+ if ( exists $item_discount->{setup_amount} ) {
+
+ $s->{amount} -= $item_discount->{setup_amount} if $s;
+ $r->{amount} -= $item_discount->{recur_amount} if $r;
+
+ } else {
+
+ # $active_line is the line item hashref for the line that will
+ # show the original price
+ # (use the recur or single line for the package, unless we're
+ # showing a setup line for a package with no recurring fee)
+ my $active_line = $r;
+ if ( $type eq 'S' ) {
+ $active_line = $s;
+ }
+ $active_line->{amount} -= $item_discount->{amount};
- $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
- && $conf->exists('discount-show-always'));
+ }
+
+ } # if there are any discounts
+ } # if this is an appropriate place to show discounts
+
+ } # foreach $display
}
- foreach ( $s, $r, ($opt{skip_usage} ? () : $u, $d ) ) {
+ foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
if ( $_ ) {
$_->{amount} = sprintf( "%.2f", $_->{amount} ),
if exists($_->{amount});
$_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
}
- push @b, { %$_ }
- if $_->{amount} != 0
- || $discount_show_always
- || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
- || ( $_->{_is_setup} && $_->{setup_show_zero} )
+ push @b, { %$_ };
+ #if $_->{amount} != 0
+ # || $discount_show_always
+ # || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
+ # || ( $_->{_is_setup} && $_->{setup_show_zero} )
}
}
}
+=item has_sections AGENTNUM
+
+Return true if invoice_sections should be enabled for this bill.
+ (Inherited by both cust_bill and cust_bill_void)
+
+Determination:
+* False if not an invoice
+* True always if conf invoice_sections is enabled
+* True always if sections_by_location is enabled
+* True if conf invoice_sections_multilocation > 1,
+ and location_count >= invoice_sections_multilocation
+* Else, False
+
+=cut
+
+sub has_sections {
+ my ($self, $agentnum) = @_;
+
+ return 0 unless $self->invnum > 0;
+
+ $agentnum ||= $self->agentnum;
+ return 1 if $self->conf->config_bool('invoice_sections', $agentnum);
+ return 1 if $self->conf->exists('sections_by_location', $agentnum);
+
+ my $location_min = $self->conf->config(
+ 'invoice_sections_multilocation', $agentnum,
+ );
+
+ return 1
+ if $location_min
+ && $self->location_count >= $location_min;
+
+ 0;
+}
+
+
+=item location_count
+
+Return the number of locations billed on this invoice
+
+=cut
+
+sub location_count {
+ my ($self) = @_;
+ return 0 unless $self->invnum;
+
+ # SELECT COUNT( DISTINCT cust_pkg.locationnum )
+ # FROM cust_bill_pkg
+ # LEFT JOIN cust_pkg USING (pkgnum)
+ # WHERE invnum = 278
+ # AND cust_bill_pkg.pkgnum > 0
+
+ my $result = qsearchs({
+ select => 'COUNT(DISTINCT cust_pkg.locationnum) as location_count',
+ table => 'cust_bill_pkg',
+ addl_from => 'LEFT JOIN cust_pkg USING (pkgnum)',
+ extra_sql => 'WHERE invnum = '.dbh->quote( $self->invnum )
+ . ' AND cust_bill_pkg.pkgnum > 0'
+ });
+ ref $result ? $result->location_count : 0;
+}
+
1;