use Locale::Country;
use Cwd;
use FS::UID;
+use FS::Misc qw( send_email );
use FS::Record qw( qsearch qsearchs );
use FS::Conf;
use FS::Misc qw( generate_ps generate_pdf );
if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
#change this to a die when the old code is removed
- # it's been almost ten years, changing it to a die.
+ # 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';
# "balance_date_range" unfortunately is unsuitable for this, since it
# cares about application dates. We want to know the sum of all
# _top-level transactions_ dated before the last invoice.
- my @sql = (
- 'SELECT SUM(charged) FROM cust_bill WHERE _date <= ? AND custnum = ?',
- 'SELECT -1*SUM(amount) FROM cust_credit WHERE _date <= ? AND custnum = ?',
- 'SELECT -1*SUM(paid) FROM cust_pay WHERE _date <= ? AND custnum = ?',
- 'SELECT SUM(refund) FROM cust_refund WHERE _date <= ? AND custnum = ?',
- );
+ my @sql =
+ map "$_ WHERE _date <= ? AND custnum = ?", (
+ "SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill",
+ "SELECT -1 * COALESCE( SUM(amount), 0 ) FROM cust_credit",
+ "SELECT -1 * COALESCE( SUM(paid), 0 ) FROM cust_pay",
+ "SELECT COALESCE( SUM(refund), 0 ) FROM cust_refund",
+ );
# the customer's current balance immediately after generating the last
# bill
my $last_bill_balance = $last_bill->charged;
foreach (@sql) {
- #warn "$_\n";
my $delta = FS::Record->scalar_sql(
$_,
$last_bill->_date - 1,
$self->custnum,
);
- #warn "$delta\n";
$last_bill_balance += $delta;
}
# to immediately before this one
my $before_this_bill_balance = 0;
foreach (@sql) {
- #warn "$_\n";
my $delta = FS::Record->scalar_sql(
$_,
$self->_date - 1,
$self->custnum,
);
- #warn "$delta\n";
$before_this_bill_balance += $delta;
}
$invoice_data{'balance_adjustments'} =
join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
if $DEBUG > 1;
+ push @buf, ( [ $line_item->{'description'},
+ $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+ ],
+ map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
+ );
+
$line_item->{'ref'} = $line_item->{'pkgnum'};
$line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()?
$line_item->{'section'} = $section;
$line_item->{'ext_description'} ||= [];
push @detail_items, $line_item;
- push @buf, ( [ $line_item->{'description'},
- $money_char. sprintf("%10.2f", $line_item->{'amount'}),
- ],
- map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
- );
}
if ( $section->{'description'} ) {
(@template);
}
+=item terms
+
+=cut
+
sub terms {
my $self = shift;
my $conf = $self->conf;
my $cust_main = $self->cust_main;
return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms;
+ my $agentnum = '';
+ if ( $cust_main ) {
+ $agentnum = $cust_main->agentnum;
+ } elsif ( my $prospect_main = $self->prospect_main ) {
+ $agentnum = $prospect_main->agentnum;
+ }
+
#use configured default
- $conf->config('invoice_default_terms') || '';
+ $conf->config('invoice_default_terms', $agentnum) || '';
}
+=item due_date
+
+=cut
+
sub due_date {
my $self = shift;
my $duedate = '';
$duedate;
}
+=item due_date2str
+
+=cut
+
sub due_date2str {
my $self = shift;
$self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
}
+=item balance_due_msg
+
+=cut
+
sub balance_due_msg {
my $self = shift;
my $msg = $self->mt('Balance Due');
$msg;
}
+=item balance_due_date
+
+=cut
+
sub balance_due_date {
my $self = shift;
my $conf = $self->conf;
my $duedate = '';
- if ( $conf->exists('invoice_default_terms')
- && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) {
+ my $terms = $self->terms;
+ if ( $terms =~ /^\s*Net\s*(\d+)\s*$/ ) {
$duedate = $self->time2str_local('rdate', $self->_date + ($1*86400) );
}
$duedate;
time2str($date_format, $self->_date);
}
+=item email HASHREF
+
+Emails this template.
+
+Options are passed as a hashref. Available options:
+
+=over 4
+
+=item from
+
+If specified, overrides the default From: address.
+
+=item notice_name
+
+If specified, overrides the name of the sent document ("Invoice" or "Quotation")
+
+=item template
+
+(Deprecated) If specified, is the name of a suffix for alternate template files.
+
+=back
+
+Options accepted by generate_email can also be used.
+
+=cut
+
+sub email {
+ my $self = shift;
+ my $opt = shift || {};
+ if ($opt and !ref($opt)) {
+ die ref($self). '->email called with positional parameters';
+ }
+
+ return if $self->hide;
+
+ my $error = send_email(
+ $self->generate_email(
+ 'subject' => $self->email_subject($opt->{template}),
+ %$opt, # template, etc.
+ )
+ );
+
+ die "can't email: $error\n" if $error;
+}
+
+=item generate_email OPTION => VALUE ...
+
+Options:
+
+=over 4
+
+=item from
+
+sender address, required
+
+=item template
+
+alternate template name, optional
+
+=item print_text
+
+text attachment arrayref, 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;
+
+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;
+
+ if ( $conf->exists($tc.'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'
+ ;
+
+ my $data = '';
+ if ( $conf->exists($tc. 'email_pdf')
+ and scalar($conf->config($tc. 'email_pdf_note')) ) {
+
+ warn "$me using '${tc}email_pdf_note' in multipart message"
+ if $DEBUG;
+ $data = [ map { $_ . "\n" }
+ $conf->config($tc.'email_pdf_note')
+ ];
+
+ } else {
+
+ warn "$me not using '${tc}email_pdf_note' in multipart message"
+ if $DEBUG;
+ if ( ref($args{'print_text'}) eq 'ARRAY' ) {
+ $data = $args{'print_text'};
+ } elsif ( $conf->exists($tc.'template') ) { #plaintext invoice_template
+ $data = [ $self->print_text(\%args) ];
+ }
+
+ }
+
+ if ( $data ) {
+ $alternative->attach(
+ 'Type' => 'text/plain',
+ 'Encoding' => 'quoted-printable',
+ 'Charset' => 'UTF-8',
+ #'Encoding' => '7bit',
+ 'Data' => $data,
+ 'Disposition' => 'inline',
+ );
+ }
+
+ my $htmldata;
+ my $image = '';
+ my $barcode = '';
+ if ( $conf->exists($tc.'email_pdf')
+ and scalar($conf->config($tc.'email_pdf_note')) ) {
+
+ $htmldata = join('<BR>', $conf->config($tc.'email_pdf_note') );
+
+ } else {
+
+ $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);
+
+ $image = 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";
+ $barcode = 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;
+ }
+
+ $htmldata = $self->print_html({ 'cid'=>$content_id, %args });
+ }
+
+ $alternative->attach(
+ 'Type' => 'text/html',
+ 'Encoding' => 'quoted-printable',
+ 'Data' => [ '<html>',
+ ' <head>',
+ ' <title>',
+ ' '. encode_entities($return{'subject'}),
+ ' </title>',
+ ' </head>',
+ ' <body bgcolor="#e8e8e8">',
+ $htmldata,
+ ' </body>',
+ '</html>',
+ ],
+ 'Disposition' => 'inline',
+ #'Filename' => 'invoice.pdf',
+ );
+
+
+ my @otherparts = ();
+ if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
+
+ 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 $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($alternative);
+
+ $related->add_part($image) if $image;
+
+ my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
+
+ $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
+
+ } else {
+
+ #no other attachment:
+ # multipart/related
+ # multipart/alternative
+ # text/plain
+ # text/html
+ # image/png
+
+ $return{'content-type'} = 'multipart/related';
+ 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';
+
+ }
+
+ } else {
+
+ if ( $conf->exists($tc.'email_pdf') ) {
+ warn "$me creating PDF attachment"
+ if $DEBUG;
+
+ #mime parts arguments a la MIME::Entity->build().
+ $return{'mimeparts'} = [
+ { $self->mimebuild_pdf(\%args) }
+ ];
+ }
+
+ if ( $conf->exists($tc.'email_pdf')
+ and scalar($conf->config($tc.'email_pdf_note')) ) {
+
+ warn "$me using '${tc}email_pdf_note'"
+ if $DEBUG;
+ $return{'body'} = [ map { $_ . "\n" }
+ $conf->config($tc.'email_pdf_note')
+ ];
+
+ } else {
+
+ warn "$me not using '${tc}email_pdf_note'"
+ if $DEBUG;
+ if ( ref($args{'print_text'}) eq 'ARRAY' ) {
+ $return{'body'} = $args{'print_text'};
+ } else {
+ $return{'body'} = [ $self->print_text(\%args) ];
+ }
+
+ }
+
+ }
+
+ %return;
+
+}
+
+=item mimebuild_pdf
+
+Returns a list suitable for passing to MIME::Entity->build(), representing
+this invoice as PDF attachment.
+
+=cut
+
+sub mimebuild_pdf {
+ my $self = shift;
+ (
+ 'Type' => 'application/pdf',
+ 'Encoding' => 'base64',
+ 'Data' => [ $self->print_pdf(@_) ],
+ 'Disposition' => 'attachment',
+ 'Filename' => 'invoice-'. $self->invnum. '.pdf',
+ );
+}
+
=item _items_sections OPTIONS
Generate section information for all items appearing on this invoice.
# and location labels
my @b = (); # accumulator for the line item hashes that we'll return
- my ($s, $r, $u, $d) = ( undef, undef, undef );
+ my ($s, $r, $u, $d) = ( undef, undef, undef, undef );
# the 'current' line item hashes for setup, recur, usage, discount
foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
{
# if the current line item is waiting to go out, and the one we're about
# to start is not bundled, then push out the current one and start a new
# one.
- foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) , $d ) {
+ foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
if ( $_ && !$cust_bill_pkg->hidden ) {
$_->{amount} = sprintf( "%.2f", $_->{amount} );
$_->{amount} =~ s/^\-0\.00$/0.00/;
'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref
'description' => $description,
'amount' => sprintf("%.2f", $cust_bill_pkg->setup),
- 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup),
+ 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup),
'quantity' => $cust_bill_pkg->quantity,
'preref_html' => ( $opt{preref_callback}
? &{ $opt{preref_callback} }( $cust_bill_pkg )
'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref
'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")",
'amount' => sprintf("%.2f", $cust_bill_pkg->recur),
- 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur),
+ 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur),
'quantity' => $cust_bill_pkg->quantity,
- 'preref_html' => ( $opt{preref_callback}
+ 'preref_html' => ( $opt{preref_callback}
? &{ $opt{preref_callback} }( $cust_bill_pkg )
: ''
),
or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
) {
+ # the line item hashref for the line that will show the original
+ # price
+ # (use the recur or single line for the package, unless we're
+ # showing a setup line for a package with no recurring fee)
+ my $active_line = $r;
+ if ( $type eq 'S' ) {
+ $active_line = $s;
+ }
+
my @discounts = $cust_bill_pkg->cust_bill_pkg_discount;
# special case: if there are old "discount details" on this line
# item, don't show discount line items
) > 0 ) {
@discounts = ();
}
- if( @discounts ) {
+ if ( @discounts ) {
warn "$me _items_cust_bill_pkg including discounts for ".
$cust_bill_pkg->billpkgnum."\n"
if $DEBUG;
my $discount_amount = sum( map {$_->amount} @discounts );
- my $orig_amount = $cust_bill_pkg->setup + $cust_bill_pkg->recur
- + $discount_amount;
# if multiple discounts apply to the same package, how to display
# them? ext_description lines, apparently
+ #
+ # # discount amounts are negative
if ( $d and $cust_bill_pkg->hidden ) {
- $d->{amount} += $discount_amount;
- $d->{orig_amount} += $orig_amount;
+ $d->{amount} -= $discount_amount;
} else {
my @ext;
- # make a placeholder for the original price, if necessary
- # (if unit prices are enabled, it won't be necessary)
- push @ext, '' if !$conf->exists('invoice-unitprice');
$d = {
_is_discount => 1,
- description => $self->mt('Discount included'),
- amount => $discount_amount,
- orig_amount => $orig_amount,
+ description => $self->mt('Discount'),
+ amount => -1 * $discount_amount,
ext_description => \@ext,
};
foreach my $cust_bill_pkg_discount (@discounts) {
- my $def = $cust_bill_pkg_discount->cust_pkg_discount->discount;
- push @ext, &{$escape_function}( $def->description );
+ my $discount = $cust_bill_pkg_discount->cust_pkg_discount->discount;
+ my $discount_desc = $discount->description_short;
+
+ if ($discount->months) {
+
+ # calculate months remaining after this invoice
+ my $used = FS::Record->scalar_sql(
+ 'SELECT SUM(months) FROM cust_bill_pkg_discount
+ JOIN cust_bill_pkg USING (billpkgnum)
+ JOIN cust_bill USING (invnum)
+ WHERE pkgdiscountnum = ? AND _date <= ?',
+ $cust_bill_pkg_discount->pkgdiscountnum,
+ $self->_date
+ );
+ $used ||= 0;
+ my $remaining = sprintf('%.2f', $discount->months - $used);
+ # append "for X months (Y months remaining)"
+ $discount_desc .= $self->mt(' for [quant,_1,month] ([quant,_2,month] remaining)',
+ $cust_bill_pkg_discount->months,
+ $remaining
+ );
+ } # else it's not time-limited
+ push @ext, &{$escape_function}($discount_desc);
}
}
- # update the placeholder to show the original price in the
- # first ext_description line
- if ( !$conf->exists('invoice-unitprice') ) {
- $d->{ext_description}->[0] =
- sprintf('Original price: %.2f', $d->{orig_amount});
- }
+ # update the active line (before the discount) to show the
+ # original price (whether this is a hidden line or not)
+ $active_line->{amount} += $discount_amount;
+
} # if there are any discounts
} # if this is an appropriate place to show discounts
}
- foreach ( $s, $r, ($opt{skip_usage} ? () : $u, $d ) ) {
+ foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
if ( $_ ) {
$_->{amount} = sprintf( "%.2f", $_->{amount} ),
if exists($_->{amount});