use FS::cust_bill_batch;
use FS::cust_bill_pay_pkg;
use FS::cust_credit_bill_pkg;
+use FS::discount_plan;
use FS::L10N;
@ISA = qw( FS::cust_main_Mixin FS::Record );
=item agent_invid - legacy invoice number
+=item promised_date - customer promised payment date, for collection
+
=back
=head1 METHODS
cust_event
cust_credit_bill
cust_bill_pay
- cust_bill_pay
cust_credit_bill
cust_pay_batch
cust_bill_pay_batch
qsearch('cust_bill_batch', { 'invnum' => $self->invnum });
}
+=item discount_plans
+
+Returns all discount plans (L<FS::discount_plan>) for this invoice, as a
+hash keyed by term length.
+
+=cut
+
+sub discount_plans {
+ my $self = shift;
+ FS::discount_plan->all($self);
+}
+
=item tax
Returns the tax amount (see L<FS::cust_bill_pkg>) for this invoice.
$balance;
}
+=item hide
+
+Returns true if this invoice should be hidden. See the
+selfservice-hide_invoices-taxclass configuraiton setting.
+
+=cut
+
+sub hide {
+ my $self = shift;
+ my $conf = $self->conf;
+ my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass')
+ or return '';
+ my @cust_bill_pkg = $self->cust_bill_pkg;
+ my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg;
+ ! grep { $_->taxclass ne $hide_taxclass } @part_pkg;
+}
+
=item apply_payments_and_credits [ OPTION => VALUE ... ]
Applies unapplied payments and credits to this invoice.
'Disposition' => 'inline',
);
- $args{'from'} =~ /\@([\w\.\-]+)/;
- my $from = $1 || 'example.com';
- my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
- my $logo;
- my $agentnum = $cust_main->agentnum;
- if ( defined($args{'template'}) && length($args{'template'})
- && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
- )
- {
- $logo = 'logo_'. $args{'template'}. '.png';
+ my $htmldata;
+ my $image = '';
+ my $barcode = '';
+ if ( $conf->exists('invoice_email_pdf')
+ and scalar($conf->config('invoice_email_pdf_note')) ) {
+
+ $htmldata = join('<BR>', $conf->config('invoice_email_pdf_note') );
+
} else {
- $logo = "logo.png";
- }
- my $image_data = $conf->config_binary( $logo, $agentnum);
-
- my $image = build MIME::Entity
- 'Type' => 'image/png',
- 'Encoding' => 'base64',
- 'Data' => $image_data,
- 'Filename' => 'logo.png',
- 'Content-ID' => "<$content_id>",
- ;
+
+ $args{'from'} =~ /\@([\w\.\-]+)/;
+ my $from = $1 || 'example.com';
+ my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+
+ my $logo;
+ my $agentnum = $cust_main->agentnum;
+ if ( defined($args{'template'}) && length($args{'template'})
+ && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
+ )
+ {
+ $logo = 'logo_'. $args{'template'}. '.png';
+ } else {
+ $logo = "logo.png";
+ }
+ my $image_data = $conf->config_binary( $logo, $agentnum);
+
+ $image = build MIME::Entity
+ 'Type' => 'image/png',
+ 'Encoding' => 'base64',
+ 'Data' => $image_data,
+ 'Filename' => 'logo.png',
+ 'Content-ID' => "<$content_id>",
+ ;
- my $barcode;
- if($conf->exists('invoice-barcode')){
- my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
- $barcode = build MIME::Entity
- 'Type' => 'image/png',
- 'Encoding' => 'base64',
- 'Data' => $self->invoice_barcode(0),
- 'Filename' => 'barcode.png',
- 'Content-ID' => "<$barcode_content_id>",
- ;
- $opt{'barcode_cid'} = $barcode_content_id;
+ if ($conf->exists('invoice-barcode')) {
+ my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+ $barcode = build MIME::Entity
+ 'Type' => 'image/png',
+ 'Encoding' => 'base64',
+ 'Data' => $self->invoice_barcode(0),
+ 'Filename' => 'barcode.png',
+ 'Content-ID' => "<$barcode_content_id>",
+ ;
+ $opt{'barcode_cid'} = $barcode_content_id;
+ }
+
+ $htmldata = $self->print_html({ 'cid'=>$content_id, %opt });
}
$alternative->attach(
' </title>',
' </head>',
' <body bgcolor="#e8e8e8">',
- $self->print_html({ 'cid'=>$content_id, %opt }),
+ $htmldata,
' </body>',
'</html>',
],
#'Filename' => 'invoice.pdf',
);
+
my @otherparts = ();
if ( $cust_main->email_csv_cdr ) {
$related->add_part($alternative);
- $related->add_part($image);
+ $related->add_part($image) if $image;
my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt);
# image/png
$return{'content-type'} = 'multipart/related';
- if($conf->exists('invoice-barcode')){
- $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
- }
- else {
- $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
+ if ($conf->exists('invoice-barcode') && $barcode) {
+ $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
+ } else {
+ $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
}
$return{'type'} = 'multipart/alternative'; #Content-Type of first part...
#$return{'disposition'} = 'inline';
$balance_over = shift if scalar(@_) && $_[0] !~ /^\s*$/;
}
+ my $cust_main = $self->cust_main;
+
return 'N/A' unless ! $agentnums
- or grep { $_ == $self->cust_main->agentnum } @$agentnums;
+ or grep { $_ == $cust_main->agentnum } @$agentnums;
return ''
- unless $self->cust_main->total_owed_date($self->_date) > $balance_over;
+ unless $cust_main->total_owed_date($self->_date) > $balance_over;
$invoice_from ||= $self->_agent_invoice_from || #XXX should go away
- $conf->config('invoice_from', $self->cust_main->agentnum );
+ $conf->config('invoice_from', $cust_main->agentnum );
my %opt = (
'template' => $template,
'notice_name' => ( $notice_name || 'Invoice' ),
);
- my @invoicing_list = $self->cust_main->invoicing_list;
+ my @invoicing_list = $cust_main->invoicing_list;
#$self->email_invoice(\%opt)
$self->email(\%opt)
- if grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list;
+ if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list )
+ && ! $self->invoice_noemail;
#$self->print_invoice(\%opt)
$self->print(\%opt)
#sub email_invoice {
sub email {
my $self = shift;
+ return if $self->hide;
my $conf = $self->conf;
my( $template, $invoice_from, $notice_name, $no_coupon );
#sub print_invoice {
sub print {
my $self = shift;
+ return if $self->hide;
my $conf = $self->conf;
+
my( $template, $notice_name );
if ( ref($_[0]) ) {
my $opt = shift;
sub fax_invoice {
my $self = shift;
+ return if $self->hide;
my $conf = $self->conf;
+
my( $template, $notice_name );
if ( ref($_[0]) ) {
my $opt = shift;
'0', # 29 | Other Taxes & Fees*** NUM* 9
);
+ } elsif ( lc($opt{'format'}) eq 'oneline' ) { #name?
+
+ my ($previous_balance) = $self->previous;
+ my $totaldue = sprintf('%.2f', $self->owed + $previous_balance);
+ my @items = map {
+ ($_->{pkgnum} || ''),
+ $_->{description},
+ $_->{amount}
+ } $self->_items_pkg;
+
+ $csv->combine(
+ $cust_main->agentnum,
+ $cust_main->agent->agent,
+ $self->custnum,
+ $cust_main->first,
+ $cust_main->last,
+ $cust_main->address1,
+ $cust_main->address2,
+ $cust_main->city,
+ $cust_main->state,
+ $cust_main->zip,
+
+ # invoice fields
+ time2str("%x", $self->_date),
+ $self->invnum,
+ $self->charged,
+ $totaldue,
+
+ @items,
+ );
+
} else {
$csv->combine(
}
+ } elsif ( lc($opt{'format'}) eq 'oneline' ) {
+
+ #do nothing
+
} else {
foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+locale - override customer's locale
+
=cut
#what's with all the sprintf('%10.2f')'s in here? will it cause any
# (alignment in text invoice?) problems to change them all to '%.2f' ?
-# yes: fixed width (dot matrix) text printing will be borked
+# yes: fixed width/plain text printing will be borked
sub print_generic {
my( $self, %params ) = @_;
my $conf = $self->conf;
);
#localization
- my $lh = FS::L10N->get_handle($cust_main->locale);
+ 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
$invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
my $countrydefault = $conf->config('countrydefault') || 'US';
- my $prefix = $cust_main->has_ship_address ? 'ship_' : '';
- foreach ( qw( contact company address1 address2 city state zip country fax) ){
- my $method = $prefix.$_;
+ foreach ( qw( address1 address2 city state zip country fax) ){
+ my $method = 'ship_'.$_;
$invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
}
+ foreach ( qw( contact company ) ) { #compatibility
+ $invoice_data{"ship_$_"} = _latex_escape($cust_main->$_);
+ }
$invoice_data{'ship_country'} = ''
if ( $invoice_data{'ship_country'} eq $countrydefault );
my $previous_section = { 'description' => $self->mt('Previous Charges'),
'subtotal' => $other_money_char.
sprintf('%.2f', $pr_total),
- 'summarized' => $summarypage ? 'Y' : '',
+ 'summarized' => '', #why? $summarypage ? 'Y' : '',
};
$previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '.
join(' / ', map { $cust_main->balance_date_range(@$_) }
my $taxtotal = 0;
my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
'subtotal' => $taxtotal, # adjusted below
- 'summarized' => $summarypage ? 'Y' : '',
};
my $tax_weight = _pkg_category($tax_section->{description})
? _pkg_category($tax_section->{description})->weight
: 0;
- $tax_section->{'summarized'} = $summarypage && !$tax_weight ? 'Y' : '';
+ $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
$tax_section->{'sort_weight'} = $tax_weight;
my $adjust_section = { 'description' =>
$self->mt('Credits, Payments, and Adjustments'),
'subtotal' => 0, # adjusted below
- 'summarized' => $summarypage ? 'Y' : '',
};
my $adjust_weight = _pkg_category($adjust_section->{description})
? _pkg_category($adjust_section->{description})->weight
: 0;
- $adjust_section->{'summarized'} = $summarypage && !$adjust_weight ? 'Y' : '';
+ $adjust_section->{'summarized'} = ''; #why? $summarypage && !$adjust_weight ? 'Y' : '';
$adjust_section->{'sort_weight'} = $adjust_weight;
my $unsquelched = $params{unsquelch_cdr} || $cust_main->squelch_cdr ne 'Y';
}
}
- unless ( $conf->exists('disable_previous_balance')
+ unless ( $conf->exists('disable_previous_balance', $agentnum)
|| $conf->exists('previous_balance-summary_only')
)
{
}
- if ( @pr_cust_bill && !$conf->exists('disable_previous_balance') ) {
+ if ( @pr_cust_bill && !$conf->exists('disable_previous_balance', $agentnum) )
+ {
push @buf, ['','-----------'];
push @buf, [ $self->mt('Total Previous Balance'),
$money_char. sprintf("%10.2f", $pr_total) ];
$options{'section'} = $section if $multisection;
$options{'format'} = $format;
$options{'escape_function'} = $escape_function;
- $options{'format_function'} = sub { () } unless $unsquelched;
+ $options{'no_usage'} = 1 unless $unsquelched;
$options{'unsquelched'} = $unsquelched;
$options{'summary_page'} = $summarypage;
$options{'skip_usage'} =
$invoice_data{current_less_finance} =
sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
- if ( $multisection && !$conf->exists('disable_previous_balance')
+ if ( $multisection && !$conf->exists('disable_previous_balance', $agentnum)
|| $conf->exists('previous_balance-summary_only') )
{
unshift @sections, $previous_section if $pr_total;
push @buf,['','-----------'];
push @buf,[$self->mt(
- $conf->exists('disable_previous_balance')
+ $conf->exists('disable_previous_balance', $agentnum)
? 'Total Charges'
: 'Total New Charges'
),
|| 'Total New Charges'
if $conf->exists('previous_balance-exclude_from_total');
my $amount = $self->charged +
- ( $conf->exists('disable_previous_balance') ||
+ ( $conf->exists('disable_previous_balance', $agentnum) ||
$conf->exists('previous_balance-exclude_from_total')
? 0
: $pr_total
push @buf,['',''];
}
- unless ( $conf->exists('disable_previous_balance') ) {
+ unless ( $conf->exists('disable_previous_balance', $agentnum) ) {
#foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments
# credits
escape_function: the function used to escape strings.
+DEPRECATED? (expensive, mostly unused?)
format_function: the function used to format CDRs.
section: a hashref containing 'description'; if this is present,
my $format = $opt{format} || '';
my $escape_function = $opt{escape_function} || sub { shift };
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 $summary_page = $opt{summary_page} || ''; #unused
my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
+ my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
+
my @b = ();
my ($s, $r, $u) = ( undef, undef, undef );
foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
my %details_opt = ( 'format' => $format,
'escape_function' => $escape_function,
'format_function' => $format_function,
+ 'no_usage' => $opt{'no_usage'},
);
if ( $cust_bill_pkg->pkgnum > 0 ) {
my $description = ($is_summary && $type && $type eq 'U')
? "Usage charges" : $desc;
- $description .= " (" . time2str($date_format, $cust_bill_pkg->sdate).
- " - ". time2str($date_format, $cust_bill_pkg->edate).
- ")"
- unless $conf->exists('disable_line_item_date_ranges')
- || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1);
+ #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')
+ || $cust_pkg->part_pkg->option('disable_line_item_date_ranges',1)
+ ) {
+ my $time_period;
+ my $date_style = $conf->config( 'cust_bill-line_item-date_style',
+ $cust_main->agentnum
+ );
+ if ( defined($date_style) && $date_style eq 'month_of' ) {
+ $time_period = time2str('The month of %B', $cust_bill_pkg->sdate);
+ } elsif ( defined($date_style) && $date_style eq 'X_month' ) {
+ my $desc = $conf->config( 'cust_bill-line_item-date_description',
+ $cust_main->agentnum
+ );
+ $desc .= ' ' unless $desc =~ /\s$/;
+ $time_period = $desc. time2str('%B', $cust_bill_pkg->sdate);
+ } else {
+ $time_period = time2str($date_format, $cust_bill_pkg->sdate).
+ " - ". time2str($date_format, $cust_bill_pkg->edate);
+ }
+ $description .= " ($time_period)";
+ }
my @d = ();
my @seconds = (); # for display of usage info
#instead of omitting details entirely in this case (unwanted side
# effects), just omit CDRs
- $details_opt{'format_function'} = sub { () }
+ $details_opt{'no_usage'} = 1
if $type && $type eq 'R';
push @d, $cust_bill_pkg->details(%details_opt);
sub _items_discounts_avail {
my $self = shift;
- my %total;
- my $pkgnums = 0;
- my $pkgnums_times_discounts = 0;
- # tricky, because packages may not all be eligible for the same discounts
- foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
- my $cust_pkg = $cust_bill_pkg->cust_pkg or next;
- my $part_pkg = $cust_pkg->part_pkg or next;
- # for simplicity, skip all this if the customer already has a term discount
- return () if $cust_pkg->cust_pkg_discount_active;
-
- $pkgnums++;
- next if $part_pkg->freq ne '1';
-
- foreach my $discount (
- map { $_->discount } $part_pkg->part_pkg_discount
- ) {
-
- $total{$discount->discountnum} ||=
- {
- discount => $discount,
- pkgnums => [],
- base_current => 0,
- base_permonth => 0,
- setup_include => 0,
- setup_exclude => 0,
- };
- my $hash = $total{$discount->discountnum};
- $hash->{discount} = $discount;
- $hash->{thismonth} += $cust_bill_pkg->recur || 0;
- $hash->{setup} += $cust_bill_pkg->setup || 0;
- $hash->{base_permonth} += $part_pkg->base_recur_permonth;
-
- # and make a list of pkgnums
- push @{ $hash->{pkgnums} }, $cust_pkg->pkgnum;
- $pkgnums_times_discounts++;
- }
- }
+ my $list_pkgnums = 0; # if any packages are not eligible for all discounts
- # Test for the simple case where all packages on the invoice
- # are eligible for the same set of discounts. If not, we need
- # to list eligibility in the ext_description.
- my $list_pkgnums = ( $pkgnums_times_discounts != $pkgnums * keys(%total) );
-
- foreach my $hash (values %total) {
- my $discount = $hash->{discount};
- my ($amount, $term_total, $percent, $permonth);
- my $months = $discount->months;
- $hash->{months} = $months;
-
- if ( $discount->percent ) {
-
- # per discount_Mixin, percent discounts are calculated on the base
- # recurring fee, not the prorated fee.
- $percent = $discount->percent;
- $amount = sprintf('%.2f', 0.01 * $percent * $hash->{base_permonth});
- # percent discounts apply to setup fee
- if ( $discount->setup ) {
- $hash->{setup} *= (1 - 0.01*$percent);
- }
+ my %plans = $self->discount_plans;
- }
- elsif ( $discount->amount > 0 ) {
+ $list_pkgnums = grep { $_->list_pkgnums } values %plans;
- # amount discounts are amount * number of packages
- $amount = $discount->amount * scalar(@{ $hash->{pkgnums} });
- $percent = sprintf('%.0f', 100 * $amount / $hash->{base_permonth});
+ map {
+ my $months = $_;
+ my $plan = $plans{$months};
- # flat discounts are applied to setup and recur together
- if ( $discount->setup ) {
- $hash->{thismonth} += $hash->{setup};
- $hash->{setup} = 0;
- }
-
- }
+ my $term_total = sprintf('%.2f', $plan->discounted_total);
+ my $percent = sprintf('%.0f',
+ 100 * (1 - $term_total / $plan->base_total) );
+ my $permonth = sprintf('%.2f', $term_total / $months);
+ my $detail = $self->mt('discount on item'). ' '.
+ join(', ', map { "#$_" } $plan->pkgnums)
+ if $list_pkgnums;
- $permonth = max( $hash->{base_permonth} - $amount, 0);
- $term_total = max( $hash->{thismonth} - $amount , 0 ) # this month
- + $permonth * ($months - 1) # rest of the term
- + $hash->{setup}; # setup fee
+ # discounts for non-integer months don't work anyway
+ $months = sprintf("%d", $months);
- $hash->{description} = $self->mt('Save [_1]% by paying for [_2] months',
- $percent, $months,
- );
- $hash->{amount} = $self->mt('[_1] ([_2] per month)',
- sprintf('%.2f',$term_total), #no money_char to accommodate template quirk
- $money_char.sprintf('%.2f',$permonth) );
-
- my @detail;
- if ( $list_pkgnums ) {
- push @detail, $self->mt('for item'). ' '.
- join(', ', map { "#$_" } @{ $hash->{pkgnums} });
- }
- if ( !$discount->setup and $hash->{setup} ) {
- push @detail, $self->mt('excluding setup fees');
+ +{
+ description => $self->mt('Save [_1]% by paying for [_2] months',
+ $percent, $months),
+ amount => $self->mt('[_1] ([_2] per month)',
+ $term_total, $money_char.$permonth),
+ ext_description => ($detail || ''),
}
- $hash->{ext_description} = join ', ', @detail;
- }
+ } #map
+ sort { $b <=> $a } keys %plans;
- sort { -( $a->{months} <=> $b->{months} ) } values(%total);
}
=item call_details [ OPTION => VALUE ... ]
}
+ #promised_date - also has an option to accept nulls
+ if ( $param->{promised_date} ) {
+ my($beginning, $ending, $null) = @{$param->{promised_date}};
+
+ push @search, "(( cust_bill.promised_date >= $beginning AND ".
+ "cust_bill.promised_date < $ending )" .
+ ($null ? ' OR cust_bill.promised_date IS NULL ) ' : ')');
+ }
+
#agent virtualization
my $curuser = $FS::CurrentUser::CurrentUser;
if ( $curuser->username eq 'fs_queue'