X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2FTemplate_Mixin.pm;h=62d15a3e6d92884a96915f82753e0dbcd813da7c;hp=131a23643419a7bda689f0f2918eb7a79dc13d07;hb=32a1571c24a90b89676e646f58436446df7deafb;hpb=cc3a43f7d4386297a8babebfdd49646f836db127 diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index 131a23643..62d15a3e6 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -2,18 +2,22 @@ package FS::Template_Mixin; use strict; use vars qw( $DEBUG $me - $money_char ); + $money_char + $date_format + ); # 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 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::Conf; use FS::Misc qw( generate_ps generate_pdf ); @@ -26,7 +30,8 @@ $DEBUG = 0; $me = '[FS::Template_Mixin]'; FS::UID->install_callback( sub { my $conf = new FS::Conf; #global - $money_char = $conf->config('money_char') || '$'; + $money_char = $conf->config('money_char') || '$'; + $date_format = $conf->config('date_format') || '%x'; #/YY } ); =item conf [ MODE ] @@ -142,6 +147,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'; @@ -217,26 +226,82 @@ Internal method - returns a filled-in template for this invoice as a scalar. See print_ps and print_pdf for methods that return PostScript and PDF output. -Non optional options include - format - latex, html, template +Required options + +=over 4 + +=item format + +The B option is required and should be set to html, latex (print and PDF) or template (plaintext). + +=back + +Additional options + +=over 4 -Optional options include +=item notice_name -template - a value used as a suffix for a configuration template. Please -don't use this. +Overrides "Invoice" as the name of the sent document. -time - a value used to control the printing of overdue messages. The +=item today + +Used to control the printing of overdue messages. The default is now. It isn't the date of the invoice; that's the `_date' field. It is specified as a UNIX timestamp; see L. Also see L and L for conversion functions. -cid - +=item logo_file + +Logo file (path to temporary EPS file on the local filesystem) + +=item cid + +CID for inline (emailed) images (logo) + +=item locale + +Override customer's locale + +=item unsquelch_cdr + +Overrides any per customer cdr squelching when true + +=item no_number + +Supress the (invoice, quotation, statement, etc.) number + +=item no_date + +Supress the date + +=item no_coupon + +Supress the payment coupon + +=item barcode_file + +Barcode file (path to temporary EPS file on the local filesystem) + +=item barcode_img + +Flag indicating the barcode image should be a link (normal HTML dipaly) + +=item barcode_cid + +Barcode CID for inline (emailed) images + +=item preref_callback -unsquelch_cdr - overrides any per customer cdr squelching when true +Coderef run for each line item, code should return HTML to be displayed +before that line item (quotations only) -notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required) +=item template -locale - override customer's locale +Dprecated. Used as a suffix for a configuration template. Please +don't use this, it deprecated in favor of more flexible alternatives. + +=back =cut @@ -256,9 +321,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; @@ -285,13 +347,13 @@ sub print_generic { my @invoice_template = map "$_\n", $conf->config($templatefile) or die "cannot load config data $templatefile"; - my $old_latex = ''; if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) { #change this to a die when the old code is removed - warn "old-style invoice template $templatefile; ". + # it's been almost ten years, changing it to a die + die "old-style invoice template $templatefile; ". "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n"; - $old_latex = 'true'; - @invoice_template = _translate_old_latex_format(@invoice_template); + #$old_latex = 'true'; + #@invoice_template = _translate_old_latex_format(@invoice_template); } warn "$me print_generic creating T:T object\n" @@ -485,9 +547,14 @@ sub print_generic { 'quotationnum' => $self->quotationnum, 'no_date' => $params{'no_date'}, '_date' => ( $params{'no_date'} ? '' : $self->_date ), + # workaround for inconsistent behavior in the early plain text + # templates; see RT#28271 'date' => ( $params{'no_date'} ? '' - : $self->time2str_local('long', $self->_date, $format) + : ($format eq 'template' + ? $self->_date + : $self->time2str_local('long', $self->_date, $format) + ) ), 'today' => $self->time2str_local('long', $today, $format), 'terms' => $self->terms, @@ -500,12 +567,14 @@ sub print_generic { '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'), @@ -584,16 +653,16 @@ 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 = (); $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 @@ -620,35 +689,103 @@ 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; - - #these are used on the summary page only - - # the customer's current balance as shown on the invoice before this one - $invoice_data{'true_previous_balance'} = sprintf("%.2f", ($self->previous_balance || 0) ); - - # the change in balance from that invoice to this one - $invoice_data{'balance_adjustments'} = sprintf("%.2f", ($self->previous_balance || 0) - ($self->billing_balance || 0) ); - - # the sum of amount owed on all previous invoices - # ($pr_total is used elsewhere but not as $previous_balance) - $invoice_data{'previous_balance'} = sprintf("%.2f", $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) $invoice_data{'balance'} = sprintf("%.2f", $balance_due); - # info from customer's last invoice before this one, for some - # summary formats - $invoice_data{'last_bill'} = {}; + # flag telling this invoice to have a first-page summary + my $summarypage = ''; if ( $self->custnum && $self->invnum ) { + # XXX should be an FS::cust_bill method to set the defaults, instead + # of checking the type here - if ( $self->previous_bill ) { - my $last_bill = $self->previous_bill; - $invoice_data{'last_bill'} = { - '_date' => $last_bill->_date, #unformatted - }; + # 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", + "SELECT -1 * COALESCE( SUM(amount), 0 ) FROM cust_credit", + "SELECT -1 * COALESCE( SUM(paid), 0 ) FROM cust_pay", + "SELECT COALESCE( SUM(refund), 0 ) FROM cust_refund", + ); + + # the customer's current balance immediately after generating the last + # bill + + my $last_bill_balance = $last_bill->charged; + foreach (@sql) { + my $delta = FS::Record->scalar_sql( + $_, + $last_bill->_date - 1, + $self->custnum, + ); + $last_bill_balance += $delta; + } + + $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; + + # 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, + $last_bill->_date, + $self->custnum, + ); + $adjustments += $delta; + } + $invoice_data{'balance_adjustments'} = sprintf("%.2f", $adjustments); + + 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) + $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', { @@ -682,15 +819,20 @@ sub print_generic { } $invoice_data{'previous_payments'} = \@payments; $invoice_data{'previous_credits'} = \@credits; + } else { + # there is no $last_bill + $invoice_data{'true_previous_balance'} = + $invoice_data{'balance_adjustments'} = + $invoice_data{'previous_balance'} = '0.00'; + $invoice_data{'previous_payments'} = []; + $invoice_data{'previous_credits'} = []; + } + + if ( $conf->exists('invoice_usesummary', $agentnum) ) { + $invoice_data{'summarypage'} = $summarypage = 1; } - } - - my $summarypage = ''; - if ( $conf->exists('invoice_usesummary', $agentnum) ) { - $summarypage = 1; - } - $invoice_data{'summarypage'} = $summarypage; + } # if this is an invoice warn "$me substituting variables in notes, footer, smallfooter\n" if $DEBUG > 1; @@ -699,35 +841,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', @@ -741,19 +884,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') || '$', @@ -761,6 +908,7 @@ sub print_generic { ); my $money_char = $money_chars{$format}; + # extremely dubious my %other_money_chars = ( 'latex' => '\dollar ',#XXX should be a config too 'html' => $conf->config('money_char') || '$', 'template' => '', @@ -786,31 +934,9 @@ 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) || + my $multisection = $self->has_sections; + $conf->exists($tc.'sections', $cust_main->agentnum) || $conf->exists($tc.'sections_by_location', $cust_main->agentnum); $invoice_data{'multisection'} = $multisection; my $late_sections; @@ -849,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) @@ -863,7 +1004,7 @@ sub print_generic { # we haven't yet changed the template to take advantage of that, so for # now, treat them as mutually exclusive. my %section_method = ( by_category => 1 ); - if ( $conf->exists($tc.'sections_by_location') ) { + if ( $conf->config($tc.'sections_method') eq 'location' ) { %section_method = ( by_location => 1 ); } my ($early, $late) = @@ -921,6 +1062,36 @@ sub print_generic { sprintf('%.2f', sum( @charges ) || 0); } + # 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') ) { + # then re-section them by the correct method + my %section_method = ( by_category => 1 ); + if ( $conf->config('summary_subtotals_method') eq 'location' ) { + %section_method = ( by_location => 1 ); + } + my ($early, $late) = + $self->_items_sections( 'summary' => $summarypage, + 'escape' => $escape_function_nonbsp, + 'extra_sections' => $extra_sections, + 'format' => $format, + %section_method + ); + foreach ( @$early ) { + next if $_->{subtotal} == 0; + $_->{subtotal} = $other_money_char.sprintf('%.2f', $_->{subtotal}); + push @summary_subtotals, $_; + } + } else { + # subtotal sectioning is the same as for the actual invoice sections + @summary_subtotals = @sections; + } + + # Hereafter, push sections to both @sections and @summary_subtotals + # if they belong in both places (e.g. tax section). Late sections are + # never in @summary_subtotals. + # previous invoice balances in the Previous Charges section if there # is one, otherwise in the main detail section # (except if summary_only is enabled, don't show them at all) @@ -942,8 +1113,7 @@ sub print_generic { ext_description => [ map { &$escape_function($_) } @{ $line_item->{'ext_description'} || [] } ], - amount => ( $old_latex ? '' : $money_char). - $line_item->{'amount'}, + amount => $money_char . $line_item->{'amount'}, product_code => $line_item->{'pkgpart'} || 'N/A', }; @@ -1013,45 +1183,36 @@ sub print_generic { $options{'summary_page'} = $summarypage; $options{'skip_usage'} = scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections; + $options{'preref_callback'} = $params{'preref_callback'}; warn "$me searching for line items\n" if $DEBUG > 1; - foreach my $line_item ( $self->_items_pkg(%options) ) { + foreach my $line_item ( $self->_items_pkg(%options), + $self->_items_fee(%options) ) { - warn "$me adding line item $line_item\n" + warn "$me adding line item ". + join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n" if $DEBUG > 1; - my $detail = { - ext_description => [], - }; - $detail->{'ref'} = $line_item->{'pkgnum'}; - $detail->{'pkgpart'} = $line_item->{'pkgpart'}; - $detail->{'quantity'} = $line_item->{'quantity'}; - $detail->{'section'} = $section; - $detail->{'description'} = &$escape_function($line_item->{'description'}); - if ( exists $line_item->{'ext_description'} ) { - @{$detail->{'ext_description'}} = @{$line_item->{'ext_description'}}; - } - $detail->{'amount'} = ( $old_latex ? '' : $money_char ). - $line_item->{'amount'}; - if ( exists $line_item->{'unit_amount'} ) { - $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ). - $line_item->{'unit_amount'}; - } - $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; - - $detail->{'sdate'} = $line_item->{'sdate'}; - $detail->{'edate'} = $line_item->{'edate'}; - $detail->{'seconds'} = $line_item->{'seconds'}; - $detail->{'svc_label'} = $line_item->{'svc_label'}; - - push @detail_items, $detail; - push @buf, ( [ $detail->{'description'}, + push @buf, ( [ $line_item->{'description'}, $money_char. sprintf("%10.2f", $line_item->{'amount'}), ], - map { [ " ". $_, '' ] } @{$detail->{'ext_description'}}, + map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}}, ); + + $line_item->{'ref'} = $line_item->{'pkgnum'}; + $line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()? + $line_item->{'section'} = $section; + $line_item->{'description'} = &$escape_function($line_item->{'description'}); + $line_item->{'amount'} = $money_char.$line_item->{'amount'}; + + if ( length($line_item->{'unit_amount'}) ) { + $line_item->{'unit_amount'} = $money_char.$line_item->{'unit_amount'}; + } + $line_item->{'ext_description'} ||= []; + + push @detail_items, $line_item; } if ( $section->{'description'} ) { @@ -1072,12 +1233,34 @@ sub print_generic { # if there's anything in the Previous Charges section, prepend it to the list if ( $pr_total and $previous_section ne $default_section ) { unshift @sections, $previous_section; + # but not @summary_subtotals } warn "$me adding taxes\n" 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 = + List::Util::first { $_->{description} eq $tax_description } @sections; + if (!$tax_section) { + $tax_section = { 'description' => $tax_description }; + 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. + # 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; + foreach my $tax ( @items_tax ) { $taxtotal += $tax->{'amount'}; @@ -1087,13 +1270,12 @@ sub print_generic { if ( $multisection ) { - my $money = $old_latex ? '' : $money_char; push @detail_items, { ext_description => [], ref => '', quantity => '', description => $description, - amount => $money. $amount, + amount => $money_char. $amount, product_code => '', section => $tax_section, }; @@ -1112,7 +1294,7 @@ sub print_generic { ]; } - + if ( @items_tax ) { my $total = {}; $total->{'total_item'} = $self->mt('Sub-total'); @@ -1120,27 +1302,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'}; - push @sections, $tax_section if $taxtotal; - }else{ + 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 ### @@ -1152,51 +1339,37 @@ 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 + + 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) ); - } else { - $adjust_section->{'pretotal'} = $self->mt('New charges total').' '. - $other_money_char. sprintf('%.2f', $self->charged ); - } - } else { - push @total_items, $total; - } push @buf,['','-----------']; - push @buf,[$item, - $money_char. - sprintf( '%10.2f', $amount ) - ]; - push @buf,['','']; + + foreach ( @new_total_items ) { + my ($item, $amount) = ($_->{'total_item'}, $_->{'total_amount'}); + $_->{'total_item'} = &$embolden_function( $item ); + $_->{'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, [ '', '' ]; # if we're showing previous invoices, also show previous # credits and payments @@ -1204,27 +1377,24 @@ 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 ) { - my $money = $old_latex ? '' : $money_char; push @detail_items, { ext_description => [], ref => '', quantity => '', description => &$escape_function($credit->{'description'}), - amount => $money. $credit->{'amount'}, + amount => $money_char . $credit->{'amount'}, product_code => '', section => $adjust_section, }; @@ -1251,15 +1421,13 @@ 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 ) { - my $money = $old_latex ? '' : $money_char; push @detail_items, { ext_description => [], ref => '', quantity => '', description => &$escape_function($payment->{'description'}), - amount => $money. $payment->{'amount'}, + amount => $money_char . $payment->{'amount'}, product_code => '', section => $adjust_section, }; @@ -1274,9 +1442,14 @@ 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 + # different rules } # create Balance Due message @@ -1295,7 +1468,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,['','-----------']; @@ -1355,7 +1528,7 @@ sub print_generic { 'no_subtotal' => 1, }; - push @sections, $discount_section; + push @sections, $discount_section; # do not summarize push @detail_items, map { +{ 'ref' => '', #should this be something else? 'section' => $discount_section, @@ -1365,24 +1538,50 @@ sub print_generic { } } @discounts_avail; } - my @summary_subtotals; - # the templates say "$_->{tax_section} || !$_->{summarized}" - # except 'summarized' is only true when tax_section is true, so this - # is always true, so what's the deal? - foreach my $s (@sections) { - # not to include in the "summary of new charges" block: - # finance charges, adjustments, previous charges, - # and itemized phone usage sections - if ( $s eq $adjust_section or - ($s eq $previous_section and $s ne $default_section) or - ($invoice_data{'finance_section'} and - $invoice_data{'finance_section'} eq $s->{description}) or - $s->{'description'} =~ /^\d+ $/ ) { - next; + # not adding any more sections after this + $invoice_data{summary_subtotals} = \@summary_subtotals; + + # 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, 'money_char' => $other_money_char); + if ( @usage_subtotals ) { + unshift @sections, $usage_subtotals[0]->{section}; # do not summarize + unshift @detail_items, @usage_subtotals; } - push @summary_subtotals, $s; } - $invoice_data{summary_subtotals} = \@summary_subtotals; + + # invoice history "section" (not really a section) + # not to be included in any subtotals, completely independent of + # everything... + 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 ) { + # XXX hardcoded format, and currently only 'charged'; add other fields + # if they become necessary + my $date = $self->time2str_local('%b %Y', $cust_bill->_date); + $history{$date} ||= 0; + $history{$date} += $cust_bill->charged; + # just so we have a numeric sort key + $monthorder{$date} ||= $cust_bill->_date; + } + my @sorted_months = sort { $monthorder{$a} <=> $monthorder{$b} } + keys %history; + my @sorted_amounts = map { sprintf('%.2f', $history{$_}) } @sorted_months; + $invoice_data{monthly_history} = [ \@sorted_months, \@sorted_amounts ]; + } + + # service locations: another option for template customization + my %location_info; + foreach my $item (@detail_items) { + if ( $item->{locationnum} ) { + $location_info{ $item->{locationnum} } ||= { + FS::cust_location->by_key( $item->{locationnum} )->location_hash + }; + } + } + $invoice_data{location_info} = \%location_info; # debugging hook: call this with 'diag' => 1 to just get a hash of # the invoice variables @@ -1473,6 +1672,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" @@ -1484,7 +1690,10 @@ sub print_generic { sub notice_name { '('.shift->table.')'; } -sub template_conf { 'invoice_'; } +# this is not supposed to happen +sub template_conf { warn "bare FS::Template_Mixin::template_conf"; + 'invoice_'; +} # helper routine for generating date ranges sub _prior_month30s { @@ -1680,6 +1889,10 @@ sub _translate_old_latex_format { (@template); } +=item terms + +=cut + sub terms { my $self = shift; my $conf = $self->conf; @@ -1691,10 +1904,21 @@ sub terms { my $cust_main = $self->cust_main; return $cust_main->invoice_terms if $cust_main && $cust_main->invoice_terms; + my $agentnum = ''; + if ( $cust_main ) { + $agentnum = $cust_main->agentnum; + } elsif ( my $prospect_main = $self->prospect_main ) { + $agentnum = $prospect_main->agentnum; + } + #use configured default - $conf->config('invoice_default_terms') || ''; + $conf->config('invoice_default_terms', $agentnum) || ''; } +=item due_date + +=cut + sub due_date { my $self = shift; my $duedate = ''; @@ -1704,30 +1928,49 @@ sub due_date { $duedate; } +=item due_date2str + +=cut + sub due_date2str { my $self = shift; $self->due_date ? $self->time2str_local(shift, $self->due_date) : ''; } +=item balance_due_msg + +=cut + sub balance_due_msg { my $self = shift; my $msg = $self->mt('Balance Due'); - 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 ) { + $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; } +=item balance_due_date + +=cut + sub balance_due_date { my $self = shift; my $conf = $self->conf; my $duedate = ''; - if ( $conf->exists('invoice_default_terms') - && $conf->config('invoice_default_terms')=~ /^\s*Net\s*(\d+)\s*$/ ) { + my $terms = $self->terms; + if ( $terms =~ /^\s*Net\s*(\d+)\s*$/ ) { $duedate = $self->time2str_local('rdate', $self->_date + ($1*86400) ); } $duedate; @@ -1740,7 +1983,8 @@ sub credit_balance_msg { =item _date_pretty -Returns a string with the date, for example: "3/20/2008" +Returns a string with the date, for example: "3/20/2008", localized for the +customer. Use _date_pretty_unlocalized for non-end-customer display use. =cut @@ -1749,6 +1993,484 @@ sub _date_pretty { $self->time2str_local('short', $self->_date); } +=item _date_pretty_unlocalized + +Returns a string with the date, for example: "3/20/2008", in the format +configured for the back-office. Use _date_pretty for end-customer display use. + +=cut + +sub _date_pretty_unlocalized { + my $self = shift; + time2str($date_format, $self->_date); +} + +=item email HASHREF + +Emails this template. + +Options are passed as a hashref. Available options: + +=over 4 + +=item from + +If specified, overrides the default From: address. + +=item notice_name + +If specified, overrides the name of the sent document ("Invoice" or "Quotation") + +=item template + +(Deprecated) If specified, is the name of a suffix for alternate template files. + +=back + +Options accepted by generate_email can also be used. + +=cut + +sub email { + my $self = shift; + my $opt = shift || {}; + if ($opt and !ref($opt)) { + die ref($self). '->email called with positional parameters'; + } + + return if $self->hide; + + my $error = send_email( + $self->generate_email( + 'subject' => $self->email_subject($opt->{template}), + %$opt, # template, etc. + ) + ); + + die "can't email: $error\n" if $error; +} + +=item generate_email OPTION => VALUE ... + +Options: + +=over 4 + +=item from + +sender address, required + +=item template + +alternate template name, optional + +=item subject + +email subject, optional + +=item notice_name + +notice name instead of "Invoice", optional + +=back + +Returns an argument list to be passed to L. + +=cut + +use MIME::Entity; + +sub generate_email { + + my $self = shift; + my %args = @_; + my $conf = $self->conf; + + my $me = '[FS::Template_Mixin::generate_email]'; + + my %return = ( + 'from' => $args{'from'}, + 'subject' => ($args{'subject'} || $self->email_subject), + 'custnum' => $self->custnum, + 'msgtype' => 'invoice', + ); + + $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email'); + + my $cust_main = $self->cust_main; + + if (ref($args{'to'}) eq 'ARRAY') { + $return{'to'} = $args{'to'}; + } elsif ( $cust_main ) { + $return{'to'} = [ $cust_main->invoicing_list_emailonly ]; + } + + my $tc = $self->template_conf; + + 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 + + if ( $conf->exists($tc. 'email_pdf') ) { + if ( my $msgnum = $conf->config($tc.'email_pdf_msgnum') ) { + + warn "$me using '${tc}email_pdf_msgnum' in multipart message" + if $DEBUG; + + 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; + } + } + + } elsif ( my @note = $conf->config($tc.'email_pdf_note') ) { + + warn "$me using '${tc}email_pdf_note' in multipart message" + if $DEBUG; + @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 = $self->print_text(\%args); + + } else { + + warn "$me no plain text version exists; sending empty message body" + if $DEBUG; + + } + + } + + my $text_part = build MIME::Entity ( + 'Type' => 'text/plain', + 'Encoding' => 'quoted-printable', + 'Charset' => 'UTF-8', + #'Encoding' => '7bit', + 'Data' => \@text, + 'Disposition' => 'inline', + ); + + if (!$html) { + + if ( $conf->exists($tc.'html') ) { + warn "$me generating HTML invoice" + if $DEBUG; + + $args{'from'} =~ /\@([\w\.\-]+)/; + my $from = $1 || 'example.com'; + my $content_id = join('.', rand()*(2**32), $$, time). "\@$from"; + + my $logo; + my $agentnum = $cust_main ? $cust_main->agentnum + : $self->prospect_main->agentnum; + if ( defined($args{'template'}) && length($args{'template'}) + && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum ) + ) + { + $logo = 'logo_'. $args{'template'}. '.png'; + } else { + $logo = "logo.png"; + } + my $image_data = $conf->config_binary( $logo, $agentnum); + + push @related_parts, build MIME::Entity + 'Type' => 'image/png', + 'Encoding' => 'base64', + 'Data' => $image_data, + 'Filename' => 'logo.png', + 'Content-ID' => "<$content_id>", + ; + + if ( ref($self) eq 'FS::cust_bill' && $conf->exists('invoice-barcode') ) { + my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from"; + push @related_parts, build MIME::Entity + 'Type' => 'image/png', + 'Encoding' => 'base64', + 'Data' => $self->invoice_barcode(0), + 'Filename' => 'barcode.png', + 'Content-ID' => "<$barcode_content_id>", + ; + $args{'barcode_cid'} = $barcode_content_id; + } + + $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( + 'Type' => 'text/html', + 'Encoding' => 'quoted-printable', + 'Data' => [ '', + ' ', + ' ', + ' '. encode_entities($return{'subject'}), + ' ', + ' ', + ' ', + $html, + ' ', + '', + ], + 'Disposition' => 'inline', + #'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; + + 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', + 'Data' => [ map { "$_\n" } + $self->call_details('prepend_billed_number' => 1) + ], + 'Disposition' => 'attachment', + 'Filename' => 'usage-'. $self->invnum. '.csv', + ; + + } + + } + + if ( $conf->exists($tc.'email_pdf') ) { + + #attaching pdf too: + # multipart/mixed + # multipart/related + # multipart/alternative + # text/plain + # text/html + # image/png + # application/pdf + + my $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 { + $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; + +} + +=item mimebuild_pdf + +Returns a list suitable for passing to MIME::Entity->build(), representing +this invoice as PDF attachment. + +=cut + +sub mimebuild_pdf { + my $self = shift; + ( + 'Type' => 'application/pdf', + 'Encoding' => 'base64', + 'Data' => [ $self->print_pdf(@_) ], + 'Disposition' => 'attachment', + 'Filename' => 'invoice-'. $self->invnum. '.pdf', + ); +} + +=item 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, + } + ); + 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. "\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. @@ -1860,10 +2582,13 @@ sub _items_sections { my $section = $display->section; my $type = $display->type; - $section = undef unless $opt{by_category}; + # Set $section = undef if we're sectioning by location and this + # line item _has_ a location (i.e. isn't a fee). + $section = undef if $locationnum; + # set this flag if the section is not tax-only $not_tax{$locationnum}{$section} = 1 - unless $cust_bill_pkg->pkgnum == 0; + if $cust_bill_pkg->pkgnum or $cust_bill_pkg->feepart; # there's actually a very important piece of logic buried in here: # incrementing $late_subtotal{$section} CREATES @@ -1899,29 +2624,24 @@ sub _items_sections { } else { # it's a pre-total (normal) section # skip tax items unless they're explicitly included in a section - next if $cust_bill_pkg->pkgnum == 0 && ! $section; + next if $cust_bill_pkg->pkgnum == 0 and + ! $cust_bill_pkg->feepart and + ! $section; - if (! $type || $type eq 'S') { + if ( $type eq 'S' ) { $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup if $cust_bill_pkg->setup != 0 || $cust_bill_pkg->setup_show_zero; - } - - if (! $type) { - $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - if $cust_bill_pkg->recur != 0 - || $cust_bill_pkg->recur_show_zero; - } - - if ($type && $type eq 'R') { + } elsif ( $type eq 'R' ) { $subtotal{$locationnum}{$section} += $cust_bill_pkg->recur - $usage if $cust_bill_pkg->recur != 0 || $cust_bill_pkg->recur_show_zero; - } - - if ($type && $type eq 'U') { + } elsif ( $type eq 'U' ) { $subtotal{$locationnum}{$section} += $usage unless scalar(@extra_sections); + } elsif ( !$type ) { + $subtotal{$locationnum}{$section} += $cust_bill_pkg->setup + + $cust_bill_pkg->recur; } } @@ -1951,7 +2671,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 ) { @@ -1967,15 +2686,16 @@ sub _items_sections { $section->{'sort_weight'} = sprintf('%012s',$location->zip) . $locationnum; $section->{'location'} = { + label_prefix => &{ $escape }($location->label_prefix), map { $_ => &{ $escape }($location->get($_)) } - $location->fields + $location->fields }; } else { $section->{'category'} = $sectionname; $section->{'description'} = &{ $escape }($sectionname); - if ( _pkg_category($_) ) { - $section->{'sort_weight'} = _pkg_category($_)->weight; - if ( _pkg_category($_)->condense ) { + if ( _pkg_category($sectionname) ) { + $section->{'sort_weight'} = _pkg_category($sectionname)->weight; + if ( _pkg_category($sectionname)->condense ) { $section = { %$section, $self->_condense_section($opt{format}) }; } } @@ -2242,17 +2962,101 @@ 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 sub _items_nontax { my $self = shift; - grep { $_->pkgnum } $self->cust_bill_pkg; + # The order of these is important. Bundled line items will be merged into + # the most recent non-hidden item, so it needs to be the one with: + # - the same pkgnum + # - the same start date + # - no pkgpart_override + # + # So: sort by pkgnum, + # then by sdate + # then sort the base line item before any overrides + # then sort hidden before non-hidden add-ons + # then sort by override pkgpart (for consistency) + sort { $a->pkgnum <=> $b->pkgnum or + $a->sdate <=> $b->sdate or + ($a->pkgpart_override ? 0 : -1) or + ($b->pkgpart_override ? 0 : 1) or + $b->hidden cmp $a->hidden or + $a->pkgpart_override <=> $b->pkgpart_override + } + # and of course exclude taxes and fees + grep { $_->pkgnum > 0 } $self->cust_bill_pkg; +} + +sub _items_fee { + my $self = shift; + my %options = @_; + my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg; + my $escape_function = $options{escape_function}; + + my @items; + foreach my $cust_bill_pkg (@cust_bill_pkg) { + # cache this, so we don't look it up again in every section + my $part_fee = $cust_bill_pkg->get('part_fee') + || $cust_bill_pkg->part_fee; + $cust_bill_pkg->set('part_fee', $part_fee); + if (!$part_fee) { + #die "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n"; # might make more sense + 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? + + my @ext_desc; + my %base_invnums; # invnum => invoice date + foreach ($cust_bill_pkg->cust_bill_pkg_fee) { + if ($_->base_invnum) { + my $base_bill = FS::cust_bill->by_key($_->base_invnum); + my $base_date = $self->time2str_local('short', $base_bill->_date) + if $base_bill; + $base_invnums{$_->base_invnum} = $base_date || ''; + } + } + 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); + # but not escape the base description line + + push @items, + { feepart => $cust_bill_pkg->feepart, + amount => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur), + description => $desc, + ext_description => \@ext_desc + # sdate/edate? + }; + } + @items; } sub _items_pkg { @@ -2310,7 +3114,8 @@ sub _taxsort { sub _items_tax { my $self = shift; - my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum } $self->cust_bill_pkg; + my @cust_bill_pkg = sort _taxsort grep { ! $_->pkgnum and ! $_->feepart } + $self->cust_bill_pkg; my @items = $self->_items_cust_bill_pkg(\@cust_bill_pkg, @_); if ( $self->conf->exists('always_show_tax') ) { @@ -2371,28 +3176,60 @@ 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 @b = (); - my ($s, $r, $u) = ( undef, undef, undef ); + # 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 ); + # the 'current' line item hashes for setup, recur, usage, discount foreach my $cust_bill_pkg ( @$cust_bill_pkgs ) { - - foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) ) { + # if the current line item is waiting to go out, and the one we're about + # to start is not bundled, then push out the current one and start a new + # one. + 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} ), + $_->{amount} = sprintf( "%.2f", $_->{amount} ); $_->{amount} =~ s/^\-0\.00$/0.00/; - $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ), - push @b, { %$_ } - if $_->{amount} != 0 - || $discount_show_always - || ( ! $_->{_is_setup} && $_->{recur_show_zero} ) - || ( $_->{_is_setup} && $_->{setup_show_zero} ) + if (exists($_->{unit_amount})) { + $_->{unit_amount} = sprintf( "%.2f", $_->{unit_amount} ); + } + 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; } @@ -2401,7 +3238,7 @@ sub _items_cust_bill_pkg { if ( $locationnum ) { # this is a location section; skip packages that aren't at this # service location. - next if $cust_bill_pkg->pkgnum == 0; + next if $cust_bill_pkg->pkgnum == 0; # skips fees... next if $self->cust_pkg_hash->{ $cust_bill_pkg->pkgnum }->locationnum != $locationnum; } @@ -2430,6 +3267,9 @@ sub _items_cust_bill_pkg { @cust_bill_pkg_display = grep { !$_->summary } @cust_bill_pkg_display; } + + my $classname = ''; # package class name, will fill in later + foreach my $display (@cust_bill_pkg_display) { warn "$me _items_cust_bill_pkg considering cust_bill_pkg_display ". @@ -2448,32 +3288,8 @@ 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 - - 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, { - 'description' => $description, - 'amount' => sprintf("%.2f", $cust_bill_pkg->setup), - }; - } - if ( $cust_bill_pkg->recur != 0 ) { - push @b, { - 'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")", - 'amount' => sprintf("%.2f", $cust_bill_pkg->recur), - }; - } - - } elsif ( $cust_bill_pkg->pkgnum > 0 ) { # and it's not a quotation_pkg + 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" if $DEBUG > 1; @@ -2490,9 +3306,13 @@ sub _items_cust_bill_pkg { %item_dates = map { $_ => $cust_bill_pkg->$_ } ('sdate', 'edate') unless $part_pkg->option('disable_line_item_date_ranges',1); + # not normally used, but pass this to the template anyway + $classname = $part_pkg->classname; + 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) ) ) { @@ -2500,10 +3320,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, @@ -2515,22 +3338,33 @@ sub _items_cust_bill_pkg { && ! $cust_bill_pkg->recur_show_zero; my @d = (); - my $svc_label; - unless ( $cust_pkg->part_pkg->hide_svc_detail + my @svc_labels = (); + my $svc_label = ''; + + unless ( $part_pkg->hide_svc_detail ) { + + # still pass the svc_label through to the template, even if + # not displaying it as an ext_description + @svc_labels = map &{$escape_function}($_), + $cust_pkg->h_labels_short($self->_date, + undef, + 'I', + $self->conf->{locale}, + ); + $svc_label = $svc_labels[0]; + + } + + unless ( $part_pkg->hide_svc_detail || $cust_bill_pkg->hidden ) { - my @svc_labels = map &{$escape_function}($_), - $cust_pkg->h_labels_short($self->_date, undef, 'I'); push @d, @svc_labels unless $cust_bill_pkg->pkgpart_override; #don't redisplay services - $svc_label = $svc_labels[0]; - - 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; @@ -2558,16 +3392,24 @@ sub _items_cust_bill_pkg { quantity => $cust_bill_pkg->quantity, ext_description => \@d, svc_label => ($svc_label || ''), + locationnum => $cust_pkg->locationnum, # sure, why not? }; }; } + # 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 ) ) @@ -2590,6 +3432,7 @@ sub _items_cust_bill_pkg { my @d = (); my @seconds = (); # for display of usage info + my @svc_labels = (); my $svc_label = ''; #at least until cust_bill_pkg has "past" ranges in addition to @@ -2599,6 +3442,14 @@ sub _items_cust_bill_pkg { push @dates, $prev->sdate if $prev; push @dates, undef if !$prev; + unless ( $part_pkg->hide_svc_detail ) { + @svc_labels = map &{$escape_function}($_), + $cust_pkg->h_labels_short(@dates, + 'I', + $self->conf->{locale}); + $svc_label = $svc_labels[0]; + } + # show service labels, unless... # the package is set not to display them unless ( $part_pkg->hide_svc_detail @@ -2618,20 +3469,15 @@ sub _items_cust_bill_pkg { warn "$me _items_cust_bill_pkg adding service details\n" if $DEBUG > 1; - my @svc_labels = map &{$escape_function}($_), - $cust_pkg->h_labels_short(@dates, 'I'); push @d, @svc_labels unless $cust_bill_pkg->pkgpart_override; #don't redisplay services - $svc_label = $svc_labels[0]; - 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; @@ -2710,6 +3556,7 @@ sub _items_cust_bill_pkg { %item_dates, ext_description => \@d, svc_label => ($svc_label || ''), + locationnum => $cust_pkg->locationnum, }; $r->{'seconds'} = \@seconds if grep {defined $_} @seconds; } @@ -2732,9 +3579,11 @@ sub _items_cust_bill_pkg { pkgpart => $pkgpart, pkgnum => $cust_bill_pkg->pkgnum, amount => $amount, + usage_item => 1, recur_show_zero => $cust_bill_pkg->recur_show_zero, %item_dates, ext_description => \@d, + locationnum => $cust_pkg->locationnum, }; } # else this has no usage, so don't create a usage section } @@ -2753,28 +3602,64 @@ 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 ( + # case 1: we are showing a single line for the package + ( !$type ) + # case 2: we are showing a setup line for a package that has + # no base recurring fee + or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 ) + # case 3: we are showing a recur line for a package that has + # a base recurring fee + or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 ) + ) { + + my $item_discount = $cust_bill_pkg->_item_discount; + if ( $item_discount ) { + # $item_discount->{amount} is negative + + if ( $d and $cust_bill_pkg->hidden ) { + $d->{setup_amount} += $item_discount->{setup_amount}; + $d->{recur_amount} += $item_discount->{recur_amount}; + } else { + $d = $item_discount; + $_ = &{$escape_function}($_) foreach @{ $d->{ext_description} }; + } + + # update the active line (before the discount) to show the + # original price (whether this is a hidden line or not) + + $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 ) ) { + foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) { if ( $_ ) { $_->{amount} = sprintf( "%.2f", $_->{amount} ), if exists($_->{amount}); $_->{amount} =~ s/^\-0\.00$/0.00/; - $_->{unit_amount} = sprintf('%.2f', $_->{unit_amount}) - if exists($_->{unit_amount}); - - push @b, { %$_ } - if $_->{amount} != 0 - || $discount_show_always - || ( ! $_->{_is_setup} && $_->{recur_show_zero} ) - || ( $_->{_is_setup} && $_->{setup_show_zero} ) + 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} ) } }