X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2FTemplate_Mixin.pm;h=c97e84e8392027d2142f33492765f9bfdd97186b;hb=57d4a5ffe7b86d032339d6eefe1a22277f3ca113;hp=d46f61772dae623c867948b513ce3cc15219d679;hpb=a3f6785d22a743f03a805f537083ab57a20d5c6f;p=freeside.git diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index d46f61772..c97e84e83 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -7,13 +7,15 @@ use vars qw( $DEBUG $me ); # 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::Misc qw( send_email ); @@ -146,6 +148,10 @@ sub print_latex { $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'; @@ -560,6 +566,7 @@ sub print_generic { 'notice_name' => $notice_name, # escape? 'current_charges' => sprintf("%.2f", $self->charged), 'duedate' => $self->due_date2str('rdate'), #date_format? + 'duedate_long' => $self->due_date2str('long'), #customer info 'custnum' => $cust_main->display_custnum, @@ -570,7 +577,7 @@ sub print_generic { )), #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'), @@ -649,7 +656,7 @@ sub print_generic { if ( $cust_main->country eq $countrydefault ) { $invoice_data{'country'} = ''; } else { - $invoice_data{'country'} = &$escape_function(code2country($cust_main->country)); + $invoice_data{'country'} = &$escape_function($cust_main->bill_country_full); } my @address = (); @@ -685,7 +692,12 @@ sub print_generic { 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; + my $balance_due = $self->owed; + if ( $self->enable_previous ) { + $balance_due += $pr_total; + } + # otherwise the previous balance is not shown, so including it in the + # balance due is just confusing # the sum of amount owed on all invoices # (this is used in the summary & on the payment coupon) @@ -698,12 +710,18 @@ sub print_generic { # XXX should be an FS::cust_bill method to set the defaults, instead # of checking the type here + # info from customer's last invoice before this one, for some + # summary formats + $invoice_data{'last_bill'} = {}; + my $last_bill = $self->previous_bill; if ( $last_bill ) { # "balance_date_range" unfortunately is unsuitable for this, since it # cares about application dates. We want to know the sum of all # _top-level transactions_ dated before the last invoice. + # + # still do this for the "Previous Balance" line of the summary block my @sql = map "$_ WHERE _date <= ? AND custnum = ?", ( "SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill", @@ -736,19 +754,31 @@ sub print_generic { # longer stored in the database) $invoice_data{'true_previous_balance'} = $last_bill_balance; - # the change in balance from immediately after that invoice - # to immediately before this one - my $before_this_bill_balance = 0; + # Now, get all applications of credits/payments dated on or after the + # previous bill, to invoices before the current bill. (The + # credit/payment date restriction prevents these from intersecting + # the "Previous Balance" set.) + # These are "adjustments". The past due balance will be shown as + # Previous Balance - Adjustments. + my $adjustments = 0; + @sql = map { + "SELECT COALESCE(SUM(y.amount),0) FROM $_ JOIN cust_bill USING (invnum) + WHERE cust_bill._date < ? + AND x._date >= ? + AND cust_bill.custnum = ?" + } "cust_credit AS x JOIN cust_credit_bill y USING (crednum)", + "cust_pay AS x JOIN cust_bill_pay y USING (paynum)" + ; foreach (@sql) { my $delta = FS::Record->scalar_sql( $_, - $self->_date - 1, + $self->_date, + $last_bill->_date, $self->custnum, ); - $before_this_bill_balance += $delta; + $adjustments += $delta; } - $invoice_data{'balance_adjustments'} = - sprintf("%.2f", $last_bill_balance - $before_this_bill_balance); + $invoice_data{'balance_adjustments'} = sprintf("%.2f", $adjustments); warn sprintf("BALANCE ADJUSTMENTS: %.2f\n\n", $invoice_data{'balance_adjustments'} @@ -758,9 +788,7 @@ sub print_generic { # ($pr_total is used elsewhere but not as $previous_balance) $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total); - $invoice_data{'last_bill'} = { - '_date' => $last_bill->_date, #unformatted - }; + $invoice_data{'last_bill'}{'_date'} = $last_bill->_date; #unformatted my (@payments, @credits); # for formats that itemize previous payments foreach my $cust_pay ( qsearch('cust_pay', { @@ -802,11 +830,7 @@ sub print_generic { $invoice_data{'previous_payments'} = []; $invoice_data{'previous_credits'} = []; } - - # info from customer's last invoice before this one, for some - # summary formats - $invoice_data{'last_bill'} = {}; - + if ( $conf->exists('invoice_usesummary', $agentnum) ) { $invoice_data{'summarypage'} = $summarypage = 1; } @@ -820,35 +844,36 @@ sub print_generic { 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', @@ -862,6 +887,8 @@ sub print_generic { die $error; } + # fill in variables + $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data ); $invoice_data{$include} =~ s/\n+$// @@ -908,29 +935,6 @@ sub print_generic { warn "$me generating sections\n" if $DEBUG > 1; - my $taxtotal = 0; - my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'), - 'subtotal' => $taxtotal, # adjusted below - 'tax_section' => 1, - }; - 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; - - my $adjusttotal = 0; - my $adjust_section = { - 'description' => $self->mt('Credits, Payments, and Adjustments'), - 'adjust_section' => 1, - 'subtotal' => 0, # adjusted below - }; - my $adjust_weight = _pkg_category($adjust_section->{description}) - ? _pkg_category($adjust_section->{description})->weight - : 0; - $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'; my $multisection = $conf->exists($tc.'sections', $cust_main->agentnum) || $conf->exists($tc.'sections_by_location', $cust_main->agentnum); @@ -971,6 +975,21 @@ sub print_generic { $previous_section = $default_section; } + my $adjust_section = { + 'description' => $self->mt('Credits, Payments, and Adjustments'), + 'adjust_section' => 1, + 'subtotal' => 0, # adjusted below + }; + my $adjust_weight = _pkg_category($adjust_section->{description}) + ? _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; + + if ( $multisection ) { ($extra_sections, $extra_lines) = $self->_items_extra_usage_sections($escape_function_nonbsp, $format) @@ -1046,7 +1065,7 @@ sub print_generic { # 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') ) { + if ( ( ref($self) ne 'FS::quotation' ) and $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' ) { @@ -1137,14 +1156,27 @@ sub print_generic { 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'}), '' ], @@ -1220,6 +1252,27 @@ sub print_generic { warn "$me adding taxes\n" if $DEBUG > 1; + # create a tax section if we don't yet have one + 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; + } + $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; + my @items_tax = $self->_items_tax; foreach my $tax ( @items_tax ) { @@ -1254,7 +1307,7 @@ sub print_generic { ]; } - + if ( @items_tax ) { my $total = {}; $total->{'total_item'} = $self->mt('Sub-total'); @@ -1262,30 +1315,32 @@ sub print_generic { $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'}; - if ( $taxtotal ) { - push @sections, $tax_section; - push @summary_subtotals, $tax_section; + 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'} = ''; + + # append it if it's not already there + if ( !grep $tax_section, @sections ) { + 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 ### @@ -1297,51 +1352,46 @@ sub print_generic { ); 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 + } + + if ( $self->can('_items_total') ) { # should always be true now - # 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; - } + # even for multisection, need plain text version - $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) ); + 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 @@ -1349,19 +1399,17 @@ sub print_generic { 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( 'template' => $template, 'trim_len' => 60 ) + $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 ) { push @detail_items, { ext_description => [], @@ -1395,7 +1443,6 @@ sub print_generic { $total->{'total_item'} = &$escape_function($payment->{'description'}); $paymenttotal += $payment->{'amount'}; $total->{'total_amount'} = $minus.$other_money_char.$payment->{'amount'}; - $adjusttotal += $payment->{'amount'}; if ( $multisection ) { push @detail_items, { ext_description => [], @@ -1417,7 +1464,10 @@ sub print_generic { 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 @@ -1440,7 +1490,7 @@ sub print_generic { if ( $multisection && !$adjust_section->{sort_weight} ) { $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '. $total->{'total_amount'}; - }else{ + } else { push @total_items, $total; } push @buf,['','-----------']; @@ -1516,7 +1566,7 @@ sub print_generic { # 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); + 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; @@ -1526,7 +1576,7 @@ sub print_generic { # invoice history "section" (not really a section) # not to be included in any subtotals, completely independent of # everything... - if ( $conf->exists('previous_invoice_history') ) { + 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 ) { @@ -1613,24 +1663,24 @@ sub print_generic { 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; @@ -1644,6 +1694,13 @@ sub print_generic { } 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" @@ -1886,6 +1943,12 @@ sub due_date { 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; } @@ -1906,12 +1969,22 @@ sub due_date2str { 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('short'); - } elsif ( $self->terms ) { - $msg .= ' - '. $self->terms; + return $msg unless $self->terms; # huh? + if ( !$self->conf->exists('invoice_show_prior_due_date') + or $self->conf->exists('invoice_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 ) { + 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')). + ' ' + unless $self->conf->config_bool('invoice_omit_due_date',$self->agentnum); + } elsif ( $self->terms ) { + $msg .= ' - '. $self->mt($self->terms); + } } $msg; } @@ -2019,10 +2092,6 @@ sender address, required alternate template name, optional -=item print_text - -text attachment arrayref, optional - =item subject email subject, optional @@ -2038,6 +2107,7 @@ Returns an argument list to be passed to L. =cut use MIME::Entity; +use Encode; sub generate_email { @@ -2066,61 +2136,70 @@ sub generate_email { my $tc = $self->template_conf; - if ( $conf->exists($tc.'html') ) { + 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 - warn "$me creating HTML/text multipart message" - if $DEBUG; + if ( $conf->exists($tc. 'email_pdf') ) { + if ( my $msgnum = $conf->config($tc.'email_pdf_msgnum') ) { - $return{'nobody'} = 1; + warn "$me using '${tc}email_pdf_msgnum' in multipart message" + if $DEBUG; - my $alternative = build MIME::Entity - 'Type' => 'multipart/alternative', - #'Encoding' => '7bit', - 'Disposition' => 'inline' - ; + my $msg_template = FS::msg_template->by_key($msgnum) + or die "${tc}email_pdf_msgnum $msgnum not found\n"; + my %prepared = $msg_template->prepare( + cust_main => $self->cust_main, + object => $self + ); - my $data = ''; - if ( $conf->exists($tc. 'email_pdf') - and scalar($conf->config($tc. 'email_pdf_note')) ) { + @text = split(/(?=\n)/, $prepared{'text_body'}); + $html = $prepared{'html_body'}; + + } elsif ( my @note = $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') - ]; + @text = $conf->config($tc.'email_pdf_note'); + $html = join('
', @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 not using '${tc}email_pdf_note' in multipart message" + warn "$me no plain text version exists; sending empty message body" 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')) ) { + my $text_part = build MIME::Entity ( + 'Type' => 'text/plain', + 'Encoding' => 'quoted-printable', + 'Charset' => 'UTF-8', + #'Encoding' => '7bit', + 'Data' => \@text, + 'Disposition' => 'inline', + ); - $htmldata = join('
', $conf->config($tc.'email_pdf_note') ); + if (!$html) { - } else { + if ( $conf->exists($tc.'html') ) { + warn "$me generating HTML invoice" + if $DEBUG; $args{'from'} =~ /\@([\w\.\-]+)/; my $from = $1 || 'example.com'; @@ -2139,7 +2218,7 @@ sub generate_email { } my $image_data = $conf->config_binary( $logo, $agentnum); - $image = build MIME::Entity + push @related_parts, build MIME::Entity 'Type' => 'image/png', 'Encoding' => 'base64', 'Data' => $image_data, @@ -2149,7 +2228,7 @@ sub generate_email { 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 + push @related_parts, build MIME::Entity 'Type' => 'image/png', 'Encoding' => 'base64', 'Data' => $self->invoice_barcode(0), @@ -2159,7 +2238,26 @@ sub generate_email { $args{'barcode_cid'} = $barcode_content_id; } - $htmldata = $self->print_html({ 'cid'=>$content_id, %args }); + $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( @@ -2172,7 +2270,7 @@ sub generate_email { ' ', ' ', ' ', - $htmldata, + Encode::encode_utf8($html), ' ', '', ], @@ -2180,10 +2278,50 @@ sub generate_email { #'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; - my @otherparts = (); - if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) { + 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', @@ -2196,88 +2334,39 @@ sub generate_email { } - 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; + if ( $conf->exists($tc.'email_pdf') ) { - my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args); + #attaching pdf too: + # multipart/mixed + # multipart/related + # multipart/alternative + # text/plain + # text/html + # image/png + # application/pdf - $return{'mimeparts'} = [ $related, $pdf, @otherparts ]; + 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 { - - #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) } - ]; + $return{'mimeparts'} = [ $text_part, @otherparts ]; + $return{'type'} = 'text/plain'; } - - 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) ]; - } - - } - + } 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; @@ -2302,6 +2391,110 @@ sub mimebuild_pdf { ); } +=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 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->payname + && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/ + ? $cust_main->payname + : $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. @@ -2502,7 +2695,6 @@ sub _items_sections { foreach my $sectionname (keys %{ $s->{$locationnum} }) { my $section = { 'subtotal' => $s->{$locationnum}{$sectionname}, - 'post_total' => $post_total, 'sort_weight' => 0, }; if ( $locationnum ) { @@ -2794,11 +2986,16 @@ 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 @@ -2858,6 +3055,7 @@ sub _items_fee { my %base_invnums; # invnum => invoice date foreach ($cust_bill_pkg->cust_bill_pkg_fee) { if ($_->base_invnum) { + # XXX what if base_bill has been voided? my $base_bill = FS::cust_bill->by_key($_->base_invnum); my $base_date = $self->time2str_local('short', $base_bill->_date) if $base_bill; @@ -2866,13 +3064,14 @@ sub _items_fee { } 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($self->cust_main->locale); - $desc = &{$escape_function}($desc); + # but not escape the base description line push @items, { feepart => $cust_bill_pkg->feepart, @@ -3005,12 +3204,34 @@ sub _items_cust_bill_pkg { } my $summary_page = $opt{summary_page} || ''; #unused my $multisection = defined($category) || defined($locationnum); - my $discount_show_always = 0; + # 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 + + # 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 ); @@ -3027,11 +3248,13 @@ sub _items_cust_bill_pkg { 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} ) + 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; } @@ -3091,14 +3314,17 @@ sub _items_cust_bill_pkg { ); if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) { + # XXX this should be pulled out into quotation_pkg warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n" if $DEBUG > 1; # quotation_pkgs are never fees, so don't worry about the case where # part_pkg is undefined + my @details = $cust_bill_pkg->details; + # and I guess they're never bundled either? - if ( $cust_bill_pkg->setup != 0 ) { + if (( $cust_bill_pkg->setup != 0 ) || ( $cust_bill_pkg->setup_show_zero )) { my $description = $desc; $description .= ' Setup' if $cust_bill_pkg->recur != 0 @@ -3112,13 +3338,14 @@ sub _items_cust_bill_pkg { 'amount' => sprintf("%.2f", $cust_bill_pkg->setup), 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup), 'quantity' => $cust_bill_pkg->quantity, + 'ext_description' => \@details, 'preref_html' => ( $opt{preref_callback} ? &{ $opt{preref_callback} }( $cust_bill_pkg ) : '' ), }; } - if ( $cust_bill_pkg->recur != 0 ) { + if (( $cust_bill_pkg->recur != 0 ) || ( $cust_bill_pkg->recur_show_zero )) { #push @b, { $r = { 'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref @@ -3126,6 +3353,7 @@ sub _items_cust_bill_pkg { 'amount' => sprintf("%.2f", $cust_bill_pkg->recur), 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur), 'quantity' => $cust_bill_pkg->quantity, + 'ext_description' => \@details, 'preref_html' => ( $opt{preref_callback} ? &{ $opt{preref_callback} }( $cust_bill_pkg ) : '' @@ -3157,6 +3385,7 @@ sub _items_cust_bill_pkg { 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) ) ) { @@ -3164,10 +3393,13 @@ sub _items_cust_bill_pkg { 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; $description .= $cust_bill_pkg->time_period_pretty( $part_pkg, @@ -3184,8 +3416,11 @@ sub _items_cust_bill_pkg { # always pass the svc_label through to the template, even if # not displaying it as an ext_description my @svc_labels = map &{$escape_function}($_), - $cust_pkg->h_labels_short($self->_date, undef, 'I'); - + $cust_pkg->h_labels_short($self->_date, + undef, + 'I', + $self->conf->{locale}, + ); $svc_label = $svc_labels[0]; unless ( $cust_pkg->part_pkg->hide_svc_detail @@ -3194,11 +3429,10 @@ sub _items_cust_bill_pkg { push @d, @svc_labels unless $cust_bill_pkg->pkgpart_override; #don't redisplay services - my $lnum = $cust_main ? $cust_main->ship_locationnum - : $self->prospect_main->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 != $lnum and !defined($locationnum) ) { + 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; @@ -3232,11 +3466,18 @@ sub _items_cust_bill_pkg { } + # 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 ) ) @@ -3269,7 +3510,9 @@ sub _items_cust_bill_pkg { push @dates, undef if !$prev; my @svc_labels = map &{$escape_function}($_), - $cust_pkg->h_labels_short(@dates, 'I'); + $cust_pkg->h_labels_short(@dates, + 'I', + $self->conf->{locale}); $svc_label = $svc_labels[0]; # show service labels, unless... @@ -3296,11 +3539,10 @@ sub _items_cust_bill_pkg { warn "$me _items_cust_bill_pkg done adding service details\n" if $DEBUG > 1; - my $lnum = $cust_main ? $cust_main->ship_locationnum - : $self->prospect_main->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 != $lnum and !defined($locationnum) ) { + 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; @@ -3479,9 +3721,6 @@ sub _items_cust_bill_pkg { } # foreach $display - $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount - && $conf->exists('discount-show-always')); - } foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) { @@ -3493,11 +3732,11 @@ sub _items_cust_bill_pkg { $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ); } - push @b, { %$_ } - if $_->{amount} != 0 - || $discount_show_always - || ( ! $_->{_is_setup} && $_->{recur_show_zero} ) - || ( $_->{_is_setup} && $_->{setup_show_zero} ) + push @b, { %$_ }; + #if $_->{amount} != 0 + # || $discount_show_always + # || ( ! $_->{_is_setup} && $_->{recur_show_zero} ) + # || ( $_->{_is_setup} && $_->{setup_show_zero} ) } }