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 Cwd;
use FS::UID;
use FS::Misc qw( send_email );
-use FS::Record qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs dbh );
use FS::Conf;
use FS::Misc qw( generate_ps generate_pdf );
use FS::pkg_category;
use FS::pkg_class;
use FS::invoice_mode;
use FS::L10N;
+use FS::Log;
$DEBUG = 0;
$me = '[FS::Template_Mixin]';
=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
$templatefile .= "_$template"
if length($template) && $conf->exists($templatefile."_$template");
+ $self->set('_template',$template);
+
# the base template
my @invoice_template = map "$_\n", $conf->config($templatefile)
or die "cannot load config data $templatefile";
'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,
'amountenclosedsep' => scalar($conf->config('invoice_latexcouponamountenclosedsep', $agentnum)),
'coupontoaddresssep' => scalar($conf->config('invoice_latexcoupontoaddresssep', $agentnum)),
'addcompanytoaddress' => $conf->exists('invoice_latexcouponaddcompanytoaddress', $agentnum),
+ 'couponlocation' => (scalar($conf->config('invoice_latexcouponlocation', $agentnum)) eq "top") ? 'top' : 'bottom',
# better hang on to conf_dir for a while (for old templates)
'conf_dir' => "$FS::UID::conf_dir/conf.$FS::UID::datasrc",
'total_pages' => 1,
);
+
+ #quotations have $name
+ $invoice_data{'name'} = $invoice_data{'payname'};
#localization
$invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
$invoice_data{'cid'} = $params{'cid'}
if $params{'cid'};
- if ( $cust_main->country eq $countrydefault ) {
- $invoice_data{'country'} = '';
- } else {
+ if ( $cust_main->bill_locationnum
+ && $cust_main->bill_location->country ne $countrydefault ) {
$invoice_data{'country'} = &$escape_function($cust_main->bill_country_full);
+ } else {
+ $invoice_data{'country'} = '';
}
my @address = ();
$invoice_data{'barcode_cid'} = $params{'barcode_cid'}
if $params{'barcode_cid'};
- my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
-# my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
- #my $balance_due = $self->owed + $pr_total - $cr_total;
- my $balance_due = $self->owed;
- 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)
+ # re: rt:78190
+ # using owed_on_invoice() instead of owed() here for $balance_due
+ # using _items_previous_total() instead of ->previous() for $pr_total
+ #
+ # owed_on_invoice() is aware of configuration flags that affect how an
+ # invoice is rendered. May not return actual current balance. Will
+ # return balance appropriate for the invoice being rendered, based
+ # on which past due items, current charges, and future payments are
+ # displayed.
+ #
+ # Going forward, usage of owed(), or bypassing cust_bill helper methods
+ # when generating invoice lines may lead to incorrect or misleading
+ # math on invoices.
+ #
+ # Helper methods that are aware of invoicing conf flags:
+ # - owed_on_invoice # use instead of owed()
+ # - _items_previous() # use instead of previous()
+ # - _items_credits() # use instead of cust_credit()
+ # - _items_payments()
+ # - _items_total()
+ # - _items_previous_total() # use instead of previous()
+ # - _items_payments_total()
+ # - _items_credits_total() # use instead of cust_credit()
+
+ my $pr_total = $self->_items_previous_total();
+
+ my $balance_due = $self->owed_on_invoice();
$invoice_data{'balance'} = sprintf("%.2f", $balance_due);
# flag telling this invoice to have a first-page summary
# summary formats
$invoice_data{'last_bill'} = {};
- my $last_bill = $self->previous_bill;
- if ( $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.
- #
- # 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",
+ # Populate template stash for previous balance and payments
+ if ($pr_total) {
+ # Used on summary page as "Previous Balance"
+ $invoice_data{'true_previous_balance'} = sprintf("%.2f", $pr_total);
+
+ # Used on summary page as "Payments"
+ $invoice_data{'balance_adjustments'} = sprintf("%.2f",
+ $self->_items_payments_total() + $self->_items_credits_total()
);
- # the customer's current balance immediately after generating the last
- # bill
+ # Used in invoice template as "Previous Balance"
+ $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
- my $last_bill_balance = $last_bill->charged;
- foreach (@sql) {
- my $delta = FS::Record->scalar_sql(
- $_,
- $last_bill->_date - 1,
- $self->custnum,
- );
- $last_bill_balance += $delta;
- }
+ # $invoice_data{last_bill}{_date}:
+ # Not used in default templates, but may be in use by someone
+ #
+ # ! May be a problem field if they are using it... this field
+ # stores the date of the previous invoice... it is possible to
+ # carry a balance, but have the immediately previous invoice paid off.
+ # In this case, this field might be presenting bad data? Not
+ # altering the problematic behavior, because someone might be
+ # expecting this bad behavior in their templates for some other
+ # purpose, such as a "your last bill was dated %_date%"
+ my $last_bill = $self->previous_bill;
+ $invoice_data{'last_bill'}{'_date'}
+ = ref $last_bill
+ ? $last_bill->_date()
+ : undef;
+
+ # $invoice_data{previous_payments}
+ # Not used in default templates, but may be in use by someone
+ #
+ # Returns an array of hrefs representing payments, each with keys:
+ # - _date: epoch timestamp
+ # - date: text formatted date
+ # - amount: money formatted amount string
+ # - payinfo: string from payby_payinfo_pretty()
+ # - paynum: id for cust_pay
+ # - description: Text description for bill line item
+ #
+ my @payments = $self->_items_payments();
+ $invoice_data{previous_payments} = \@payments;
- $last_bill_balance = sprintf("%.2f", $last_bill_balance);
-
- warn sprintf("LAST BILL: INVNUM %d, DATE %s, BALANCE %.2f\n\n",
- $last_bill->invnum,
- $self->time2str_local('%D', $last_bill->_date),
- $last_bill_balance
- ) if $DEBUG > 0;
- # ("true_previous_balance" is a terrible name, but at least it's no
- # longer stored in the database)
- $invoice_data{'true_previous_balance'} = $last_bill_balance;
-
- # 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) {
- my $delta = FS::Record->scalar_sql(
- $_,
- $self->_date,
- $last_bill->_date,
- $self->custnum,
+ # $invoice_data{previous_credits}
+ # Not used in default templates, but may be in use by someone
+ #
+ # Returns an array of hrefs representing credits, each with keys:
+ # - _date: epoch timestamp
+ # - date: text formatted date
+ # - amount: money formatted amount string
+ # - crednum: id for cust_credit
+ # - description: Text description for bill line item
+ # - creditreason: reason() from cust_credit
+ #
+ my @credits = $self->_items_credits();
+ $invoice_data{previous_credits} = \@credits;
+
+ # Populate formatted date field
+ for my $pmt_href (@payments, @credits) {
+ $pmt_href->{date} = $self->time2str_local(
+ 'long',
+ $pmt_href->{_date},
+ $format
);
- $adjustments += $delta;
}
- $invoice_data{'balance_adjustments'} = sprintf("%.2f", $adjustments);
-
- warn sprintf("BALANCE ADJUSTMENTS: %.2f\n\n",
- $invoice_data{'balance_adjustments'}
- ) if $DEBUG > 0;
- # the sum of amount owed on all previous invoices
- # ($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
- my (@payments, @credits);
- # for formats that itemize previous payments
- foreach my $cust_pay ( qsearch('cust_pay', {
- 'custnum' => $self->custnum,
- '_date' => { op => '>=',
- value => $last_bill->_date }
- } ) )
- {
- next if $cust_pay->_date > $self->_date;
- push @payments, {
- '_date' => $cust_pay->_date,
- 'date' => $self->time2str_local('long', $cust_pay->_date, $format),
- 'payinfo' => $cust_pay->payby_payinfo_pretty,
- 'amount' => sprintf('%.2f', $cust_pay->paid),
- };
- # not concerned about applications
- }
- foreach my $cust_credit ( qsearch('cust_credit', {
- 'custnum' => $self->custnum,
- '_date' => { op => '>=',
- value => $last_bill->_date }
- } ) )
- {
- next if $cust_credit->_date > $self->_date;
- push @credits, {
- '_date' => $cust_credit->_date,
- 'date' => $self->time2str_local('long', $cust_credit->_date, $format),
- 'creditreason'=> $cust_credit->reason,
- 'amount' => sprintf('%.2f', $cust_credit->amount),
- };
- }
- $invoice_data{'previous_payments'} = \@payments;
- $invoice_data{'previous_credits'} = \@credits;
} else {
- # there is no $last_bill
+ # There are no outstanding invoices = YAPH
$invoice_data{'true_previous_balance'} =
$invoice_data{'balance_adjustments'} =
$invoice_data{'previous_balance'} = '0.00';
- $invoice_data{'previous_payments'} = [];
+ $invoice_data{'previous_payments'} =
$invoice_data{'previous_credits'} = [];
}
-
+
+ # Condencing a lot of debug staements here
+ if ($DEBUG) {
+ warn "\$invoice_data{$_}: $invoice_data{$_}"
+ for qw(
+ true_previous_balance
+ balance_adjustments
+ previous_balance
+ previous_payments
+ previous_credits
+ );
+ }
+
if ( $conf->exists('invoice_usesummary', $agentnum) ) {
$invoice_data{'summarypage'} = $summarypage = 1;
}
my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
my $multisection = $self->has_sections;
- $conf->exists($tc.'sections', $cust_main->agentnum) ||
- $conf->exists($tc.'sections_by_location', $cust_main->agentnum);
- $invoice_data{'multisection'} = $multisection;
+ if ( $multisection ) {
+ $invoice_data{multisection} = $conf->config($tc.'sections_method') || 1;
+ }
my $late_sections;
my $extra_sections = [];
my $extra_lines = ();
sprintf('%.2f', $pr_total),
'summarized' => '', #why? $summarypage ? 'Y' : '',
};
- $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '.
- join(' / ', map { $cust_main->balance_date_range(@$_) }
- $self->_prior_month30s
- )
- if $conf->exists('invoice_include_aging');
+
+ # Include balance aging line and template variables
+ my @aged_balances = $self->_items_aging_balances();
+ ( $invoice_data{aged_balance_current},
+ $invoice_data{aged_balance_30d},
+ $invoice_data{aged_balance_60d},
+ $invoice_data{aged_balance_90d}
+ ) = @aged_balances;
+
+ if ($conf->exists('invoice_include_aging')) {
+ $previous_section->{posttotal} = sprintf(
+ '0 / 30 / 60 / 90 days overdue %.2f / %.2f / %.2f / %.2f',
+ @aged_balances,
+ );
+ }
} else {
# otherwise put them in the main section
}
- if ( @pr_cust_bill && $self->enable_previous ) {
+ if ( $pr_total && $self->enable_previous ) {
push @buf, ['','-----------'];
push @buf, [ $self->mt('Total Previous Balance'),
$money_char. sprintf("%10.2f", $pr_total) ];
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'}) ) {
+
+ $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 $multisection;
+ # 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) ) {
+ # When bill is sectioned by location, fees may be displayed within the
+ # appropriate location section. Suppress this fee from the taxes/fees
+ # end section, so it doesn't appear to be charged twice and make the
+ # subtotals seem incorrect
+ next
+ if $line_item->{locationnum}
+ && ref $options{section}
+ && !exists $options{section}->{locationnum}
+ && $self->has_sections
+ && $conf->config($tc.'sections_method') eq 'location';
+
warn "$me adding line item ".
join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
if $DEBUG > 1;
$line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'};
}
$line_item->{'ext_description'} ||= [];
-
+
+ if ( $options{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;
}
+ # If conf flag invoice_sections_with_taxes:
+ # - Add @detail_items for taxes into each section
+ # - Update section subtotal to include taxes
+ if ( $options{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'} ) {
push @buf, ( ['','-----------'],
[ $section->{'description'}. ' sub-total',
if $DEBUG > 1;
# create a tax section if we don't yet have one
+ my @items_tax = $self->_items_tax;
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 and @items_tax > 0;
}
$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.
#$tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
#$tax_section->{'sort_weight'} = $tax_weight;
- my @items_tax = $self->_items_tax;
- push @sections, $tax_section if $multisection and @items_tax > 0;
+ my $invoice_sections_with_taxes = $conf->config_bool(
+ 'invoice_sections_with_taxes', $cust_main->agentnum
+ );
foreach my $tax ( @items_tax ) {
- $taxtotal += $tax->{'amount'};
my $description = &$escape_function( $tax->{'description'} );
my $amount = sprintf( '%.2f', $tax->{'amount'} );
if ( $multisection ) {
+ if ( !$invoice_sections_with_taxes ) {
+
+ $taxtotal += $tax->{'amount'};
+
+ push @detail_items, {
+ ext_description => [],
+ ref => '',
+ quantity => '',
+ description => $description,
+ amount => $money_char. $amount,
+ product_code => '',
+ section => $tax_section,
+ };
- push @detail_items, {
- ext_description => [],
- ref => '',
- quantity => '',
- description => $description,
- amount => $money_char. $amount,
- product_code => '',
- section => $tax_section,
- };
-
+ }
} else {
+ $taxtotal += $tax->{'amount'};
+
push @total_items, {
'total_item' => $description,
'total_amount' => $other_money_char. $amount,
$other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
if ( $multisection ) {
+
+ if ( $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum) ) {
+ # If all tax items are displayed in location/category sections,
+ # remove the empty tax section
+ @sections = grep{ $_ ne $tax_section } @sections
+ unless grep{ $_->{section} eq $tax_section } @detail_items;
+ }
+
if ( $taxtotal > 0 ) {
# there are taxes, so prepare the section to be displayed.
# $taxtotal already includes any line items that were already in the
$tax_section->{'description'} = $self->mt($tax_description);
$tax_section->{'summarized'} = '';
- # append it if it's not already there
- if ( !grep $tax_section, @sections ) {
- push @sections, $tax_section;
- push @summary_subtotals, $tax_section;
- }
+ # append tax section unless it's already there
+ push @sections, $tax_section
+ unless grep {$_ eq $tax_section} @sections;
+
+ push @summary_subtotals, $tax_section
+ unless grep {$_ eq $tax_section} @summary_subtotals;
+
}
} else {
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 {
$_->{'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;
# ? $self->charged +
# $self->billing_balance
# :
- $self->owed + $pr_total
+ $balance_due
)
);
if ( $multisection && !$adjust_section->{sort_weight} ) {
$total->{'total_item'} = &$embolden_function($self->balance_due_msg);
$total->{'total_amount'} =
&$embolden_function(
- $other_money_char. sprintf('%.2f', $self->owed + $pr_total)
+ $other_money_char. sprintf('%.2f', $balance_due)
);
my $last_section = pop @sections;
$last_section->{'posttotal'} = $total->{'total_item'}. ' '.
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;
'invoice_';
}
-# helper routine for generating date ranges
-sub _prior_month30s {
- my $self = shift;
- my @ranges = (
- [ 1, 2592000 ], # 0-30 days ago
- [ 2592000, 5184000 ], # 30-60 days ago
- [ 5184000, 7776000 ], # 60-90 days ago
- [ 7776000, 0 ], # 90+ days ago
- );
-
- map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '',
- $_->[1] ? $self->_date - $_->[1] - 1 : '',
- ] }
- @ranges;
-}
-
=item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
Returns an postscript invoice, as a scalar.
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;
}
$self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
}
+=item invoice_pay_by_msg
+
+ displays the invoice_pay_by_msg or default Please pay by [_1] if empty.
+
+=cut
+
+sub invoice_pay_by_msg {
+ my $self = shift;
+ my $msg = '';
+ 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')) . ' ';
+
+ $msg;
+}
+
=item balance_due_msg
=cut
my $msg = $self->mt('Balance Due');
return $msg unless $self->terms; # huh?
if ( !$self->conf->exists('invoice_show_prior_due_date')
- or $self->conf->exists('invoice_sections') ) {
+ || $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 ) {
- $msg .= ' - ' . $self->mt('Please pay by'). ' '.
- $self->due_date2str('short');
+ $msg .= $self->invoice_pay_by_msg
+ unless $self->conf->config_bool('invoice_omit_due_date',$self->agentnum);
} elsif ( $self->terms ) {
$msg .= ' - '. $self->mt($self->terms);
}
=back
-Returns an argument list to be passed to L<FS::Misc::send_email>.
+Returns an argument list to be passed to L<FS::Misc/send_email>.
=cut
use MIME::Entity;
+use Encode;
sub generate_email {
if (!@text) {
- if ( $conf->config($tc.'template') ) {
+ if ( $conf->exists($tc.'template') ) {
warn "$me generating plain text invoice"
if $DEBUG;
- # 'print_text' argument is no longer used
@text = $self->print_text(\%args);
} else {
'Encoding' => 'quoted-printable',
'Charset' => 'UTF-8',
#'Encoding' => '7bit',
- 'Data' => \@text,
+ 'Data' => [
+ map
+ { Encode::encode('UTF-8', $_, Encode::FB_WARN | Encode::LEAVE_SRC ) }
+ @text
+ ],
'Disposition' => 'inline',
);
' </title>',
' </head>',
' <body bgcolor="#e8e8e8">',
- $html,
+ Encode::encode(
+ 'UTF-8',
+ $html,
+ Encode::FB_WARN | Encode::LEAVE_SRC
+ ),
' </body>',
'</html>',
],
=item mimebuild_pdf
Returns a list suitable for passing to MIME::Entity->build(), representing
-this invoice as PDF attachment.
+this quotation or invoice as PDF attachment.
=cut
'Encoding' => 'base64',
'Data' => [ $self->print_pdf(@_) ],
'Disposition' => 'attachment',
- 'Filename' => 'invoice-'. $self->invnum. '.pdf',
+ 'Filename' => $self->pdf_filename,
);
}
sub postal_mail_fsinc {
my ( $self, %opt ) = @_;
+ if ( $FS::Misc::DISABLE_PRINT ) {
+ warn 'postal_mail_fsinc() disabled by $FS::Misc::DISABLE_PRINT' if $DEBUG;
+ return;
+ }
+
my $url = 'https://ws.freeside.biz/print';
my $cust_main = $self->cust_main;
'ssl_opts' => {
verify_hostname => 0,
SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
+ SSL_version => 'SSLv3',
}
);
my $response = $ua->request( POST $url, [
'country' => $bill_location->country,
]);
- die "Print connection error: ". $response->message. "\n"
+ die "Print connection error: ". $response->message.
+ ' ('. $response->as_string. ")\n"
unless $response->is_success;
local $@;
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).
my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg;
my $escape_function = $options{escape_function};
+ my $locale = $self->cust_main
+ ? $self->cust_main->locale
+ : $self->prospect_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
warn "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n";
next;
}
- if ( exists($options{section}) and exists($options{section}{category}) )
- {
- my $categoryname = $options{section}{category};
- # then filter for items that have that section
- if ( $part_fee->categoryname ne $categoryname ) {
- warn "skipping fee '".$part_fee->itemdesc."'--not in section $categoryname\n" if $DEBUG;
- next;
- }
- } # otherwise include them all in the main section
- # XXX what to do when sectioning by location?
-
+
+ # If _items_fee is called while building a sectioned invoice,
+ # - invoice_sections_method: category
+ # Skip fee records that do not match the section category.
+ # - invoice_sections_method: location
+ # Skip fee records always for location sections.
+ # The fee records will be presented in the tax/fee section instead.
+ if (
+ exists( $options{section} )
+ and
+ (
+ (
+ exists( $options{section}{category} )
+ and
+ $part_fee->categoryname ne $options{section}{category}
+ )
+ or
+ exists( $options{section}{location})
+ )
+ ) {
+ warn "skipping fee '".$part_fee->itemdesc.
+ "'--not in section $options{section}{category}\n" if $DEBUG;
+ next;
+ }
+
my @ext_desc;
my %base_invnums; # invnum => invoice date
foreach ($cust_bill_pkg->cust_bill_pkg_fee) {
$self->mt('from invoice #[_1] on [_2]', $_, $base_invnums{$_})
);
}
- my $desc = $part_fee->itemdesc_locale($self->cust_main->locale);
+ 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,
+ billpkgnum => $cust_bill_pkg->billpkgnum,
amount => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
description => $desc,
+ pkg_tax => \@pkg_tax,
ext_description => \@ext_desc
# sdate/edate?
};
multisection: a flag indicating that this is a multisection invoice,
which does something complicated.
+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 $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
+ my $agentnum = $self->agentnum;
+
# for location labels: use default location on the invoice date
my $default_locationnum;
- if ( $self->custnum ) {
+ 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({
if $DEBUG > 1;
my $cust_pkg = $cust_bill_pkg->cust_pkg;
+
+ unless ( $cust_pkg ) {
+ # There is no related row in cust_pkg for this cust_bill_pkg.pkgnum.
+ # This invoice may have been broken by an unusual combination
+ # of manually editing package dates, and aborted package changes
+ # when the manually edited dates used are nonsensical.
+
+ my $error = sprintf
+ 'cust_bill_pkg(billpkgnum:%s) '.
+ 'is missing related row in cust_pkg(pkgnum:%s)! '.
+ 'cust_bill(invnum:%s) is corrupted by bad database data, '.
+ 'and should be investigated',
+ $cust_bill_pkg->billpkgnum,
+ $cust_bill_pkg->pkgnum,
+ $cust_bill_pkg->invnum;
+
+ FS::Log->new('FS::cust_bill_pkg')->critical( $error );
+ warn $error;
+ next;
+ }
+
my $part_pkg = $cust_pkg->part_pkg;
# which pkgpart to show for display purposes?
# not normally used, but pass this to the template anyway
$classname = $part_pkg->classname;
+ my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+ if $self->conf->exists('invoice_sections_with_taxes');
+
if ( (!$type || $type eq 'S')
&& ( $cust_bill_pkg->setup != 0
|| $cust_bill_pkg->setup_show_zero
# 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 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 @{ $s->{ext_description} }, @d;
} else {
$s = {
+ billpkgnum => $cust_bill_pkg->billpkgnum,
_is_setup => 1,
description => $description,
pkgpart => $pkgpart,
ext_description => \@d,
svc_label => ($svc_label || ''),
locationnum => $cust_pkg->locationnum, # sure, why not?
+ pkg_tax => \@pkg_tax,
};
};
$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...
push @{ $r->{ext_description} }, @d;
} else {
$r = {
+ billpkgnum => $cust_bill_pkg->billpkgnum,
description => $description,
pkgpart => $pkgpart,
pkgnum => $cust_bill_pkg->pkgnum,
ext_description => \@d,
svc_label => ($svc_label || ''),
locationnum => $cust_pkg->locationnum,
+ pkg_tax => \@pkg_tax,
};
$r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
}
} elsif ( $amount ) {
# create a new usage line
$u = {
+ billpkgnum => $cust_bill_pkg->billpkgnum,
description => $description,
pkgpart => $pkgpart,
pkgnum => $cust_bill_pkg->pkgnum,
%item_dates,
ext_description => \@d,
locationnum => $cust_pkg->locationnum,
+ pkg_tax => \@pkg_tax,
};
} # else this has no usage, so don't create a usage section
}
}
+=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;