X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2FTemplate_Mixin.pm;h=00cea1a211d74a60d8fda7028a8b614d9a22673b;hb=fa298c55a9e276ef714f1e6dbf11ae3931ad8684;hp=f70ac3ce2ab8d64dc982728d88fb6c75c06ea192;hpb=ce200f25b9d4eebeddac0e8a9a58dbab6a54645b;p=freeside.git diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index f70ac3ce2..00cea1a21 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -16,6 +16,7 @@ use HTML::Entities; 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 ); @@ -344,13 +345,13 @@ sub print_generic { 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" @@ -702,25 +703,24 @@ sub print_generic { # "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; } @@ -739,13 +739,11 @@ sub print_generic { # 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'} = @@ -880,6 +878,7 @@ sub print_generic { ); 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' => '', @@ -1091,8 +1090,7 @@ sub print_generic { ext_description => [ map { &$escape_function($_) } @{ $line_item->{'ext_description'} || [] } ], - amount => ( $old_latex ? '' : $money_char). - $line_item->{'amount'}, + amount => $money_char . $line_item->{'amount'}, product_code => $line_item->{'pkgpart'} || 'N/A', }; @@ -1178,8 +1176,9 @@ sub print_generic { $line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()? $line_item->{'section'} = $section; $line_item->{'description'} = &$escape_function($line_item->{'description'}); - if (!$old_latex) { # dubious; templates should provide this - $line_item->{'amount'} = $money_char.$line_item->{'amount'}; + $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'} ||= []; @@ -1226,13 +1225,12 @@ sub print_generic { 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, }; @@ -1360,13 +1358,12 @@ sub print_generic { $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, }; @@ -1395,13 +1392,12 @@ sub print_generic { $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, }; @@ -1850,6 +1846,10 @@ sub _translate_old_latex_format { (@template); } +=item terms + +=cut + sub terms { my $self = shift; my $conf = $self->conf; @@ -1861,10 +1861,21 @@ sub terms { 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 = ''; @@ -1874,11 +1885,19 @@ sub due_date { $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'); @@ -1892,12 +1911,16 @@ sub balance_due_msg { $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; @@ -1932,6 +1955,348 @@ sub _date_pretty_unlocalized { 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. + +=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('
', $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' => [ '', + ' ', + ' ', + ' '. encode_entities($return{'subject'}), + ' ', + ' ', + ' ', + $htmldata, + ' ', + '', + ], + '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. @@ -2155,9 +2520,9 @@ sub _items_sections { } else { $section->{'category'} = $sectionname; $section->{'description'} = &{ $escape }($sectionname); - if ( _pkg_category($_) ) { - $section->{'sort_weight'} = _pkg_category($_)->weight; - if ( _pkg_category($_)->condense ) { + if ( _pkg_category($sectionname) ) { + $section->{'sort_weight'} = _pkg_category($sectionname)->weight; + if ( _pkg_category($sectionname)->condense ) { $section = { %$section, $self->_condense_section($opt{format}) }; } } @@ -2635,16 +3000,21 @@ sub _items_cust_bill_pkg { my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style # and location labels - my @b = (); - my ($s, $r, $u) = ( undef, undef, undef ); + 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. + 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} ), + if (exists($_->{unit_amount})) { + $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ); + } push @b, { %$_ } if $_->{amount} != 0 || $discount_show_always @@ -2715,6 +3085,7 @@ sub _items_cust_bill_pkg { # quotation_pkgs are never fees, so don't worry about the case where # part_pkg is undefined + # and I guess they're never bundled either? if ( $cust_bill_pkg->setup != 0 ) { my $description = $desc; $description .= ' Setup' @@ -2725,6 +3096,8 @@ sub _items_cust_bill_pkg { 'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref 'description' => $description, 'amount' => sprintf("%.2f", $cust_bill_pkg->setup), + 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup), + 'quantity' => $cust_bill_pkg->quantity, 'preref_html' => ( $opt{preref_callback} ? &{ $opt{preref_callback} }( $cust_bill_pkg ) : '' @@ -2736,10 +3109,17 @@ sub _items_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), + 'quantity' => $cust_bill_pkg->quantity, + 'preref_html' => ( $opt{preref_callback} + ? &{ $opt{preref_callback} }( $cust_bill_pkg ) + : '' + ), }; } - } elsif ( $cust_bill_pkg->pkgnum > 0 ) { # and it's not a quotation_pkg + } elsif ( $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; @@ -3018,6 +3398,89 @@ sub _items_cust_bill_pkg { } # recurring or usage with recurring charge + # decide whether to show active discounts here + if ( + # case 1: we are showing a single line for the package + ( !$type ) + # case 2: we are showing a setup line for a package that has + # no base recurring fee + or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 ) + # case 3: we are showing a recur line for a package that has + # a base recurring fee + or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 ) + ) { + + # 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 + if ( FS::cust_bill_pkg_detail->count( + "detail LIKE 'Includes discount%' AND billpkgnum = " . + $cust_bill_pkg->billpkgnum + ) > 0 ) { + @discounts = (); + } + if ( @discounts ) { + warn "$me _items_cust_bill_pkg including discounts for ". + $cust_bill_pkg->billpkgnum."\n" + if $DEBUG; + my $discount_amount = sum( map {$_->amount} @discounts ); + # 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; + } else { + my @ext; + $d = { + _is_discount => 1, + description => $self->mt('Discount'), + amount => -1 * $discount_amount, + ext_description => \@ext, + }; + foreach my $cust_bill_pkg_discount (@discounts) { + 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 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 + } else { # taxes and fees warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n" @@ -3039,13 +3502,14 @@ sub _items_cust_bill_pkg { } - foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) { + 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}) - if exists($_->{unit_amount}); + if (exists($_->{unit_amount})) { + $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ); + } push @b, { %$_ } if $_->{amount} != 0