use strict;
use vars qw( $DEBUG $me
- $money_char $date_format $rdate_format $date_format_long );
+ $money_char
+ $date_format
+ );
# 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;
use FS::pkg_class;
+use FS::invoice_mode;
use FS::L10N;
$DEBUG = 0;
$me = '[FS::Template_Mixin]';
-FS::UID->install_callback( sub {
+FS::UID->install_callback( sub {
my $conf = new FS::Conf; #global
- $money_char = $conf->config('money_char') || '$';
- $date_format = $conf->config('date_format') || '%x'; #/YY
- $rdate_format = $conf->config('date_format') || '%m/%d/%Y'; #/YYYY
- $date_format_long = $conf->config('date_format_long') || '%b %o, %Y';
+ $money_char = $conf->config('money_char') || '$';
+ $date_format = $conf->config('date_format') || '%x'; #/YY
} );
-=item print_text HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
+=item conf [ MODE ]
+
+Returns a configuration handle (L<FS::Conf>) set to the customer's locale.
+
+If the "mode" pseudo-field is set on the object, the configuration handle
+will be an L<FS::invoice_conf> for that invoice mode (and the customer's
+locale).
+
+=cut
+
+sub conf {
+ my $self = shift;
+ my $mode = $self->get('mode');
+ if ($self->{_conf} and !defined($mode)) {
+ return $self->{_conf};
+ }
+
+ my $cust_main = $self->cust_main;
+ my $locale = $cust_main ? $cust_main->locale : '';
+ my $conf;
+ if ( $mode ) {
+ if ( ref $mode and $mode->isa('FS::invoice_mode') ) {
+ $mode = $mode->modenum;
+ } elsif ( $mode =~ /\D/ ) {
+ die "invalid invoice mode $mode";
+ }
+ $conf = qsearchs('invoice_conf', { modenum => $mode, locale => $locale });
+ if (!$conf) {
+ $conf = qsearchs('invoice_conf', { modenum => $mode, locale => '' });
+ # it doesn't have a locale, but system conf still might
+ $conf->set('locale' => $locale) if $conf;
+ }
+ }
+ # if $mode is unspecified, or if there is no invoice_conf matching this mode
+ # and locale, then use the system config only (but with the locale)
+ $conf ||= FS::Conf->new({ 'locale' => $locale });
+ # cache it
+ return $self->{_conf} = $conf;
+}
+
+=item print_text OPTIONS
Returns an text invoice, as a list of lines.
-Options can be passed as a hashref (recommended) or as a list of time, template
-and then any key/value pairs for any other options.
+Options can be passed as a hash.
I<time>, if specified, is used to control the printing of overdue messages. The
default is now. It isn't the date of the invoice; that's the `_date' field.
sub print_text {
my $self = shift;
- my( $today, $template, %opt );
+ my %params;
if ( ref($_[0]) ) {
- %opt = %{ shift() };
- $today = delete($opt{'time'}) || '';
- $template = delete($opt{template}) || '';
+ %params = %{ shift() };
} else {
- ( $today, $template, %opt ) = @_;
+ %params = @_;
}
- my %params = ( 'format' => 'template' );
- $params{'time'} = $today if $today;
- $params{'template'} = $template if $template;
- $params{$_} = $opt{$_}
- foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
+ $params{'format'} = 'template'; # for some reason
$self->print_generic( %params );
}
-=item print_latex HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
+=item print_latex HASHREF
Internal method - returns a filename of a filled-in LaTeX template for this
invoice (Note: add ".tex" to get the actual filename), and a filename of
See print_ps and print_pdf for methods that return PostScript and PDF output.
-Options can be passed as a hashref (recommended) or as a list of time, template
-and then any key/value pairs for any other options.
+Options can be passed as a hash.
I<time>, if specified, is used to control the printing of overdue messages. The
default is now. It isn't the date of the invoice; that's the `_date' field.
L<Time::Local> and L<Date::Parse> for conversion functions.
I<template>, if specified, is the name of a suffix for alternate invoices.
+This is strongly deprecated; see L<FS::invoice_conf> for the right way to
+customize invoice templates for different purposes.
I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
sub print_latex {
my $self = shift;
- my $conf = $self->conf;
- my( $today, $template, %opt );
+ my %params;
+
if ( ref($_[0]) ) {
- %opt = %{ shift() };
- $today = delete($opt{'time'}) || '';
- $template = delete($opt{template}) || '';
+ %params = %{ shift() };
} else {
- ( $today, $template, %opt ) = @_;
+ %params = @_;
}
- my %params = ( 'format' => 'latex' );
- $params{'time'} = $today if $today;
- $params{'template'} = $template if $template;
- $params{$_} = $opt{$_}
- foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
+ $params{'format'} = 'latex';
+ my $conf = $self->conf;
+ # this needs to go away
+ my $template = $params{'template'};
+ # and this especially
$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';
UNLINK => 0,
) or die "can't open temp file: $!\n";
- my $cust_main = $self->cust_main;
- my $prospect_main = $self->prospect_main;
- my $agentnum = $cust_main ? $cust_main->agentnum : $prospect_main->agentnum;
+ my $agentnum = $self->agentnum;
if ( $template && $conf->exists("logo_${template}.eps", $agentnum) ) {
print $lh $conf->config_binary("logo_${template}.eps", $agentnum)
close $lh;
$params{'logo_file'} = $lh->filename;
- if( $conf->exists('invoice-barcode')
+ if( $conf->exists('invoice-barcode')
&& $self->can('invoice_barcode')
&& $self->invnum ) { # don't try to barcode statements
my $png_file = $self->invoice_barcode($dir);
$eps_file = $1;
my $curr_dir = cwd();
- chdir($dir);
+ chdir($dir);
# after painfuly long experimentation, it was determined that sam2p won't
# accept : and other chars in the path, no matter how hard I tried to
# escape them, hence the chdir (and chdir back, just to be safe)
}
my @filled_in = $self->print_generic( %params );
-
+
my $fh = new File::Temp( TEMPLATE => $tmp_template,
DIR => $dir,
SUFFIX => '.tex',
}
+sub agentnum {
+ my $self = shift;
+ my $cust_main = $self->cust_main;
+ $cust_main ? $cust_main->agentnum : $self->prospect_main->agentnum;
+}
+
=item print_generic OPTION => VALUE ...
Internal method - returns a filled-in template for this invoice as a scalar.
See print_ps and print_pdf for methods that return PostScript and PDF output.
-Non optional options include
- format - latex, html, template
+Required options
+
+=over 4
+
+=item format
-Optional options include
+The B<format> option is required and should be set to html, latex (print and PDF) or template (plaintext).
-template - a value used as a suffix for a configuration template
+=back
-time - a value used to control the printing of overdue messages. The
+Additional options
+
+=over 4
+
+=item notice_name
+
+Overrides "Invoice" as the name of the sent document.
+
+=item today
+
+Used to control the printing of overdue messages. The
default is now. It isn't the date of the invoice; that's the `_date' field.
It is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
L<Time::Local> and L<Date::Parse> for conversion functions.
-cid -
+=item logo_file
+
+Logo file (path to temporary EPS file on the local filesystem)
+
+=item cid
+
+CID for inline (emailed) images (logo)
+
+=item locale
+
+Override customer's locale
+
+=item unsquelch_cdr
+
+Overrides any per customer cdr squelching when true
+
+=item no_number
+
+Supress the (invoice, quotation, statement, etc.) number
+
+=item no_date
+
+Supress the date
-unsquelch_cdr - overrides any per customer cdr squelching when true
+=item no_coupon
-notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+Supress the payment coupon
-locale - override customer's locale
+=item barcode_file
+
+Barcode file (path to temporary EPS file on the local filesystem)
+
+=item barcode_img
+
+Flag indicating the barcode image should be a link (normal HTML dipaly)
+
+=item barcode_cid
+
+Barcode CID for inline (emailed) images
+
+=item preref_callback
+
+Coderef run for each line item, code should return HTML to be displayed
+before that line item (quotations only)
+
+=item template
+
+Deprecated. Used as a suffix for a configuration template. Please
+don't use this, it deprecated in favor of more flexible alternatives.
+
+=back
=cut
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;
unless $format =~ /^(latex|html|template)$/;
my $cust_main = $self->cust_main || $self->prospect_main;
- $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') )
- unless $cust_main->payname
- && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
+
+ my $locale = $params{'locale'} || $cust_main->locale;
my %delimiters = ( 'latex' => [ '[@--', '--@]' ],
'html' => [ '<%=', '%>' ],
warn "$me print_generic creating template\n"
if $DEBUG > 1;
+ # set the notice name here, and nowhere else.
+ my $notice_name = $params{notice_name}
+ || $conf->config('notice_name')
+ || $self->notice_name;
+
#create the template
my $template = $params{template} ? $params{template} : $self->_agent_template;
my $templatefile = $self->template_conf. $format;
$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";
- my $old_latex = '';
if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
#change this to a die when the old code is removed
- warn "old-style invoice template $templatefile; ".
+ # it's been almost ten years, changing it to a die
+ die "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);
- }
+ #$old_latex = 'true';
+ #@invoice_template = _translate_old_latex_format(@invoice_template);
+ }
warn "$me print_generic creating T:T object\n"
if $DEBUG > 1;
# additional substitution could possibly cause breakage in existing templates
- my %convert_maps = (
+ my %convert_maps = (
'latex' => {
'notes' => sub { map "$_", @_ },
'footer' => sub { map "$_", @_ },
'html' => {
'notes' =>
sub {
- map {
+ map {
s/%%(.*)$/<!-- $1 -->/g;
s/\\section\*\{\\textsc\{(.)(.*)\}\}/<p><b><font size="+1">$1<\/font>\U$2<\/b>/g;
s/\\begin\{enumerate\}/<ol>/g;
sub { map { s/~/ /g; s/\\\\\*?\s*$/<BR>/; $_; } @_ },
'returnaddress' =>
sub {
- map {
+ map {
s/~/ /g;
s/\\\\\*?\s*$/<BR>/;
s/\\hyphenation\{[\w\s\-]+}//;
'template' => {
'notes' =>
sub {
- map {
+ map {
s/%%.*$//g;
s/\\section\*\{\\textsc\{(.*)\}\}/\U$1/g;
s/\\begin\{enumerate\}//g;
sub { map { s/~/ /g; s/\\\\\*?\s*$/\n/; $_; } @_ },
'returnaddress' =>
sub {
- map {
+ map {
s/~/ /g;
s/\\\\\*?\s*$/\n/; # dubious
s/\\hyphenation\{[\w\s\-]+}//;
my $escape_function_nonbsp = ($format eq 'html')
? \&_html_escape : $escape_function;
- my %date_formats = ( 'latex' => $date_format_long,
- 'html' => $date_format_long,
- 'template' => '%s',
- );
- $date_formats{'html'} =~ s/ / /g;
-
- my $date_format = $date_formats{$format};
-
my %newline_tokens = ( 'latex' => '\\\\',
'html' => '<br>',
'template' => "\n",
# generate template variables
my $returnaddress;
+
if (
defined( $conf->config_orbase( "invoice_${format}returnaddress",
$template
'agent' => &$escape_function($cust_main->agent->agent),
#invoice/quotation info
- 'invnum' => $self->invnum,
+ 'no_number' => $params{'no_number'},
+ 'invnum' => ( $params{'no_number'} ? '' : $self->invnum ),
'quotationnum' => $self->quotationnum,
- 'date' => time2str($date_format, $self->_date),
- 'today' => time2str($date_format_long, $today),
+ 'no_date' => $params{'no_date'},
+ '_date' => ( $params{'no_date'} ? '' : $self->_date ),
+ # workaround for inconsistent behavior in the early plain text
+ # templates; see RT#28271
+ 'date' => ( $params{'no_date'}
+ ? ''
+ : ($format eq 'template'
+ ? $self->_date
+ : $self->time2str_local('long', $self->_date, $format)
+ )
+ ),
+ 'today' => $self->time2str_local('long', $today, $format),
'terms' => $self->terms,
'template' => $template, #params{'template'},
- 'notice_name' => ($params{'notice_name'} || $self->notice_name),#escape_function?
+ 'notice_name' => $notice_name, # escape?
'current_charges' => sprintf("%.2f", $self->charged),
- 'duedate' => $self->due_date2str($rdate_format), #date_format?
+ 'duedate' => $self->due_date2str('rdate'), #date_format?
+ 'duedate_long' => $self->due_date2str('long'),
#customer info
'custnum' => $cust_main->display_custnum,
'prospectnum' => $cust_main->prospectnum,
'agent_custid' => &$escape_function($cust_main->agent_custid),
- ( map { $_ => &$escape_function($cust_main->$_()) } qw(
- payname company address1 address2 city state zip fax
- )),
+ ( map { $_ => &$escape_function($cust_main->$_()) }
+ qw( company address1 address2 city state zip fax )
+ ),
+ 'payname' => &$escape_function( $cust_main->invoice_attn
+ || $cust_main->contact_firstlast ),
#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'),
'balance_due_below_line' => $conf->exists('balance_due_below_line'),
-
+
#layout info -- would be fancy to calc some of this and bury the template
# here in the code
'topmargin' => scalar($conf->config('invoice_latextopmargin', $agentnum)),
'total_pages' => 1,
);
-
+
+ #quotations have $name
+ $invoice_data{'name'} = $invoice_data{'payname'};
+
#localization
- my $lh = FS::L10N->get_handle( $params{'locale'} || $cust_main->locale );
$invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
- my %info = FS::Locales->locale_info($cust_main->locale || 'en_US');
- # eval to avoid death for unimplemented languages
- my $dh = eval { Date::Language->new($info{'name'}) } ||
- Date::Language->new(); # fall back to English
# prototype here to silence warnings
- $invoice_data{'time2str'} = sub ($;$$) { $dh->time2str(@_) };
- # eventually use this date handle everywhere in here, too
+ $invoice_data{'time2str'} = sub ($;$$) { $self->time2str_local(@_, $format) };
my $min_sdate = 999999999999;
my $max_edate = 0;
}
$invoice_data{'bill_period'} = '';
- $invoice_data{'bill_period'} = time2str('%e %h', $min_sdate)
- . " to " . time2str('%e %h', $max_edate)
+ $invoice_data{'bill_period'} =
+ $self->time2str_local('%e %h', $min_sdate, $format)
+ . " to " .
+ $self->time2str_local('%e %h', $max_edate, $format)
if ($max_edate != 0 && $min_sdate != 999999999999);
$invoice_data{finance_section} = '';
my $pkg_class =
qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
$invoice_data{finance_section} = $pkg_class->categoryname;
- }
+ }
$invoice_data{finance_amount} = '0.00';
$invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
my $countrydefault = $conf->config('countrydefault') || 'US';
foreach ( qw( address1 address2 city state zip country fax) ){
my $method = 'ship_'.$_;
- $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
+ $invoice_data{"ship_$_"} = $escape_function->($cust_main->$method);
}
- foreach ( qw( contact company ) ) { #compatibility
- $invoice_data{"ship_$_"} = _latex_escape($cust_main->$_);
+ if ( length($cust_main->ship_company) ) {
+ $invoice_data{'ship_company'} = $escape_function->($cust_main->ship_company);
+ } else {
+ $invoice_data{'ship_company'} = $escape_function->($cust_main->company);
}
+ $invoice_data{'ship_contact'} = $escape_function->($cust_main->contact);
$invoice_data{'ship_country'} = ''
if ( $invoice_data{'ship_country'} eq $countrydefault );
-
+
$invoice_data{'cid'} = $params{'cid'}
if $params{'cid'};
- if ( $cust_main->country eq $countrydefault ) {
- $invoice_data{'country'} = '';
+ 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'} = &$escape_function(code2country($cust_main->country));
+ $invoice_data{'country'} = '';
}
my @address = ();
$invoice_data{'address'} = \@address;
push @address,
- $cust_main->payname.
- ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo
- ? " (P.O. #". $cust_main->payinfo. ")"
- : ''
+ $invoice_data{'payname'}.
+ ( $cust_main->po_number
+ ? " (P.O. #". $cust_main->po_number. ")"
+ : ''
)
;
push @address, $cust_main->company
$invoice_data{'barcode_cid'} = $params{'barcode_cid'}
if $params{'barcode_cid'};
- my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance
-# my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits
- #my $balance_due = $self->owed + $pr_total - $cr_total;
- my $balance_due = $self->owed + $pr_total;
- #these are used on the summary page only
+ # 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);
- # the customer's current balance as shown on the invoice before this one
- $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) );
+ # flag telling this invoice to have a first-page summary
+ my $summarypage = '';
- # the change in balance from that invoice to this one
- $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) );
+ if ( $self->custnum && $self->invnum ) {
+ # XXX should be an FS::cust_bill method to set the defaults, instead
+ # of checking the type here
- # 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);
+ # info from customer's last invoice before this one, for some
+ # summary formats
+ $invoice_data{'last_bill'} = {};
- # 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);
+ # my $last_bill = $self->previous_bill;
+ # if ( $last_bill ) {
- # info from customer's last invoice before this one, for some
- # summary formats
- $invoice_data{'last_bill'} = {};
- # returns the last unpaid bill, not the last bill
- #my $last_bill = $pr_cust_bill[-1];
- # THIS returns the customer's last bill before this one
- my $last_bill = qsearchs({
- 'table' => 'cust_bill',
- 'hashref' => { 'custnum' => $self->custnum,
- 'invnum' => { op => '<', value => $self->invnum },
- },
- 'order_by' => ' ORDER BY invnum DESC LIMIT 1'
- });
- if ( $last_bill ) {
- $invoice_data{'last_bill'} = {
- '_date' => $last_bill->_date, #unformatted
- # all we need for now
- };
- 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' => time2str($date_format, $cust_pay->_date),
- 'payinfo' => $cust_pay->payby_payinfo_pretty,
- 'amount' => sprintf('%.2f', $cust_pay->paid),
- };
- # not concerned about applications
+ # 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()
+ );
+
+ # Used in invoice template as "Previous Balance"
+ $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total);
+
+ # $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;
+
+ # $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
+ );
+ }
+
+ } else {
+ # 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_credits'} = [];
}
- 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' => time2str($date_format, $cust_credit->_date),
- 'creditreason'=> $cust_credit->cust_credit->reason,
- 'amount' => sprintf('%.2f', $cust_credit->amount),
- };
+
+ # 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
+ );
}
- $invoice_data{'previous_payments'} = \@payments;
- $invoice_data{'previous_credits'} = \@credits;
- }
- my $summarypage = '';
- if ( $conf->exists('invoice_usesummary', $agentnum) ) {
- $summarypage = 1;
- }
- $invoice_data{'summarypage'} = $summarypage;
+ if ( $conf->config_bool('invoice_usesummary', $agentnum) ) {
+ $invoice_data{'summarypage'} = $summarypage = 1;
+ }
+
+ } # 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+$//
if ($format eq 'latex');
}
- # 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($self->mt("Purchase Order #").$cust_main->payinfo)
- : $nbsp;
+# if (well, probably when) we still need PO numbers in the brave new world of
+# 4.x, then we'll have to add them back as their own customer fields
+# # 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($self->mt("Purchase Order #").$cust_main->payinfo)
+# : $nbsp;
my %money_chars = ( 'latex' => '',
'html' => $conf->config('money_char') || '$',
);
my $money_char = $money_chars{$format};
+ # extremely dubious
my %other_money_chars = ( 'latex' => '\dollar ',#XXX should be a config too
'html' => $conf->config('money_char') || '$',
'template' => '',
warn "$me generating sections\n"
if $DEBUG > 1;
+ my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
+ my $multisection = $self->has_sections;
+ if ( $multisection ) {
+ $invoice_data{multisection} = $conf->config($tc.'sections_method') || 1;
+ }
+ my $late_sections;
+ my $extra_sections = [];
+ my $extra_lines = ();
+
+ # default section ('Charges')
+ my $default_section = { 'description' => '',
+ 'subtotal' => '',
+ 'no_subtotal' => 1,
+ };
+
# Previous Charges section
# subtotal is the first return value from $self->previous
- my $previous_section = { 'description' => $self->mt('Previous Charges'),
+ my $previous_section;
+ # if the invoice has major sections, or if we're summarizing previous
+ # charges with a single line, or if we've been specifically told to put them
+ # in a section, create a section for previous charges:
+ if ( $multisection or
+ $conf->exists('previous_balance-summary_only') or
+ $conf->exists('previous_balance-section') ) {
+
+ $previous_section = { 'description' => $self->mt('Previous Charges'),
'subtotal' => $other_money_char.
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');
-
- my $taxtotal = 0;
- my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
- 'subtotal' => $taxtotal, # adjusted below
- };
- 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;
+ # 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
+ $previous_section = $default_section;
+ }
- my $adjusttotal = 0;
my $adjust_section = {
'description' => $self->mt('Credits, Payments, and Adjustments'),
'adjust_section' => 1,
? _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;
- my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
- my $multisection = $conf->exists($tc.'_sections', $cust_main->agentnum);
- $invoice_data{'multisection'} = $multisection;
- my $late_sections = [];
- my $extra_sections = [];
- my $extra_lines = ();
-
- my $default_section = { 'description' => '',
- 'subtotal' => '',
- 'no_subtotal' => 1,
- };
if ( $multisection ) {
($extra_sections, $extra_lines) =
push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
push @detail_items, @$extra_lines if $extra_lines;
- push @sections,
- $self->_items_sections( $late_sections, # this could stand a refactor
- $summarypage,
- $escape_function_nonbsp,
- $extra_sections,
- $format, #bah
+
+ # the code is written so that both methods can be used together, but
+ # we haven't yet changed the template to take advantage of that, so for
+ # now, treat them as mutually exclusive.
+ my %section_method = ( by_category => 1 );
+ if ( $conf->config($tc.'sections_method') eq 'location' ) {
+ %section_method = ( by_location => 1 );
+ }
+ my ($early, $late) =
+ $self->_items_sections( 'summary' => $summarypage,
+ 'escape' => $escape_function_nonbsp,
+ 'extra_sections' => $extra_sections,
+ 'format' => $format,
+ %section_method
);
+ push @sections, @$early;
+ $late_sections = $late;
+
if ( $conf->exists('svc_phone_sections')
&& $self->can('_items_svc_phone_sections')
)
# make a default section
push @sections, $default_section;
# and calculate the finance charge total, since it won't get done otherwise.
- # XXX possibly other totals?
+ # and the default section total
# 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;
- }
+ my @finance_charges;
+ my @charges;
+ foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+ if ( $invoice_data{finance_section} and
+ 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;
+ } else {
+ push @charges, $cust_bill_pkg->recur + $cust_bill_pkg->setup;
}
- $invoice_data{finance_amount} =
- sprintf('%.2f', sum( @finance_charges ) || 0);
}
+ $invoice_data{finance_amount} =
+ sprintf('%.2f', sum( @finance_charges ) || 0);
+ $default_section->{subtotal} = $other_money_char.
+ sprintf('%.2f', sum( @charges ) || 0);
+ }
+
+ # 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') ) {
+ # then re-section them by the correct method
+ my %section_method = ( by_category => 1 );
+ if ( $conf->config('summary_subtotals_method') eq 'location' ) {
+ %section_method = ( by_location => 1 );
+ }
+ my ($early, $late) =
+ $self->_items_sections( 'summary' => $summarypage,
+ 'escape' => $escape_function_nonbsp,
+ 'extra_sections' => $extra_sections,
+ 'format' => $format,
+ %section_method
+ );
+ foreach ( @$early ) {
+ next if $_->{subtotal} == 0;
+ $_->{subtotal} = $other_money_char.sprintf('%.2f', $_->{subtotal});
+ push @summary_subtotals, $_;
+ }
+ } else {
+ # subtotal sectioning is the same as for the actual invoice sections
+ @summary_subtotals = grep $_->{subtotal}, @sections;
}
+ # Hereafter, push sections to both @sections and @summary_subtotals
+ # if they belong in both places (e.g. tax section). Late sections are
+ # never in @summary_subtotals.
+
# previous invoice balances in the Previous Charges section if there
# is one, otherwise in the main detail section
+ # (except if summary_only is enabled, don't show them at all)
if ( $self->can('_items_previous') &&
$self->enable_previous &&
! $conf->exists('previous_balance-summary_only') ) {
foreach my $line_item ( $self->_items_previous ) {
my $detail = {
- ext_description => [],
+ ref => $line_item->{'pkgnum'},
+ pkgpart => $line_item->{'pkgpart'},
+ #quantity => 1, # not really correct
+ section => $previous_section, # which might be $default_section
+ description => &$escape_function($line_item->{'description'}),
+ ext_description => [ map { &$escape_function($_) }
+ @{ $line_item->{'ext_description'} || [] }
+ ],
+ amount => $money_char . $line_item->{'amount'},
+ product_code => $line_item->{'pkgpart'} || 'N/A',
};
- $detail->{'ref'} = $line_item->{'pkgnum'};
- $detail->{'pkgpart'} = $line_item->{'pkgpart'};
- $detail->{'quantity'} = 1;
- $detail->{'section'} = $multisection ? $previous_section
- : $default_section;
- $detail->{'description'} = &$escape_function($line_item->{'description'});
- if ( exists $line_item->{'ext_description'} ) {
- @{$detail->{'ext_description'}} = map {
- &$escape_function($_);
- } @{$line_item->{'ext_description'}};
- }
- $detail->{'amount'} = ( $old_latex ? '' : $money_char).
- $line_item->{'amount'};
- $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
push @detail_items, $detail;
push @buf, [ $detail->{'description'},
}
- 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) ];
push @buf, ['',''];
}
-
+
if ( $conf->exists('svc_phone-did-summary') && $self->can('_did_summary') ) {
warn "$me adding DID summary\n"
if $DEBUG > 1;
my ($didsummary,$minutes) = $self->_did_summary;
my $didsummary_desc = 'DID Activity Summary (since last invoice)';
- push @detail_items,
+ push @detail_items,
{ 'description' => $didsummary_desc,
'ext_description' => [ $didsummary, $minutes ],
};
foreach my $section (@sections, @$late_sections) {
- warn "$me adding section \n". Dumper($section)
- if $DEBUG > 1;
-
# begin some normalization
$section->{'subtotal'} = $section->{'amount'}
if $multisection
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{'summary_page'} = $summarypage;
$options{'skip_usage'} =
scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
- $options{'multisection'} = $multisection;
+ $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;
- foreach my $line_item ( $self->_items_pkg(%options) ) {
-
- warn "$me adding line item $line_item\n"
+ 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;
- my $detail = {
- ext_description => [],
- };
- $detail->{'ref'} = $line_item->{'pkgnum'};
- $detail->{'pkgpart'} = $line_item->{'pkgpart'};
- $detail->{'quantity'} = $line_item->{'quantity'};
- $detail->{'section'} = $section;
- $detail->{'description'} = &$escape_function($line_item->{'description'});
- if ( exists $line_item->{'ext_description'} ) {
- @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}};
- }
- $detail->{'amount'} = ( $old_latex ? '' : $money_char ).
- $line_item->{'amount'};
- if ( exists $line_item->{'unit_amount'} ) {
- $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ).
- $line_item->{'unit_amount'};
- }
- $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A';
-
- $detail->{'sdate'} = $line_item->{'sdate'};
- $detail->{'edate'} = $line_item->{'edate'};
- $detail->{'seconds'} = $line_item->{'seconds'};
- $detail->{'svc_label'} = $line_item->{'svc_label'};
-
- push @detail_items, $detail;
- push @buf, ( [ $detail->{'description'},
+ push @buf, ( [ $line_item->{'description'},
$money_char. sprintf("%10.2f", $line_item->{'amount'}),
],
- map { [ " ". $_, '' ] } @{$detail->{'ext_description'}},
+ 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->{'description'} = &$escape_function($line_item->{'description'});
+ $line_item->{'amount'} = $money_char.$line_item->{'amount'};
+
+ if ( length($line_item->{'unit_amount'}) ) {
+ $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.
+
+ # $section->{subtotal} = '$5.00 to 12.00'; # for testing:
+ 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',
- $section->{'subtotal'} # already formatted this
+ $section->{'subtotal'} # already formatted this
],
[ '', '' ],
[ '', '' ],
);
}
-
+
}
$invoice_data{current_less_finance} =
sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
- # create a major section for previous balance if we have major sections,
- # or if previous_section is in summary form
- if ( ( $multisection && $self->enable_previous )
- || $conf->exists('previous_balance-summary_only') )
- {
- unshift @sections, $previous_section if $pr_total;
+ # if there's anything in the Previous Charges section, prepend it to the list
+ if ( $pr_total and $previous_section ne $default_section ) {
+ unshift @sections, $previous_section;
+ # but not @summary_subtotals
}
warn "$me adding taxes\n"
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.
+ # 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;
+
foreach my $tax ( @items_tax ) {
$taxtotal += $tax->{'amount'};
if ( $multisection ) {
- my $money = $old_latex ? '' : $money_char;
push @detail_items, {
ext_description => [],
ref => '',
quantity => '',
description => $description,
- amount => $money. $amount,
+ amount => $money_char. $amount,
product_code => '',
section => $tax_section,
};
];
}
-
+
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'};
- push @sections, $tax_section if $taxtotal;
- }else{
+ 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
+ }
- # 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;
- }
+ if ( $self->can('_items_total') ) { # should always be true now
- $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) );
+ # even for multisection, need plain text version
+
+ 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
- if ( $self->enable_previous
+ # credits and payments
+ if ( $self->enable_previous
and $self->can('_items_credits')
and $self->can('_items_payments') )
{
- #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
-
+
# credits
my $credittotal = 0;
- foreach my $credit ( $self->_items_credits('trim_len'=>60) ) {
+ foreach my $credit (
+ $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 ) {
- my $money = $old_latex ? '' : $money_char;
push @detail_items, {
ext_description => [],
ref => '',
quantity => '',
description => &$escape_function($credit->{'description'}),
- amount => $money. $credit->{'amount'},
+ amount => $money_char . $credit->{'amount'},
product_code => '',
section => $adjust_section,
};
$invoice_data{'credittotal'} = sprintf('%.2f', $credittotal);
#credits (again)
- foreach my $credit ( $self->_items_credits('trim_len'=>32) ) {
+ foreach my $credit (
+ $self->_items_credits( 'template' => $template, 'trim_len'=>32 )
+ ) {
push @buf, [ $credit->{'description'}, $money_char.$credit->{'amount'} ];
}
# payments
my $paymenttotal = 0;
- foreach my $payment ( $self->_items_payments ) {
+ foreach my $payment (
+ $self->_items_payments( 'template' => $template )
+ ) {
my $total = {};
$total->{'total_item'} = &$escape_function($payment->{'description'});
$paymenttotal += $payment->{'amount'};
$total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'};
- $adjusttotal += $payment->{'amount'};
if ( $multisection ) {
- my $money = $old_latex ? '' : $money_char;
push @detail_items, {
ext_description => [],
ref => '',
quantity => '',
description => &$escape_function($payment->{'description'}),
- amount => $money. $payment->{'amount'},
+ amount => $money_char . $payment->{'amount'},
product_code => '',
section => $adjust_section,
};
];
}
$invoice_data{'paymenttotal'} = sprintf('%.2f', $paymenttotal);
-
+
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
+ # different rules
}
# create Balance Due message
- {
+ {
my $total;
$total->{'total_item'} = &$embolden_function($self->balance_due_msg);
$total->{'total_amount'} =
&$embolden_function(
- $other_money_char. sprintf('%.2f', #why? $summarypage
+ $other_money_char. sprintf('%.2f', #why? $summarypage
# ? $self->charged +
# $self->billing_balance
# :
- $self->owed + $pr_total
+ $balance_due
)
);
if ( $multisection && !$adjust_section->{sort_weight} ) {
$adjust_section->{'posttotal'} = $total->{'total_item'}. ' '.
$total->{'total_amount'};
- }else{
+ } else {
push @total_items, $total;
}
push @buf,['','-----------'];
- push @buf,[$self->balance_due_msg, $money_char.
+ push @buf,[$self->balance_due_msg, $money_char.
sprintf("%10.2f", $balance_due ) ];
}
push @total_items, $credit_total;
}
push @buf,['','-----------'];
- push @buf,[$self->credit_balance_msg, $money_char.
+ push @buf,[$self->credit_balance_msg, $money_char.
sprintf("%10.2f", -$cust_main->balance ) ];
}
}
$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'}. ' '.
}
# make a discounts-available section, even without multisection
- if ( $conf->exists('discount-show_available')
+ if ( $conf->exists('discount-show_available')
and my @discounts_avail = $self->_items_discounts_avail ) {
my $discount_section = {
'description' => $self->mt('Discounts Available'),
'no_subtotal' => 1,
};
- push @sections, $discount_section;
+ push @sections, $discount_section; # do not summarize
push @detail_items, map { +{
'ref' => '', #should this be something else?
'section' => $discount_section,
} } @discounts_avail;
}
- # debugging hook: call this with 'diag' => 1 to just get a hash of
+ # not adding any more sections after this
+ $invoice_data{summary_subtotals} = \@summary_subtotals;
+
+ # 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, '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') and $cust_main->isa('FS::cust_main') ) {
+ my %history;
+ my %monthorder;
+ foreach my $cust_bill ( $cust_main->cust_bill ) {
+ # XXX hardcoded format, and currently only 'charged'; add other fields
+ # if they become necessary
+ my $date = $self->time2str_local('%b %Y', $cust_bill->_date);
+ $history{$date} ||= 0;
+ $history{$date} += $cust_bill->charged;
+ # just so we have a numeric sort key
+ $monthorder{$date} ||= $cust_bill->_date;
+ }
+ my @sorted_months = sort { $monthorder{$a} <=> $monthorder{$b} }
+ keys %history;
+ my @sorted_amounts = map { sprintf('%.2f', $history{$_}) } @sorted_months;
+ $invoice_data{monthly_history} = [ \@sorted_months, \@sorted_amounts ];
+ }
+
+ # service locations: another option for template customization
+ my %location_info;
+ foreach my $item (@detail_items) {
+ if ( $item->{locationnum} ) {
+ $location_info{ $item->{locationnum} } ||= {
+ FS::cust_location->by_key( $item->{locationnum} )->location_hash
+ };
+ }
+ }
+ $invoice_data{location_info} = \%location_info;
+
+ # debugging hook: call this with 'diag' => 1 to just get a hash of
# the invoice variables
return \%invoice_data if ( $params{'diag'} );
@inc_src = map { s/\[\@--/$delimiters{$format}[0]/g;
s/--\@\]/$delimiters{$format}[1]/g;
$_;
- }
+ }
&$convert_map( $conf->config($inc_file, $agentnum) );
}
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"
sub notice_name { '('.shift->table.')'; }
-sub template_conf { '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;
+# this is not supposed to happen
+sub template_conf { warn "bare FS::Template_Mixin::template_conf";
+ 'invoice_';
}
=item print_ps HASHREF | [ TIME [ , TEMPLATE ] ]
my $self = shift;
my %params;
if ( ref($_[0]) ) {
- %params = %{ shift() };
- }else{
- $params{'time'} = shift;
- $params{'template'} = shift;
- $params{'cid'} = shift;
+ %params = %{ shift() };
+ } else {
+ %params = @_;
}
-
$params{'format'} = 'html';
-
+
$self->print_generic( %params );
}
# quick subroutine for print_latex
#
# There are ten characters that LaTeX treats as special characters, which
-# means that they do not simply typeset themselves:
+# means that they do not simply typeset themselves:
# # $ % & ~ _ ^ \ { }
#
# TeX ignores blanks following an escaped character; if you want a blank (as
-# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ...").
+# in "10% of ..."), you have to "escape" the blank as well ("10\%\ of ...").
sub _latex_escape {
my $value = shift;
sub _translate_old_latex_format {
warn "_translate_old_latex_format called\n"
- if $DEBUG;
+ if $DEBUG;
my @template = ();
while ( @_ ) {
my $line = shift;
-
+
if ( $line =~ /^%%Detail\s*$/ ) {
-
+
push @template, q![@--!,
q! foreach my $_tr_line (@detail_items) {!,
q! if ( scalar ($_tr_item->{'ext_description'} ) ) {!,
- q! $_tr_line->{'description'} .= !,
+ q! $_tr_line->{'description'} .= !,
q! "\\tabularnewline\n~~".!,
q! join( "\\tabularnewline\n~~",!,
q! @{$_tr_line->{'ext_description'}}!,
} else {
$line =~ s/\$(\w+)/[\@-- \$$1 --\@]/g;
- push @template, $line;
+ push @template, $line;
}
-
+
}
if ($DEBUG) {
(@template);
}
+=item terms
+
+=cut
+
sub terms {
my $self = shift;
my $conf = $self->conf;
#check for an invoice-specific override
return $self->invoice_terms if $self->invoice_terms;
-
+
#check for a customer- specific override
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 ? time2str(shift, $self->due_date) : '';
+ $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
+
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($date_format);
- } 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 ) {
+ $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);
+ }
}
$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*$/ ) {
- $duedate = time2str($rdate_format, $self->_date + ($1*86400) );
+ my $terms = $self->terms;
+ if ( $terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
+ $duedate = $self->time2str_local('rdate', $self->_date + ($1*86400) );
}
$duedate;
}
-sub credit_balance_msg {
+sub credit_balance_msg {
my $self = shift;
$self->mt('Credit Balance Remaining')
}
=item _date_pretty
-Returns a string with the date, for example: "3/20/2008"
+Returns a string with the date, for example: "3/20/2008", localized for the
+customer. Use _date_pretty_unlocalized for non-end-customer display use.
=cut
sub _date_pretty {
+ my $self = shift;
+ $self->time2str_local('short', $self->_date);
+}
+
+=item _date_pretty_unlocalized
+
+Returns a string with the date, for example: "3/20/2008", in the format
+configured for the back-office. Use _date_pretty for end-customer display use.
+
+=cut
+
+sub _date_pretty_unlocalized {
my $self = shift;
time2str($date_format, $self->_date);
}
-=item _items_sections LATE SUMMARYPAGE ESCAPE EXTRA_SECTIONS FORMAT
+=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 $cust_msg = $msg_template->prepare(
+ cust_main => $self->cust_main,
+ object => $self,
+ msgtype => 'invoice',
+ );
+
+ # XXX hack to make this work in the new cust_msg era; consider replacing
+ # with cust_bill_send_with_notice events.
+ my @parts = $cust_msg->parts;
+ foreach my $part (@parts) { # will only have two parts, normally
+ if ( $part->mime_type eq 'text/plain' ) {
+ @text = @{ $part->body };
+ } elsif ( $part->mime_type eq 'text/html' ) {
+ $html = $part->bodyhandle->as_string;
+ }
+ }
+
+ } 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 quotation or invoice as PDF attachment.
+
+=cut
+
+sub mimebuild_pdf {
+ my $self = shift;
+ (
+ 'Type' => 'application/pdf',
+ 'Encoding' => 'base64',
+ 'Data' => [ $self->print_pdf(@_) ],
+ 'Disposition' => 'attachment',
+ 'Filename' => $self->pdf_filename,
+ );
+}
+
+=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 Cpanel::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->invoice_attn
+ || $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.
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 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
+Section descriptions are returned in sort weight order. Each consists
of a hash containing:
description: the package category name, escaped
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
+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:
+The method returns two arrayrefs, one of "early" sections and one of "late"
+sections.
+
+OPTIONS may include:
+
+by_location: a flag to divide the invoice into sections by location.
+Each section hash will have a 'location' element containing a hashref of
+the location fields (see L<FS::cust_location>). The section description
+will be the location label, but the template can use any of the location
+fields to create a suitable label.
-LATE: an arrayref to push the "late" section hashes onto. The "early"
-group is simply returned from the method.
+by_category: a flag to divide the invoice into sections using display
+records (see L<FS::cust_bill_pkg_display>). This is the "traditional"
+behavior. Each section hash will have a 'category' element containing
+the section name from the display record (which probably equals the
+category name of the package, but may not in some cases).
-SUMMARYPAGE: a flag indicating whether this is a summary-format invoice.
+summary: a flag indicating that 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
+- Places all sections in the "early" group even if they have post_total.
+- 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.
+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
+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
+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;
- my $late = shift;
- my $summarypage = shift;
- my $escape = shift;
- my $extra_sections = shift;
- my $format = shift;
+ my %opt = @_;
+
+ my $escape = $opt{escape};
+ my @extra_sections = @{ $opt{extra_sections} || [] };
+ # $subtotal{$locationnum}{$categoryname} = amount.
+ # if we're not using by_location, $locationnum is undef.
+ # if we're not using by_category, you guessed it, $categoryname is undef.
+ # if we're not using either one, we shouldn't be here in the first place...
my %subtotal = ();
my %late_subtotal = ();
my %not_tax = ();
+ # About tax items + multisection invoices:
+ # If either invoice_*summary option is enabled, AND there is a
+ # package category with the name of the tax, then there will be
+ # a display record assigning the tax item to that category.
+ #
+ # However, the taxes are always placed in the "Taxes, Surcharges,
+ # and Fees" section regardless of that. The only effect of the
+ # display record is to create a subtotal for the summary page.
+
+ # cache these
+ my $pkg_hash = $self->cust_pkg_hash;
+
foreach my $cust_bill_pkg ( $self->cust_bill_pkg )
{
my $usage = $cust_bill_pkg->usage;
+ my $locationnum;
+ if ( $opt{by_location} ) {
+ if ( $cust_bill_pkg->pkgnum ) {
+ $locationnum = $pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum;
+ } else {
+ $locationnum = '';
+ }
+ } else {
+ $locationnum = undef;
+ }
+
+ # as in _items_cust_pkg, if a line item has no display records,
+ # cust_bill_pkg_display() returns a default record for it
+
foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) {
- next if ( $display->summary && $summarypage );
+ next if ( $display->summary && $opt{summary} );
+
+ #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 $section = $display->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).
+ $section = undef if $locationnum;
- $not_tax{$section} = 1
- unless $cust_bill_pkg->pkgnum == 0;
+ # set this flag if the section is not tax-only
+ $not_tax{$locationnum}{$section} = 1
+ if $cust_bill_pkg->pkgnum or $cust_bill_pkg->feepart;
# there's actually a very important piece of logic buried in here:
- # incrementing $late_subtotal{$section} CREATES
- # $late_subtotal{$section}. keys(%late_subtotal) is later used
+ # incrementing $late_subtotal{$section} CREATES
+ # $late_subtotal{$section}. keys(%late_subtotal) is later used
# to define the list of late sections, and likewise keys(%subtotal).
- # When _items_cust_bill_pkg is called to generate line items for
- # real, it will be called with 'section' => $section for each
+ # When _items_cust_bill_pkg is called to generate line items for
+ # real, it will be called with 'section' => $section for each
# of these.
- if ( $display->post_total && !$summarypage ) {
+ if ( $display->post_total && !$opt{summary} ) {
if (! $type || $type eq 'S') {
- $late_subtotal{$section} += $cust_bill_pkg->setup
+ $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
if $cust_bill_pkg->setup != 0
|| $cust_bill_pkg->setup_show_zero;
}
if (! $type) {
- $late_subtotal{$section} += $cust_bill_pkg->recur
+ $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur
if $cust_bill_pkg->recur != 0
|| $cust_bill_pkg->recur_show_zero;
}
if ($type && $type eq 'R') {
- $late_subtotal{$section} += $cust_bill_pkg->recur - $usage
+ $late_subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
if $cust_bill_pkg->recur != 0
|| $cust_bill_pkg->recur_show_zero;
}
-
+
if ($type && $type eq 'U') {
- $late_subtotal{$section} += $usage
- unless scalar(@$extra_sections);
+ $late_subtotal{$locationnum}{$section} += $usage
+ unless scalar(@extra_sections);
}
- } else {
+ } else { # it's a pre-total (normal) section
- next if $cust_bill_pkg->pkgnum == 0 && ! $section;
+ # skip tax items unless they're explicitly included in a section
+ next if $cust_bill_pkg->pkgnum == 0 and
+ ! $cust_bill_pkg->feepart and
+ ! $section;
- if (! $type || $type eq 'S') {
- $subtotal{$section} += $cust_bill_pkg->setup
+ if ( $type eq 'S' ) {
+ $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
if $cust_bill_pkg->setup != 0
|| $cust_bill_pkg->setup_show_zero;
- }
-
- if (! $type) {
- $subtotal{$section} += $cust_bill_pkg->recur
+ } elsif ( $type eq 'R' ) {
+ $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage
if $cust_bill_pkg->recur != 0
|| $cust_bill_pkg->recur_show_zero;
- }
-
- if ($type && $type eq 'R') {
- $subtotal{$section} += $cust_bill_pkg->recur - $usage
- if $cust_bill_pkg->recur != 0
- || $cust_bill_pkg->recur_show_zero;
- }
-
- if ($type && $type eq 'U') {
- $subtotal{$section} += $usage
- unless scalar(@$extra_sections);
+ } elsif ( $type eq 'U' ) {
+ $subtotal{$locationnum}{$section} += $usage
+ unless scalar(@extra_sections);
+ } elsif ( !$type ) {
+ $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup
+ + $cust_bill_pkg->recur;
}
}
%pkg_category_cache = ();
- push @$late, map { { 'description' => &{$escape}($_),
- 'subtotal' => $late_subtotal{$_},
- 'post_total' => 1,
- 'sort_weight' => ( _pkg_category($_)
- ? _pkg_category($_)->weight
- : 0
- ),
- ((_pkg_category($_) && _pkg_category($_)->condense)
- ? $self->_condense_section($format)
- : ()
- ),
- } }
- sort _sectionsort keys %late_subtotal;
+ # summary invoices need subtotals for all non-disabled package categories,
+ # even if they're zero
+ # but currently assume that there are no location sections, or at least
+ # that the summary page doesn't care about them
+ if ( $opt{summary} ) {
+ foreach my $category (qsearch('pkg_category', {disabled => ''})) {
+ $subtotal{''}{$category->categoryname} ||= 0;
+ }
+ $subtotal{''}{''} ||= 0;
+ }
my @sections;
- if ( $summarypage ) {
- @sections = grep { exists($subtotal{$_}) || ! _pkg_category($_)->disabled }
- map { $_->categoryname } qsearch('pkg_category', {});
- push @sections, '' if exists($subtotal{''});
- } else {
- @sections = keys %subtotal;
- }
-
- my @early = map { { 'description' => &{$escape}($_),
- 'subtotal' => $subtotal{$_},
- 'summarized' => $not_tax{$_} ? '' : 'Y',
- 'tax_section' => $not_tax{$_} ? '' : 'Y',
- 'sort_weight' => ( _pkg_category($_)
- ? _pkg_category($_)->weight
- : 0
- ),
- ((_pkg_category($_) && _pkg_category($_)->condense)
- ? $self->_condense_section($format)
- : ()
- ),
- }
- } @sections;
- push @early, @$extra_sections if $extra_sections;
-
- sort { $a->{sort_weight} <=> $b->{sort_weight} } @early;
-
+ foreach my $post_total (0,1) {
+ my @these;
+ my $s = $post_total ? \%late_subtotal : \%subtotal;
+ foreach my $locationnum (keys %$s) {
+ foreach my $sectionname (keys %{ $s->{$locationnum} }) {
+ my $section = {
+ 'subtotal' => $s->{$locationnum}{$sectionname},
+ 'sort_weight' => 0,
+ };
+ if ( $locationnum ) {
+ $section->{'locationnum'} = $locationnum;
+ my $location = FS::cust_location->by_key($locationnum);
+ $section->{'description'} = &{ $escape }($location->location_label);
+ # Better ideas? This will roughly group them by proximity,
+ # which alpha sorting on any of the address fields won't.
+ # Sorting by locationnum is meaningless.
+ # We have to sort on _something_ or the order may change
+ # randomly from one invoice to the next, which will confuse
+ # people.
+ $section->{'sort_weight'} = sprintf('%012s',$location->zip) .
+ $locationnum;
+ $section->{'location'} = {
+ label_prefix => &{ $escape }($location->label_prefix),
+ map { $_ => &{ $escape }($location->get($_)) }
+ $location->fields
+ };
+ } else {
+ $section->{'category'} = $sectionname;
+ $section->{'description'} = &{ $escape }($sectionname);
+ if ( _pkg_category($sectionname) ) {
+ $section->{'sort_weight'} = _pkg_category($sectionname)->weight;
+ if ( _pkg_category($sectionname)->condense ) {
+ $section = { %$section, $self->_condense_section($opt{format}) };
+ }
+ }
+ }
+ if ( !$post_total and !$not_tax{$locationnum}{$sectionname} ) {
+ # then it's a tax-only section
+ $section->{'summarized'} = 'Y';
+ $section->{'tax_section'} = 'Y';
+ }
+ push @these, $section;
+ } # foreach $sectionname
+ } #foreach $locationnum
+ push @these, @extra_sections if $post_total == 0;
+ # need an alpha sort for location sections, because postal codes can
+ # be non-numeric
+ $sections[ $post_total ] = [ sort {
+ $opt{'by_location'} ?
+ ($a->{sort_weight} cmp $b->{sort_weight}) :
+ ($a->{sort_weight} <=> $b->{sort_weight})
+ } @these ];
+ } #foreach $post_total
+
+ return @sections; # early, late
}
#helper subs for above
-sub _sectionsort {
- _pkg_category($a)->weight <=> _pkg_category($b)->weight;
+sub cust_pkg_hash {
+ my $self = shift;
+ $self->{cust_pkg} ||= { map { $_->pkgnum => $_ } $self->cust_pkg };
}
sub _pkg_category {
}
-# sub _items { # seems to be unused
-# my $self = shift;
-#
-# #my @display = scalar(@_)
-# # ? @_
-# # : qw( _items_previous _items_pkg );
-# # #: qw( _items_pkg );
-# # #: qw( _items_previous _items_pkg _items_tax _items_credits _items_payments );
-# my @display = qw( _items_previous _items_pkg );
-#
-# my @b = ();
-# foreach my $display ( @display ) {
-# push @b, $self->$display(@_);
-# }
-# @b;
-# }
-
=item _items_pkg [ OPTIONS ]
-Return line item hashes for each package item on this invoice. Nearly
-equivalent to
+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).
+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
sub _items_nontax {
my $self = shift;
- grep { $_->pkgnum } $self->cust_bill_pkg;
+ # The order of these is important. Bundled line items will be merged into
+ # the most recent non-hidden item, so it needs to be the one with:
+ # - the same pkgnum
+ # - the same start date
+ # - no pkgpart_override
+ #
+ # So: sort by pkgnum,
+ # then by sdate
+ # then sort the base line item before any overrides
+ # then sort hidden before non-hidden add-ons
+ # then sort by override pkgpart (for consistency)
+ sort { $a->pkgnum <=> $b->pkgnum or
+ $a->sdate <=> $b->sdate or
+ ($a->pkgpart_override ? 0 : -1) or
+ ($b->pkgpart_override ? 0 : 1) or
+ $b->hidden cmp $a->hidden or
+ $a->pkgpart_override <=> $b->pkgpart_override
+ }
+ # and of course exclude taxes and fees
+ grep { $_->pkgnum > 0 } $self->cust_bill_pkg;
+}
+
+sub _items_fee {
+ 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 $part_fee = $cust_bill_pkg->get('part_fee')
+ || $cust_bill_pkg->part_fee;
+ $cust_bill_pkg->set('part_fee', $part_fee);
+ if (!$part_fee) {
+ #die "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n"; # might make more sense
+ 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?
+
+ my @ext_desc;
+ my %base_invnums; # invnum => invoice date
+ foreach ($cust_bill_pkg->cust_bill_pkg_fee) {
+ if ($_->base_invnum) {
+ my $base_bill = FS::cust_bill->by_key($_->base_invnum);
+ my $base_date = $self->time2str_local('short', $base_bill->_date)
+ if $base_bill;
+ $base_invnums{$_->base_invnum} = $base_date || '';
+ }
+ }
+ foreach (sort keys(%base_invnums)) {
+ next if $_ == $self->invnum;
+ # per convention, we must escape ext_description lines
+ push @ext_desc,
+ &{$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 => $desc,
+ pkg_tax => \@pkg_tax,
+ ext_description => \@ext_desc,
+ # sdate/edate?
+ };
+ }
+ @items;
}
sub _items_pkg {
sub _items_tax {
my $self = shift;
- my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg;
+ my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart }
+ $self->cust_bill_pkg;
my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_);
if ( $self->conf->exists('always_show_tax') ) {
DEPRECATED? (expensive, mostly unused?)
format_function: the function used to format CDRs.
-section: a hashref containing 'description'; if this is present,
-cust_bill_pkg_display records not belonging to this section are
-ignored.
+section: a hashref containing 'category' and/or 'locationnum'; if this
+is present, only returns line items that belong to that category and/or
+location (whichever is defined).
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
-ext_description, which is an arrayref of detail lines to show below
+pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and
+ext_description, which is an arrayref of detail lines to show below
the package line.
=cut
my $format_function = $opt{format_function} || '';
my $no_usage = $opt{no_usage} || '';
my $unsquelched = $opt{unsquelched} || ''; #unused
- my $section = $opt{section}->{description} if $opt{section};
+ my ($section, $locationnum, $category);
+ if ( $opt{section} ) {
+ $category = $opt{section}->{category};
+ $locationnum = $opt{section}->{locationnum};
+ }
my $summary_page = $opt{summary_page} || ''; #unused
- my $multisection = $opt{multisection} || '';
- my $discount_show_always = 0;
+ my $multisection = defined($category) || defined($locationnum);
+ # 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 $locale = $cust_main->locale;
- my @b = ();
- my ($s, $r, $u) = ( undef, undef, undef );
+ 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, undef );
+ # the 'current' line item hashes for setup, recur, usage, discount
foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
{
-
- foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+ # 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.
+ if ( $d ) {
+ $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
+ }
+ foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
if ( $_ && !$cust_bill_pkg->hidden ) {
- $_->{amount} = sprintf( "%.2f", $_->{amount} ),
+ $_->{amount} = sprintf( "%.2f", $_->{amount} );
$_->{amount} =~ s/^\-0\.00$/0.00/;
- $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
- push @b, { %$_ }
- if $_->{amount} != 0
- || $discount_show_always
- || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
- || ( $_->{_is_setup} && $_->{setup_show_zero} )
+ if (exists($_->{unit_amount})) {
+ $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} );
+ }
+ 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;
}
}
+ if ( $locationnum ) {
+ # this is a location section; skip packages that aren't at this
+ # service location.
+ next if $cust_bill_pkg->pkgnum == 0; # skips fees...
+ next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum
+ != $locationnum;
+ }
+
+ # Consider display records for this item to determine if it belongs
+ # in this section. Note that if there are no display records, there
+ # will be a default pseudo-record that includes all charge types
+ # and has no section name.
my @cust_bill_pkg_display = $cust_bill_pkg->can('cust_bill_pkg_display')
? $cust_bill_pkg->cust_bill_pkg_display
: ( $cust_bill_pkg );
$cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
if $DEBUG > 1;
- foreach my $display ( grep { defined($section)
- ? $_->section eq $section
- : 1
- }
- grep { !$_->summary || $multisection }
- @cust_bill_pkg_display
- )
- {
+ if ( defined($category) ) {
+ # then this is a package category section; process all display records
+ # that belong to this section.
+ @cust_bill_pkg_display = grep { $_->section eq $category }
+ @cust_bill_pkg_display;
+ } else {
+ # otherwise, process all display records that aren't usage summaries
+ # (I don't think there should be usage summaries if you aren't using
+ # category sections, but this is the historical behavior)
+ @cust_bill_pkg_display = grep { !$_->summary }
+ @cust_bill_pkg_display;
+ }
+
+ my $classname = ''; # package class name, will fill in later
+
+ foreach my $display (@cust_bill_pkg_display) {
warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ".
$display->billpkgdisplaynum. "\n"
my $type = $display->type;
- my $desc = $cust_bill_pkg->desc( $cust_main->locale );
+ my $desc = $cust_bill_pkg->desc( $cust_main ? $cust_main->locale : '' );
$desc = substr($desc, 0, $maxlength). '...'
if $format eq 'latex' && length($desc) > $maxlength;
'no_usage' => $opt{'no_usage'},
);
- if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) {
-
- warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n"
- if $DEBUG > 1;
-
- if ( $cust_bill_pkg->setup != 0 ) {
- my $description = $desc;
- $description .= ' Setup'
- if $cust_bill_pkg->recur != 0
- || $discount_show_always
- || $cust_bill_pkg->recur_show_zero;
- push @b, {
- 'description' => $description,
- 'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
- };
- }
- if ( $cust_bill_pkg->recur != 0 ) {
- push @b, {
- 'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
- 'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
- };
- }
-
- } elsif ( $cust_bill_pkg->pkgnum > 0 ) {
+ if ( $cust_bill_pkg->pkgnum > 0 ) {
+ # a "normal" package line item (not a quotation, not a fee, not a tax)
warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n"
if $DEBUG > 1;
-
+
my $cust_pkg = $cust_bill_pkg->cust_pkg;
+ my $part_pkg = $cust_pkg->part_pkg;
# which pkgpart to show for display purposes?
my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
- # start/end dates for invoice formats that do nonstandard
+ # start/end dates for invoice formats that do nonstandard
# things with them
my %item_dates = ();
%item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate')
- unless $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
+ unless $part_pkg->option('disable_line_item_date_ranges',1);
+
+ # not normally used, but pass this to the template anyway
+ $classname = $part_pkg->classname;
+
+ my @pkg_tax = $cust_bill_pkg->_pkg_tax_list
+ if $opt{section_with_taxes};
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;
+ 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)
+ && $cust_bill_pkg->recur == 0
+ && ! $cust_bill_pkg->recur_show_zero;
+
my @d = ();
- my $svc_label;
- unless ( $cust_pkg->part_pkg->hide_svc_detail
+ my @svc_labels = ();
+ my $svc_label = '';
+
+ unless ( $part_pkg->hide_svc_detail ) {
+
+ # still pass the svc_label through to the template, even if
+ # not displaying it as an ext_description
+ @svc_labels = map &{$escape_function}($_),
+ $cust_pkg->h_labels_short($self->_date,
+ undef,
+ 'I',
+ $self->conf->{locale},
+ );
+ $svc_label = $svc_labels[0];
+
+ }
+
+ unless ( $part_pkg->hide_svc_detail
|| $cust_bill_pkg->hidden )
{
- my @svc_labels = map &{$escape_function}($_),
- $cust_pkg->h_labels_short($self->_date, undef, 'I');
push @d, @svc_labels
unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
- $svc_label = $svc_labels[0];
-
- if ( ! $cust_pkg->locationnum or
- $cust_pkg->locationnum != $cust_main->ship_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 != $default_locationnum
+ and !defined($locationnum) ) {
my $loc = $cust_pkg->location_label;
$loc = substr($loc, 0, $maxlength). '...'
if $format eq 'latex' && length($loc) > $maxlength;
push @{ $s->{ext_description} }, @d;
} else {
$s = {
+ billpkgnum => $cust_bill_pkg->billpkgnum,
_is_setup => 1,
description => $description,
pkgpart => $pkgpart,
quantity => $cust_bill_pkg->quantity,
ext_description => \@d,
svc_label => ($svc_label || ''),
+ locationnum => $cust_pkg->locationnum, # sure, why not?
+ pkg_tax => \@pkg_tax,
};
};
}
+ # 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
)
)
if $DEBUG > 1;
my $is_summary = $display->summary;
- my $description = ($is_summary && $type && $type eq 'U')
- ? "Usage charges" : $desc;
-
- my $part_pkg = $cust_pkg->part_pkg;
-
- #pry be a bit more efficient to look some of this conf stuff up
- # outside the loop
- unless (
- $conf->exists('disable_line_item_date_ranges')
- || $part_pkg->option('disable_line_item_date_ranges',1)
- || ! $cust_bill_pkg->sdate
- || ! $cust_bill_pkg->edate
- ) {
- my $time_period;
- my $date_style = '';
- $date_style = $conf->config( 'cust_bill-line_item-date_style-non_monhtly',
- $cust_main->agentnum
- )
- if $part_pkg && $part_pkg->freq !~ /^1m?$/;
- $date_style ||= $conf->config( 'cust_bill-line_item-date_style',
- $cust_main->agentnum
- );
- if ( defined($date_style) && $date_style eq 'month_of' ) {
- $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
- } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
- my $desc = $conf->config( 'cust_bill-line_item-date_description',
- $cust_main->agentnum
- );
- $desc .= ' ' unless $desc =~ /\s$/;
- $time_period = $desc. time2str('%B', $cust_bill_pkg->sdate);
- } else {
- $time_period = time2str($date_format, $cust_bill_pkg->sdate).
- " - ". time2str($date_format, $cust_bill_pkg->edate);
- }
- $description .= " ($time_period)";
+ my $description = $desc;
+ if ( $type eq 'U' and defined($r) ) {
+ # don't just show the same description as the recur line
+ $description = $self->mt('Usage charges');
}
+ 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,
+ );
+
my @d = ();
my @seconds = (); # for display of usage info
+ my @svc_labels = ();
my $svc_label = '';
#at least until cust_bill_pkg has "past" ranges in addition to
push @dates, $prev->sdate if $prev;
push @dates, undef if !$prev;
+ unless ( $part_pkg->hide_svc_detail ) {
+ @svc_labels = map &{$escape_function}($_),
+ $cust_pkg->h_labels_short(@dates,
+ 'I',
+ $self->conf->{locale});
+ $svc_label = $svc_labels[0];
+ }
+
+ # show service labels, unless...
+ # the package is set not to display them
unless ( $part_pkg->hide_svc_detail
+ # or this is a tax-like line item
|| $cust_bill_pkg->itemdesc
+ # or this is a hidden (bundled) line item
|| $cust_bill_pkg->hidden
+ # or this is a usage summary line
|| $is_summary && $type && $type eq 'U'
+ # or this is a usage line and there's a recurring line
+ # for the package in the same section (which will
+ # have service labels already)
+ || ($type eq 'U' and defined($r))
)
{
warn "$me _items_cust_bill_pkg adding service details\n"
if $DEBUG > 1;
- my @svc_labels = map &{$escape_function}($_),
- $cust_pkg->h_labels_short($self->_date, undef, 'I');
push @d, @svc_labels
unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
- $svc_label = $svc_labels[0];
-
warn "$me _items_cust_bill_pkg done adding service details\n"
if $DEBUG > 1;
- if ( $cust_pkg->locationnum != $cust_main->ship_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 != $default_locationnum
+ and !defined($locationnum) ) {
my $loc = $cust_pkg->location_label;
$loc = substr($loc, 0, $maxlength). '...'
if $format eq 'latex' && length($loc) > $maxlength;
# 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
+ # 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')
+ if ( $conf->exists('svc_acct-usage_seconds')
and ! $cust_bill_pkg->pkgpart_override ) {
- foreach my $cust_svc (
- $cust_pkg->h_cust_svc(@dates, 'I')
+ 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
+ # 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]);
}
} #if svc_acct-usage_seconds
- }
+ } # if we are showing service labels
unless ( $is_summary ) {
warn "$me _items_cust_bill_pkg adding details\n"
warn "$me _items_cust_bill_pkg calculating amount\n"
if $DEBUG > 1;
-
+
my $amount = 0;
if (!$type) {
$amount = $cust_bill_pkg->recur;
} elsif ($type eq 'U') {
$amount = $cust_bill_pkg->usage;
}
-
- my $unit_amount =
- ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
- : $amount;
if ( !$type || $type eq 'R' ) {
warn "$me _items_cust_bill_pkg adding recur\n"
if $DEBUG > 1;
+ my $unit_amount =
+ ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
+ : $amount;
+
if ( $cust_bill_pkg->hidden ) {
$r->{amount} += $amount;
$r->{unit_amount} += $unit_amount;
push @{ $r->{ext_description} }, @d;
} else {
$r = {
+ billpkgnum => $cust_bill_pkg->billpkgnum,
description => $description,
pkgpart => $pkgpart,
pkgnum => $cust_bill_pkg->pkgnum,
%item_dates,
ext_description => \@d,
svc_label => ($svc_label || ''),
+ locationnum => $cust_pkg->locationnum,
+ pkg_tax => \@pkg_tax,
};
$r->{'seconds'} = \@seconds if grep {defined $_} @seconds;
}
warn "$me _items_cust_bill_pkg adding usage\n"
if $DEBUG > 1;
- if ( $cust_bill_pkg->hidden ) {
+ if ( $cust_bill_pkg->hidden and defined($u) ) {
+ # if this is a hidden package and there's already a usage
+ # line for the bundle, add this package's total amount and
+ # usage details to it
$u->{amount} += $amount;
- $u->{unit_amount} += $unit_amount,
push @{ $u->{ext_description} }, @d;
- } else {
+ } elsif ( $amount ) {
+ # create a new usage line
$u = {
+ billpkgnum => $cust_bill_pkg->billpkgnum,
description => $description,
pkgpart => $pkgpart,
pkgnum => $cust_bill_pkg->pkgnum,
amount => $amount,
+ usage_item => 1,
recur_show_zero => $cust_bill_pkg->recur_show_zero,
- unit_amount => $unit_amount,
- quantity => $cust_bill_pkg->quantity,
%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
}
} # recurring or usage with recurring charge
- } else { #pkgnum tax or one-shot line item (??)
+ } else { # taxes and fees
warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
if $DEBUG > 1;
- if ( $cust_bill_pkg->setup != 0 ) {
- push @b, {
- 'description' => $desc,
- 'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
- };
- }
- if ( $cust_bill_pkg->recur != 0 ) {
- push @b, {
- 'description' => "$desc (".
- time2str($date_format, $cust_bill_pkg->sdate). ' - '.
- time2str($date_format, $cust_bill_pkg->edate). ')',
- 'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
- };
- }
+ # items of this kind should normally not have sdate/edate.
+ push @b, {
+ 'description' => $desc,
+ 'amount' => sprintf('%.2f', $cust_bill_pkg->setup
+ + $cust_bill_pkg->recur)
+ };
- }
+ } # if package line item / other line item
+
+ # 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->{setup_amount} += $item_discount->{setup_amount};
+ $d->{recur_amount} += $item_discount->{recur_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)
+
+ $s->{amount} -= $item_discount->{setup_amount} if $s;
+ $r->{amount} -= $item_discount->{recur_amount} if $r;
+
+ } # if there are any discounts
+ } # if this is an appropriate place to show discounts
- $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount
- && $conf->exists('discount-show-always'));
+ } # foreach $display
}
- foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) {
+ # discount amount is internally split up
+ if ( $d ) {
+ $d->{amount} = $d->{setup_amount} + $d->{recur_amount};
+ }
+
+ foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
if ( $_ ) {
$_->{amount} = sprintf( "%.2f", $_->{amount} ),
+ if exists($_->{amount});
$_->{amount} =~ s/^\-0\.00$/0.00/;
- $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ),
- push @b, { %$_ }
- if $_->{amount} != 0
- || $discount_show_always
- || ( ! $_->{_is_setup} && $_->{recur_show_zero} )
- || ( $_->{_is_setup} && $_->{setup_show_zero} )
+ 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} )
}
}
=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
+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;
- #maybe move this method from cust_bill when quotations support discount_plans
+ #maybe move this method from cust_bill when quotations support discount_plans
return () unless $self->can('discount_plans');
my %plans = $self->discount_plans;
my $plan = $plans{$months};
my $term_total = sprintf('%.2f', $plan->discounted_total);
- my $percent = sprintf('%.0f',
+ my $percent = sprintf('%.0f',
100 * (1 - $term_total / $plan->base_total) );
my $permonth = sprintf('%.2f', $term_total / $months);
my $detail = $self->mt('discount on item'). ' '.
+{
description => $self->mt('Save [_1]% by paying for [_2] months',
$percent, $months),
- amount => $self->mt('[_1] ([_2] per month)',
+ amount => $self->mt('[_1] ([_2] per month)',
$term_total, $money_char.$permonth),
ext_description => ($detail || ''),
}
}
+=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;