X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2FTemplate_Mixin.pm;h=38c5066922986205e7bb8b1a4c08fae0246c5530;hb=596f08196fd33e134009833b6453809789088dc8;hp=95d001e83694bfe679aa2e30326fb1af468a8e9f;hpb=84bd1a42758d23b5068f664bad602664954ddf83;p=freeside.git diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index 95d001e83..38c506692 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -7,17 +7,19 @@ use vars qw( $DEBUG $me ); # but NOT $conf use vars qw( $invoice_lines @buf ); #yuck -use List::Util qw(sum first); +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 ); -use FS::Record qw( qsearch qsearchs ); +use FS::Record qw( qsearch qsearchs dbh ); use FS::Conf; use FS::Misc qw( generate_ps generate_pdf ); use FS::pkg_category; @@ -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'; @@ -293,7 +299,7 @@ before that line item (quotations only) =item template -Dprecated. Used as a suffix for a configuration template. Please +Deprecated. Used as a suffix for a configuration template. Please don't use this, it deprecated in favor of more flexible alternatives. =back @@ -316,9 +322,6 @@ sub print_generic { unless $format =~ /^(latex|html|template)$/; my $cust_main = $self->cust_main || $self->prospect_main; - $cust_main->payname( $cust_main->first. ' '. $cust_main->getfield('last') ) - unless $cust_main->payname - && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/; my $locale = $params{'locale'} || $cust_main->locale; @@ -341,6 +344,8 @@ sub print_generic { $templatefile .= "_$template" if length($template) && $conf->exists($templatefile."_$template"); + $self->set('_template',$template); + # the base template my @invoice_template = map "$_\n", $conf->config($templatefile) or die "cannot load config data $templatefile"; @@ -560,17 +565,20 @@ 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, 'prospectnum' => $cust_main->prospectnum, 'agent_custid' => &$escape_function($cust_main->agent_custid), - ( map { $_ => &$escape_function($cust_main->$_()) } qw( - payname company address1 address2 city state zip fax - )), + ( map { $_ => &$escape_function($cust_main->$_()) } + qw( company address1 address2 city state zip fax ) + ), + 'payname' => &$escape_function( $cust_main->invoice_attn + || $cust_main->contact_firstlast ), #global config - 'ship_enable' => $conf->exists('invoice-ship_address'), + 'ship_enable' => $cust_main->invoice_ship_address || $conf->exists('invoice-ship_address'), 'unitprices' => $conf->exists('invoice-unitprice'), 'smallernotes' => $conf->exists('invoice-smallernotes'), 'smallerfooter' => $conf->exists('invoice-smallerfooter'), @@ -597,6 +605,9 @@ sub print_generic { 'total_pages' => 1, ); + + #quotations have $name + $invoice_data{'name'} = $invoice_data{'payname'}; #localization $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) }; @@ -646,19 +657,20 @@ sub print_generic { $invoice_data{'cid'} = $params{'cid'} if $params{'cid'}; - if ( $cust_main->country eq $countrydefault ) { - $invoice_data{'country'} = ''; + if ( $cust_main->bill_locationnum + && $cust_main->bill_location->country ne $countrydefault ) { + $invoice_data{'country'} = &$escape_function($cust_main->bill_country_full); } else { - $invoice_data{'country'} = &$escape_function(code2country($cust_main->country)); + $invoice_data{'country'} = ''; } my @address = (); $invoice_data{'address'} = \@address; push @address, - $cust_main->payname. - ( ( $cust_main->payby eq 'BILL' ) && $cust_main->payinfo - ? " (P.O. #". $cust_main->payinfo. ")" - : '' + $invoice_data{'payname'}. + ( $cust_main->po_number + ? " (P.O. #". $cust_main->po_number. ")" + : '' ) ; push @address, $cust_main->company @@ -682,13 +694,34 @@ sub print_generic { $invoice_data{'barcode_cid'} = $params{'barcode_cid'} if $params{'barcode_cid'}; - my( $pr_total, @pr_cust_bill ) = $self->previous; #previous balance -# my( $cr_total, @cr_cust_credit ) = $self->cust_credit; #credits - #my $balance_due = $self->owed + $pr_total - $cr_total; - my $balance_due = $self->owed + $pr_total; - # the sum of amount owed on all invoices - # (this is used in the summary & on the payment coupon) + # re: rt:78190 + # using owed_on_invoice() instead of owed() here for $balance_due + # using _items_previous_total() instead of ->previous() for $pr_total + # + # owed_on_invoice() is aware of configuration flags that affect how an + # invoice is rendered. May not return actual current balance. Will + # return balance appropriate for the invoice being rendered, based + # on which past due items, current charges, and future payments are + # displayed. + # + # Going forward, usage of owed(), or bypassing cust_bill helper methods + # when generating invoice lines may lead to incorrect or misleading + # math on invoices. + # + # Helper methods that are aware of invoicing conf flags: + # - owed_on_invoice # use instead of owed() + # - _items_previous() # use instead of previous() + # - _items_credits() # use instead of cust_credit() + # - _items_payments() + # - _items_total() + # - _items_previous_total() # use instead of previous() + # - _items_payments_total() + # - _items_credits_total() # use instead of cust_credit() + + my $pr_total = $self->_items_previous_total(); + + my $balance_due = $self->owed_on_invoice(); $invoice_data{'balance'} = sprintf("%.2f", $balance_due); # flag telling this invoice to have a first-page summary @@ -698,115 +731,100 @@ sub print_generic { # XXX should be an FS::cust_bill method to set the defaults, instead # of checking the type here - 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. - 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 + # 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 ) { - my $last_bill_balance = $last_bill->charged; - foreach (@sql) { - my $delta = FS::Record->scalar_sql( - $_, - $last_bill->_date - 1, - $self->custnum, - ); - $last_bill_balance += $delta; - } + # Populate template stash for previous balance and payments + if ($pr_total) { + # Used on summary page as "Previous Balance" + $invoice_data{'true_previous_balance'} = sprintf("%.2f", $pr_total); - $last_bill_balance = sprintf("%.2f", $last_bill_balance); - - warn sprintf("LAST BILL: INVNUM %d, DATE %s, BALANCE %.2f\n\n", - $last_bill->invnum, - $self->time2str_local('%D', $last_bill->_date), - $last_bill_balance - ) if $DEBUG > 0; - # ("true_previous_balance" is a terrible name, but at least it's no - # 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; - foreach (@sql) { - my $delta = FS::Record->scalar_sql( - $_, - $self->_date - 1, - $self->custnum, + # Used on summary page as "Payments" + $invoice_data{'balance_adjustments'} = sprintf("%.2f", + $self->_items_payments_total() + $self->_items_credits_total() ); - $before_this_bill_balance += $delta; - } - $invoice_data{'balance_adjustments'} = - sprintf("%.2f", $last_bill_balance - $before_this_bill_balance); - - warn sprintf("BALANCE ADJUSTMENTS: %.2f\n\n", - $invoice_data{'balance_adjustments'} - ) if $DEBUG > 0; - # the sum of amount owed on all previous invoices - # ($pr_total is used elsewhere but not as $previous_balance) + # Used in invoice template as "Previous Balance" $invoice_data{'previous_balance'} = sprintf("%.2f", $pr_total); - $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', { - 'custnum' => $self->custnum, - '_date' => { op => '>=', - value => $last_bill->_date } - } ) ) - { - next if $cust_pay->_date > $self->_date; - push @payments, { - '_date' => $cust_pay->_date, - 'date' => $self->time2str_local('long', $cust_pay->_date, $format), - 'payinfo' => $cust_pay->payby_payinfo_pretty, - 'amount' => sprintf('%.2f', $cust_pay->paid), - }; - # not concerned about applications - } - foreach my $cust_credit ( qsearch('cust_credit', { - 'custnum' => $self->custnum, - '_date' => { op => '>=', - value => $last_bill->_date } - } ) ) - { - next if $cust_credit->_date > $self->_date; - push @credits, { - '_date' => $cust_credit->_date, - 'date' => $self->time2str_local('long', $cust_credit->_date, $format), - 'creditreason'=> $cust_credit->reason, - 'amount' => sprintf('%.2f', $cust_credit->amount), - }; + # $invoice_data{last_bill}{_date}: + # Not used in default templates, but may be in use by someone + # + # ! May be a problem field if they are using it... this field + # stores the date of the previous invoice... it is possible to + # carry a balance, but have the immediately previous invoice paid off. + # In this case, this field might be presenting bad data? Not + # altering the problematic behavior, because someone might be + # expecting this bad behavior in their templates for some other + # purpose, such as a "your last bill was dated %_date%" + my $last_bill = $self->previous_bill; + $invoice_data{'last_bill'}{'_date'} + = ref $last_bill + ? $last_bill->_date() + : undef; + + # $invoice_data{previous_payments} + # Not used in default templates, but may be in use by someone + # + # Returns an array of hrefs representing payments, each with keys: + # - _date: epoch timestamp + # - date: text formatted date + # - amount: money formatted amount string + # - payinfo: string from payby_payinfo_pretty() + # - paynum: id for cust_pay + # - description: Text description for bill line item + # + my @payments = $self->_items_payments(); + $invoice_data{previous_payments} = \@payments; + + # $invoice_data{previous_credits} + # Not used in default templates, but may be in use by someone + # + # Returns an array of hrefs representing credits, each with keys: + # - _date: epoch timestamp + # - date: text formatted date + # - amount: money formatted amount string + # - crednum: id for cust_credit + # - description: Text description for bill line item + # - creditreason: reason() from cust_credit + # + my @credits = $self->_items_credits(); + $invoice_data{previous_credits} = \@credits; + + # Populate formatted date field + for my $pmt_href (@payments, @credits) { + $pmt_href->{date} = $self->time2str_local( + 'long', + $pmt_href->{_date}, + $format + ); } - $invoice_data{'previous_payments'} = \@payments; - $invoice_data{'previous_credits'} = \@credits; + } else { - # there is no $last_bill + # There are no outstanding invoices = YAPH $invoice_data{'true_previous_balance'} = $invoice_data{'balance_adjustments'} = $invoice_data{'previous_balance'} = '0.00'; - $invoice_data{'previous_payments'} = []; + $invoice_data{'previous_payments'} = $invoice_data{'previous_credits'} = []; } - # info from customer's last invoice before this one, for some - # summary formats - $invoice_data{'last_bill'} = {}; - + # Condencing a lot of debug staements here + if ($DEBUG) { + warn "\$invoice_data{$_}: $invoice_data{$_}" + for qw( + true_previous_balance + balance_adjustments + previous_balance + previous_payments + previous_credits + ); + } + if ( $conf->exists('invoice_usesummary', $agentnum) ) { $invoice_data{'summarypage'} = $summarypage = 1; } @@ -820,35 +838,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,19 +881,23 @@ sub print_generic { die $error; } + # fill in variables + $invoice_data{$include} = $inc_tt->fill_in( HASH => \%invoice_data ); $invoice_data{$include} =~ s/\n+$// if ($format eq 'latex'); } - # let invoices use either of these as needed - $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL') - ? $cust_main->payinfo : ''; - $invoice_data{'po_line'} = - ( $cust_main->payby eq 'BILL' && $cust_main->payinfo ) - ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo) - : $nbsp; +# if (well, probably when) we still need PO numbers in the brave new world of +# 4.x, then we'll have to add them back as their own customer fields +# # let invoices use either of these as needed +# $invoice_data{'po_num'} = ($cust_main->payby eq 'BILL') +# ? $cust_main->payinfo : ''; +# $invoice_data{'po_line'} = +# ( $cust_main->payby eq 'BILL' && $cust_main->payinfo ) +# ? &$escape_function($self->mt("Purchase Order #").$cust_main->payinfo) +# : $nbsp; my %money_chars = ( 'latex' => '', 'html' => $conf->config('money_char') || '$', @@ -909,9 +932,10 @@ sub print_generic { if $DEBUG > 1; 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); - $invoice_data{'multisection'} = $multisection; + my $multisection = $self->has_sections; + if ( $multisection ) { + $invoice_data{multisection} = $conf->config($tc.'sections_method') || 1; + } my $late_sections; my $extra_sections = []; my $extra_lines = (); @@ -937,11 +961,21 @@ sub print_generic { sprintf('%.2f', $pr_total), 'summarized' => '', #why? $summarypage ? 'Y' : '', }; - $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '. - join(' / ', map { $cust_main->balance_date_range(@$_) } - $self->_prior_month30s - ) - if $conf->exists('invoice_include_aging'); + + # Include balance aging line and template variables + my @aged_balances = $self->_items_aging_balances(); + ( $invoice_data{aged_balance_current}, + $invoice_data{aged_balance_30d}, + $invoice_data{aged_balance_60d}, + $invoice_data{aged_balance_90d} + ) = @aged_balances; + + if ($conf->exists('invoice_include_aging')) { + $previous_section->{posttotal} = sprintf( + '0 / 30 / 60 / 90 days overdue %.2f / %.2f / %.2f / %.2f', + @aged_balances, + ); + } } else { # otherwise put them in the main section @@ -1058,7 +1092,7 @@ sub print_generic { } } else { # subtotal sectioning is the same as for the actual invoice sections - @summary_subtotals = @sections; + @summary_subtotals = grep $_->{subtotal}, @sections; } # Hereafter, push sections to both @sections and @summary_subtotals @@ -1098,7 +1132,7 @@ sub print_generic { } - if ( @pr_cust_bill && $self->enable_previous ) { + if ( $pr_total && $self->enable_previous ) { push @buf, ['','-----------']; push @buf, [ $self->mt('Total Previous Balance'), $money_char. sprintf("%10.2f", $pr_total) ]; @@ -1129,14 +1163,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'}), '' ], @@ -1149,6 +1196,9 @@ sub print_generic { my %options = (); $options{'section'} = $section if $multisection; + $options{'section_with_taxes'} = 1 + if $multisection + && $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum); $options{'format'} = $format; $options{'escape_function'} = $escape_function; $options{'no_usage'} = 1 unless $unsquelched; @@ -1157,13 +1207,29 @@ sub print_generic { $options{'skip_usage'} = scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections; $options{'preref_callback'} = $params{'preref_callback'}; + $options{'disable_line_item_date_ranges'} = + $conf->exists('disable_line_item_date_ranges'); warn "$me searching for line items\n" if $DEBUG > 1; + my %section_tax_lines; + my %seen_tax_lines; + foreach my $line_item ( $self->_items_pkg(%options), $self->_items_fee(%options) ) { + # When bill is sectioned by location, fees may be displayed within the + # appropriate location section. Suppress this fee from the taxes/fees + # end section, so it doesn't appear to be charged twice and make the + # subtotals seem incorrect + next + if $line_item->{locationnum} + && ref $options{section} + && !exists $options{section}->{locationnum} + && $self->has_sections + && $conf->config($tc.'sections_method') eq 'location'; + warn "$me adding line item ". join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n" if $DEBUG > 1; @@ -1184,10 +1250,56 @@ sub print_generic { $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'}; } $line_item->{'ext_description'} ||= []; - + + if ( $options{section_with_taxes} && ref $line_item->{pkg_tax} ) { + for my $line_tax ( @{$ line_item->{pkg_tax} } ) { + + # It is rarely possible for the same tax record to be presented here + # multiple times. See cust_bill_pkg::_pkg_tax_list for more info + next if $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} }; + $seen_tax_lines{ $line_tax->{billpkgtaxlocationnum} } = 1; + + $section_tax_lines{ $line_tax->{taxname} } += $line_tax->{amount}; + } + } + push @detail_items, $line_item; } + # If conf flag invoice_sections_with_taxes: + # - Add @detail_items for taxes into each section + # - Update section subtotal to include taxes + if ( $options{section_with_taxes} && %section_tax_lines ) { + for my $taxname ( keys %section_tax_lines ) { + + push @detail_items, { + section => $section, + amount => sprintf($money_char."%.2f",$section_tax_lines{$taxname}), + description => &$escape_function($taxname), + }; + + # Append taxes to total. If line format resembles "$5.00 to $12.00" + # append to the second value. + + if ($section->{subtotal} =~ /to/) { + my @subtotal = split /\s/, $section->{subtotal}; + $subtotal[2] =~ s/[^\d\.]//g; + $subtotal[2] = sprintf( + $money_char."%.2f", + ( $subtotal[2] + $section_tax_lines{$taxname} ) + ); + $section->{subtotal} = join ' ', @subtotal; + } else { + $section->{subtotal} =~ s/[^\d\.]//g; + $section->{subtotal} = sprintf( + $money_char . "%.2f", + ( $section->{subtotal} + $section_tax_lines{$taxname} ) + ); + } + + } + } + if ( $section->{'description'} ) { push @buf, ( ['','-----------'], [ $section->{'description'}. ' sub-total', @@ -1213,11 +1325,13 @@ sub print_generic { if $DEBUG > 1; # create a tax section if we don't yet have one + my @items_tax = $self->_items_tax; my $tax_description = 'Taxes, Surcharges, and Fees'; - my $tax_section = first { $_->{description} eq $tax_description } @sections; + 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; + push @sections, $tax_section if $multisection and @items_tax > 0; } $tax_section->{tax_section} = 1; # mark this section as containing taxes # if this is an existing tax section, we're merging the tax items into it. @@ -1232,28 +1346,36 @@ sub print_generic { #$tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : ''; #$tax_section->{'sort_weight'} = $tax_weight; - my @items_tax = $self->_items_tax; + my $invoice_sections_with_taxes = $conf->config_bool( + 'invoice_sections_with_taxes', $cust_main->agentnum + ); + foreach my $tax ( @items_tax ) { - $taxtotal += $tax->{'amount'}; my $description = &$escape_function( $tax->{'description'} ); my $amount = sprintf( '%.2f', $tax->{'amount'} ); if ( $multisection ) { + if ( !$invoice_sections_with_taxes ) { + + $taxtotal += $tax->{'amount'}; + + push @detail_items, { + ext_description => [], + ref => '', + quantity => '', + description => $description, + amount => $money_char. $amount, + product_code => '', + section => $tax_section, + }; - push @detail_items, { - ext_description => [], - ref => '', - quantity => '', - description => $description, - amount => $money_char. $amount, - product_code => '', - section => $tax_section, - }; - + } } else { + $taxtotal += $tax->{'amount'}; + push @total_items, { 'total_item' => $description, 'total_amount' => $other_money_char. $amount, @@ -1266,7 +1388,7 @@ sub print_generic { ]; } - + if ( @items_tax ) { my $total = {}; $total->{'total_item'} = $self->mt('Sub-total'); @@ -1274,18 +1396,34 @@ sub print_generic { $other_money_char. sprintf('%.2f', $self->charged - $taxtotal ); if ( $multisection ) { + + if ( $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum) ) { + # If all tax items are displayed in location/category sections, + # remove the empty tax section + @sections = grep{ $_ ne $tax_section } @sections + unless grep{ $_->{section} eq $tax_section } @detail_items; + } + 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 tax section unless it's already there + push @sections, $tax_section + unless grep {$_ eq $tax_section} @sections; + + push @summary_subtotals, $tax_section + unless grep {$_ eq $tax_section} @summary_subtotals; - # append it if it's not already there - if ( !grep $tax_section, @sections ) { - push @sections, $tax_section; - push @summary_subtotals, $tax_section; - } } } else { @@ -1294,15 +1432,6 @@ sub print_generic { } $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 ### @@ -1314,51 +1443,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 + + # even for multisection, need plain text version - # 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; - } + my @new_total_items = $self->_items_total; - $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) ); + 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 @@ -1366,12 +1490,11 @@ 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; @@ -1452,13 +1575,13 @@ sub print_generic { # ? $self->charged + # $self->billing_balance # : - $self->owed + $pr_total + $balance_due ) ); if ( $multisection && !$adjust_section->{sort_weight} ) { $adjust_section->{'posttotal'} = $total->{'total_item'}. ' '. $total->{'total_amount'}; - }else{ + } else { push @total_items, $total; } push @buf,['','-----------']; @@ -1498,7 +1621,7 @@ sub print_generic { $total->{'total_item'} = &$embolden_function($self->balance_due_msg); $total->{'total_amount'} = &$embolden_function( - $other_money_char. sprintf('%.2f', $self->owed + $pr_total) + $other_money_char. sprintf('%.2f', $balance_due) ); my $last_section = pop @sections; $last_section->{'posttotal'} = $total->{'total_item'}. ' '. @@ -1534,7 +1657,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; @@ -1544,7 +1667,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 ) { @@ -1631,24 +1754,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; @@ -1662,6 +1785,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" @@ -1673,22 +1803,9 @@ sub print_generic { sub notice_name { '('.shift->table.')'; } -sub template_conf { 'invoice_'; } - -# helper routine for generating date ranges -sub _prior_month30s { - my $self = shift; - my @ranges = ( - [ 1, 2592000 ], # 0-30 days ago - [ 2592000, 5184000 ], # 30-60 days ago - [ 5184000, 7776000 ], # 60-90 days ago - [ 7776000, 0 ], # 90+ days ago - ); - - map { [ $_->[0] ? $self->_date - $_->[0] - 1 : '', - $_->[1] ? $self->_date - $_->[1] - 1 : '', - ] } - @ranges; +# this is not supposed to happen +sub template_conf { warn "bare FS::Template_Mixin::template_conf"; + 'invoice_'; } =item print_ps HASHREF | [ TIME [ , TEMPLATE ] ] @@ -1904,6 +2021,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; } @@ -1917,6 +2040,23 @@ sub due_date2str { $self->due_date ? $self->time2str_local(shift, $self->due_date) : ''; } +=item invoice_pay_by_msg + + displays the invoice_pay_by_msg or default Please pay by [_1] if empty. + +=cut + +sub invoice_pay_by_msg { + my $self = shift; + my $msg = ''; + my $please_pay_by = + $self->conf->config('invoice_pay_by_msg', $self->agentnum) + || 'Please pay by [_1]'; + $msg .= ' - ' . $self->mt($please_pay_by, $self->due_date2str('short')) . ' '; + + $msg; +} + =item balance_due_msg =cut @@ -1924,12 +2064,18 @@ 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') + || $self->has_sections ) { + # if enabled, the due date is shown with Total New Charges (see + # _items_total) and not here + # (yes, or if invoice_sections is enabled; this is just for compatibility) + if ( $self->due_date ) { + $msg .= $self->invoice_pay_by_msg + unless $self->conf->config_bool('invoice_omit_due_date',$self->agentnum); + } elsif ( $self->terms ) { + $msg .= ' - '. $self->mt($self->terms); + } } $msg; } @@ -2037,10 +2183,6 @@ sender address, required alternate template name, optional -=item print_text - -text attachment arrayref, optional - =item subject email subject, optional @@ -2056,6 +2198,7 @@ Returns an argument list to be passed to L. =cut use MIME::Entity; +use Encode; sub generate_email { @@ -2084,61 +2227,82 @@ 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 $cust_msg = $msg_template->prepare( + cust_main => $self->cust_main, + object => $self, + msgtype => 'invoice', + ); + + # XXX hack to make this work in the new cust_msg era; consider replacing + # with cust_bill_send_with_notice events. + my @parts = $cust_msg->parts; + foreach my $part (@parts) { # will only have two parts, normally + if ( $part->mime_type eq 'text/plain' ) { + @text = @{ $part->body }; + } elsif ( $part->mime_type eq 'text/html' ) { + $html = $part->bodyhandle->as_string; + } + } - my $data = ''; - if ( $conf->exists($tc. 'email_pdf') - and scalar($conf->config($tc. 'email_pdf_note')) ) { + } 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; + + @text = $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' => [ + map + { Encode::encode('UTF-8', $_, Encode::FB_WARN | Encode::LEAVE_SRC ) } + @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'; @@ -2157,7 +2321,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, @@ -2167,7 +2331,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), @@ -2177,7 +2341,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( @@ -2190,7 +2373,11 @@ sub generate_email { ' ', ' ', ' ', - $htmldata, + Encode::encode( + 'UTF-8', + $html, + Encode::FB_WARN | Encode::LEAVE_SRC + ), ' ', '', ], @@ -2198,10 +2385,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', @@ -2214,88 +2441,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; - - 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; + if ( $conf->exists($tc.'email_pdf') ) { - #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')) ) { + #attaching pdf too: + # multipart/mixed + # multipart/related + # multipart/alternative + # text/plain + # text/html + # image/png + # application/pdf - warn "$me using '${tc}email_pdf_note'" - if $DEBUG; - $return{'body'} = [ map { $_ . "\n" } - $conf->config($tc.'email_pdf_note') - ]; + 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 { - - 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{'mimeparts'} = [ $text_part, @otherparts ]; + $return{'type'} = 'text/plain'; } - + } elsif ( $html ) { # no PDF or CSV, strip the outer container + $return{'mimeparts'} = \@related_parts; + $return{'content-type'} = 'multipart/related'; + $return{'type'} = 'multipart/alternative'; + } else { # no HTML either + $return{'body'} = \@text; + $return{'content-type'} = 'text/plain'; } %return; @@ -2305,7 +2483,7 @@ sub generate_email { =item mimebuild_pdf Returns a list suitable for passing to MIME::Entity->build(), representing -this invoice as PDF attachment. +this quotation or invoice as PDF attachment. =cut @@ -2316,8 +2494,110 @@ sub mimebuild_pdf { 'Encoding' => 'base64', 'Data' => [ $self->print_pdf(@_) ], 'Disposition' => 'attachment', - 'Filename' => 'invoice-'. $self->invnum. '.pdf', + 'Filename' => $self->pdf_filename, + ); +} + +=item postal_mail_fsinc + +Sends this invoice to the Freeside Internet Services, Inc. print and mail +service. + +=cut + +use CAM::PDF; +use IO::Socket::SSL; +use LWP::UserAgent; +use HTTP::Request::Common qw( POST ); +use Cpanel::JSON::XS; +use MIME::Base64; +sub postal_mail_fsinc { + my ( $self, %opt ) = @_; + + my $url = 'https://ws.freeside.biz/print'; + + my $cust_main = $self->cust_main; + my $agentnum = $cust_main->agentnum; + my $bill_location = $cust_main->bill_location; + + die "Extra charges for international mailing; contact support\@freeside.biz to enable\n" + if $bill_location->country ne 'US'; + + my $conf = new FS::Conf; + + my @company_address = $conf->config('company_address', $agentnum); + my ( $company_address1, $company_address2, $company_city, $company_state, $company_zip ); + if ( $company_address[2] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) { + $company_address1 = $company_address[0]; + $company_address2 = $company_address[1]; + $company_city = $1; + $company_state = $2; + $company_zip = $3; + } elsif ( $company_address[1] =~ /^\s*(\S.*\S)\s*[\s,](\w\w),?\s*(\d{5}(-\d{4})?)\s*$/ ) { + $company_address1 = $company_address[0]; + $company_address2 = ''; + $company_city = $1; + $company_state = $2; + $company_zip = $3; + } else { + die "Unparsable company_address; contact support\@freeside.biz\n"; + } + $company_city =~ s/,$//; + + my $file = $self->print_pdf(%opt, 'no_addresses' => 1); + my $pages = CAM::PDF->new($file)->numPages; + + my $ua = LWP::UserAgent->new( + 'ssl_opts' => { + verify_hostname => 0, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, + SSL_version => 'SSLv3', + } ); + my $response = $ua->request( POST $url, [ + 'support-key' => scalar($conf->config('support-key')), + 'file' => encode_base64($file), + 'pages' => $pages, + + #from: + 'company_name' => scalar( $conf->config('company_name', $agentnum) ), + 'company_address1' => $company_address1, + 'company_address2' => $company_address2, + 'company_city' => $company_city, + 'company_state' => $company_state, + 'company_zip' => $company_zip, + 'company_country' => 'US', + 'company_phonenum' => scalar($conf->config('company_phonenum', $agentnum)), + 'company_email' => scalar($conf->config('invoice_from', $agentnum)), + + #to: + 'name' => $cust_main->invoice_attn + || $cust_main->contact_firstlast, + 'company' => $cust_main->company, + 'address1' => $bill_location->address1, + 'address2' => $bill_location->address2, + 'city' => $bill_location->city, + 'state' => $bill_location->state, + 'zip' => $bill_location->zip, + 'country' => $bill_location->country, + ]); + + die "Print connection error: ". $response->message. + ' ('. $response->as_string. ")\n" + unless $response->is_success; + + local $@; + my $content = eval { decode_json($response->content) }; + die "Print JSON error : $@\n" if $@; + + die $content->{error}."\n" + if $content->{error}; + + #TODO: store this so we can query for a status later + warn "Invoice printed, ID ". $content->{id}. "\n"; + + $content->{id}; + } =item _items_sections OPTIONS @@ -2429,7 +2709,13 @@ sub _items_sections { foreach my $display ($cust_bill_pkg->cust_bill_pkg_display) { next if ( $display->summary && $opt{summary} ); - my $section = $display->section; + #my $section = $display->section; + #false laziness with the method, but for efficiency inside this loop + my $section = $display->get('section'); + if ( !$section && !$cust_bill_pkg->hidden ) { + $section = $cust_bill_pkg->get('categoryname'); #cust_bill->cust_bill_pkg added it (XXX quotations / quotation_section) + } + my $type = $display->type; # Set $section = undef if we're sectioning by location and this # line item _has_ a location (i.e. isn't a fee). @@ -2520,7 +2806,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 ) { @@ -2855,6 +3140,10 @@ sub _items_fee { my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg; my $escape_function = $options{escape_function}; + my $locale = $self->cust_main + ? $self->cust_main->locale + : $self->prospect_main->locale; + my @items; foreach my $cust_bill_pkg (@cust_bill_pkg) { # cache this, so we don't look it up again in every section @@ -2866,17 +3155,31 @@ sub _items_fee { warn "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n"; next; } - if ( exists($options{section}) and exists($options{section}{category}) ) - { - my $categoryname = $options{section}{category}; - # then filter for items that have that section - if ( $part_fee->categoryname ne $categoryname ) { - warn "skipping fee '".$part_fee->itemdesc."'--not in section $categoryname\n" if $DEBUG; - next; - } - } # otherwise include them all in the main section - # XXX what to do when sectioning by location? - + + # If _items_fee is called while building a sectioned invoice, + # - invoice_sections_method: category + # Skip fee records that do not match the section category. + # - invoice_sections_method: location + # Skip fee records always for location sections. + # The fee records will be presented in the tax/fee section instead. + if ( + exists( $options{section} ) + and + ( + ( + exists( $options{section}{category} ) + and + $part_fee->categoryname ne $options{section}{category} + ) + or + exists( $options{section}{location}) + ) + ) { + warn "skipping fee '".$part_fee->itemdesc. + "'--not in section $options{section}{category}\n" if $DEBUG; + next; + } + my @ext_desc; my %base_invnums; # invnum => invoice date foreach ($cust_bill_pkg->cust_bill_pkg_fee) { @@ -2895,13 +3198,18 @@ sub _items_fee { $self->mt('from invoice #[_1] on [_2]', $_, $base_invnums{$_}) ); } - my $desc = $part_fee->itemdesc_locale($self->cust_main->locale); + my $desc = $part_fee->itemdesc_locale($locale); # but not escape the base description line + my @pkg_tax = $cust_bill_pkg->_pkg_tax_list + if $options{section_with_taxes}; + push @items, { feepart => $cust_bill_pkg->feepart, + billpkgnum => $cust_bill_pkg->billpkgnum, amount => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur), description => $desc, + pkg_tax => \@pkg_tax, ext_description => \@ext_desc # sdate/edate? }; @@ -3000,8 +3308,7 @@ location (whichever is defined). multisection: a flag indicating that this is a multisection invoice, which does something complicated. -preref_callback: coderef run for each line item, code should return HTML to be -displayed before that line item (quotations only) +section_with_taxes: Look up and include applied taxes for each record Returns a list of hashrefs, each of which may contain: @@ -3029,12 +3336,36 @@ 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 + + my $agentnum = $self->agentnum; + + # for location labels: use default location on the invoice date + my $default_locationnum; + if ( $conf->exists('invoice-all_pkg_addresses') ) { + $default_locationnum = 0; # treat them all as non-default + } elsif ( $self->custnum ) { + my $h_cust_main; + my @h_search = FS::h_cust_main->sql_h_search($self->_date); + $h_cust_main = qsearchs({ + 'table' => 'h_cust_main', + 'hashref' => { custnum => $self->custnum }, + 'extra_sql' => $h_search[1], + 'addl_from' => $h_search[3], + }) || $cust_main; + $default_locationnum = $h_cust_main->ship_locationnum; + } elsif ( $self->prospectnum ) { + my $cust_location = qsearchs('cust_location', + { prospectnum => $self->prospectnum, + disabled => '' }); + $default_locationnum = $cust_location->locationnum if $cust_location; + } my @b = (); # accumulator for the line item hashes that we'll return my ($s, $r, $u, $d) = ( undef, undef, undef, undef ); @@ -3044,6 +3375,9 @@ sub _items_cust_bill_pkg { # if the current line item is waiting to go out, and the one we're about # to start is not bundled, then push out the current one and start a new # one. + if ( $d ) { + $d->{amount} = $d->{setup_amount} + $d->{recur_amount}; + } foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) { if ( $_ && !$cust_bill_pkg->hidden ) { $_->{amount} = sprintf( "%.2f", $_->{amount} ); @@ -3051,11 +3385,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; } @@ -3114,50 +3450,7 @@ sub _items_cust_bill_pkg { 'no_usage' => $opt{'no_usage'}, ); - if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) { - - warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n" - if $DEBUG > 1; - # 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' - if $cust_bill_pkg->recur != 0 - || $discount_show_always - || $cust_bill_pkg->recur_show_zero; - #push @b, { - # keep it consistent, please - $s = { - '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 ) - : '' - ), - }; - } - if ( $cust_bill_pkg->recur != 0 ) { - #push @b, { - $r = { - '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 ) { + if ( $cust_bill_pkg->pkgnum > 0 ) { # a "normal" package line item (not a quotation, not a fee, not a tax) warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n" @@ -3178,9 +3471,13 @@ sub _items_cust_bill_pkg { # not normally used, but pass this to the template anyway $classname = $part_pkg->classname; + my @pkg_tax = $cust_bill_pkg->_pkg_tax_list + if $self->conf->exists('invoice_sections_with_taxes'); + if ( (!$type || $type eq 'S') && ( $cust_bill_pkg->setup != 0 || $cust_bill_pkg->setup_show_zero + || ($discount_show_always and $cust_bill_pkg->unitsetup > 0) ) ) { @@ -3188,14 +3485,24 @@ 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, - $self->agentnum ) + my $disable_date_ranges = + $opt{disable_line_item_date_ranges} + || $part_pkg->option('disable_line_item_date_ranges', 1); + + $description .= $cust_bill_pkg->time_period_pretty( + $part_pkg, + $agentnum, + disable_date_ranges => $disable_date_ranges, + ) if $part_pkg->is_prepaid #for prepaid, "display the validity period # triggered by the recurring charge freq # (RT#26274) @@ -3208,8 +3515,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 @@ -3218,11 +3528,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; @@ -3240,6 +3549,7 @@ sub _items_cust_bill_pkg { push @{ $s->{ext_description} }, @d; } else { $s = { + billpkgnum => $cust_bill_pkg->billpkgnum, _is_setup => 1, description => $description, pkgpart => $pkgpart, @@ -3251,16 +3561,24 @@ sub _items_cust_bill_pkg { ext_description => \@d, svc_label => ($svc_label || ''), locationnum => $cust_pkg->locationnum, # sure, why not? + pkg_tax => \@pkg_tax, }; }; } + # should we show a recur line? + # if type eq 'S', then NO, because we've been told not to. + # otherwise, show the recur line if: + # - there's a recurring charge + # - or recur_show_zero is on + # - or there's a positive unitrecur (so it's been discounted to zero) + # and discount-show-always is on if ( ( !$type || $type eq 'R' || $type eq 'U' ) && ( $cust_bill_pkg->recur != 0 - || $cust_bill_pkg->setup == 0 - || $discount_show_always + || !defined($s) + || ($discount_show_always and $cust_bill_pkg->unitrecur > 0) || $cust_bill_pkg->recur_show_zero ) ) @@ -3276,10 +3594,15 @@ sub _items_cust_bill_pkg { $description = $self->mt('Usage charges'); } - my $part_pkg = $cust_pkg->part_pkg; + my $disable_date_ranges = + $opt{disable_line_item_date_ranges} + || $part_pkg->option('disable_line_item_date_ranges', 1); - $description .= $cust_bill_pkg->time_period_pretty( $part_pkg, - $self->agentnum ); + $description .= $cust_bill_pkg->time_period_pretty( + $part_pkg, + $agentnum, + disable_date_ranges => $disable_date_ranges, + ); my @d = (); my @seconds = (); # for display of usage info @@ -3293,7 +3616,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... @@ -3320,11 +3645,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; @@ -3393,6 +3717,7 @@ sub _items_cust_bill_pkg { push @{ $r->{ext_description} }, @d; } else { $r = { + billpkgnum => $cust_bill_pkg->billpkgnum, description => $description, pkgpart => $pkgpart, pkgnum => $cust_bill_pkg->pkgnum, @@ -3404,6 +3729,7 @@ sub _items_cust_bill_pkg { ext_description => \@d, svc_label => ($svc_label || ''), locationnum => $cust_pkg->locationnum, + pkg_tax => \@pkg_tax, }; $r->{'seconds'} = \@seconds if grep {defined $_} @seconds; } @@ -3422,6 +3748,7 @@ sub _items_cust_bill_pkg { } elsif ( $amount ) { # create a new usage line $u = { + billpkgnum => $cust_bill_pkg->billpkgnum, description => $description, pkgpart => $pkgpart, pkgnum => $cust_bill_pkg->pkgnum, @@ -3431,6 +3758,7 @@ sub _items_cust_bill_pkg { %item_dates, ext_description => \@d, locationnum => $cust_pkg->locationnum, + pkg_tax => \@pkg_tax, }; } # else this has no usage, so don't create a usage section } @@ -3449,7 +3777,7 @@ sub _items_cust_bill_pkg { + $cust_bill_pkg->recur) }; - } # if quotation / package line item / other line item + } # if package line item / other line item # decide whether to show active discounts here if ( @@ -3468,7 +3796,8 @@ sub _items_cust_bill_pkg { # $item_discount->{amount} is negative if ( $d and $cust_bill_pkg->hidden ) { - $d->{amount} += $item_discount->{amount}; + $d->{setup_amount} += $item_discount->{setup_amount}; + $d->{recur_amount} += $item_discount->{recur_amount}; } else { $d = $item_discount; $_ = &{$escape_function}($_) foreach @{ $d->{ext_description} }; @@ -3476,36 +3805,20 @@ sub _items_cust_bill_pkg { # update the active line (before the discount) to show the # original price (whether this is a hidden line or not) - # - # quotation discounts keep track of setup and recur; invoice - # discounts currently don't - if ( exists $item_discount->{setup_amount} ) { - - $s->{amount} -= $item_discount->{setup_amount} if $s; - $r->{amount} -= $item_discount->{recur_amount} if $r; - - } else { - # $active_line is 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; - } - $active_line->{amount} -= $item_discount->{amount}; - - } + $s->{amount} -= $item_discount->{setup_amount} if $s; + $r->{amount} -= $item_discount->{recur_amount} if $r; } # if there are any discounts } # if this is an appropriate place to show discounts } # foreach $display - $discount_show_always = ($cust_bill_pkg->cust_bill_pkg_discount - && $conf->exists('discount-show-always')); + } + # discount amount is internally split up + if ( $d ) { + $d->{amount} = $d->{setup_amount} + $d->{recur_amount}; } foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) { @@ -3517,11 +3830,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} ) } } @@ -3579,4 +3892,66 @@ sub _items_discounts_avail { } +=item has_sections AGENTNUM + +Return true if invoice_sections should be enabled for this bill. + (Inherited by both cust_bill and cust_bill_void) + +Determination: +* False if not an invoice +* True always if conf invoice_sections is enabled +* True always if sections_by_location is enabled +* True if conf invoice_sections_multilocation > 1, + and location_count >= invoice_sections_multilocation +* Else, False + +=cut + +sub has_sections { + my ($self, $agentnum) = @_; + + return 0 unless $self->invnum > 0; + + $agentnum ||= $self->agentnum; + return 1 if $self->conf->config_bool('invoice_sections', $agentnum); + return 1 if $self->conf->exists('sections_by_location', $agentnum); + + my $location_min = $self->conf->config( + 'invoice_sections_multilocation', $agentnum, + ); + + return 1 + if $location_min + && $self->location_count >= $location_min; + + 0; +} + + +=item location_count + +Return the number of locations billed on this invoice + +=cut + +sub location_count { + my ($self) = @_; + return 0 unless $self->invnum; + + # SELECT COUNT( DISTINCT cust_pkg.locationnum ) + # FROM cust_bill_pkg + # LEFT JOIN cust_pkg USING (pkgnum) + # WHERE invnum = 278 + # AND cust_bill_pkg.pkgnum > 0 + + my $result = qsearchs({ + select => 'COUNT(DISTINCT cust_pkg.locationnum) as location_count', + table => 'cust_bill_pkg', + addl_from => 'LEFT JOIN cust_pkg USING (pkgnum)', + extra_sql => 'WHERE invnum = '.dbh->quote( $self->invnum ) + . ' AND cust_bill_pkg.pkgnum > 0' + }); + ref $result ? $result->location_count : 0; +} + 1;