make non-package fees appear in invoice spools, #29824, #25899
[freeside.git] / FS / FS / Template_Mixin.pm
index f67de76..d8e46c5 100644 (file)
@@ -16,6 +16,7 @@ use HTML::Entities;
 use Locale::Country;
 use Cwd;
 use FS::UID;
+use FS::Misc qw( send_email );
 use FS::Record qw( qsearch qsearchs );
 use FS::Conf;
 use FS::Misc qw( generate_ps generate_pdf );
@@ -346,7 +347,7 @@ sub print_generic {
 
   if ( $format eq 'latex' && grep { /^%%Detail/ } @invoice_template ) {
     #change this to a die when the old code is removed
-    # it's been almost ten years, changing it to a die.
+    # it's been almost ten years, changing it to a die
     die "old-style invoice template $templatefile; ".
          "patch with conf/invoice_latex.diff or use new conf/invoice_latex*\n";
          #$old_latex = 'true';
@@ -690,11 +691,12 @@ sub print_generic {
   # (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
 
     my $last_bill = $self->previous_bill;
     if ( $last_bill ) {
@@ -800,13 +802,16 @@ sub print_generic {
       $invoice_data{'previous_payments'} = [];
       $invoice_data{'previous_credits'} = [];
     }
-  } # if this is an invoice
 
-  my $summarypage = '';
-  if ( $conf->exists('invoice_usesummary', $agentnum) ) {
-    $summarypage = 1;
-  }
-  $invoice_data{'summarypage'} = $summarypage;
+    # info from customer's last invoice before this one, for some 
+    # summary formats
+    $invoice_data{'last_bill'} = {};
+  
+    if ( $conf->exists('invoice_usesummary', $agentnum) ) {
+      $invoice_data{'summarypage'} = $summarypage = 1;
+    }
+
+  } # if this is an invoice
 
   warn "$me substituting variables in notes, footer, smallfooter\n"
     if $DEBUG > 1;
@@ -1171,6 +1176,12 @@ sub print_generic {
            join(', ', map "$_=>".$line_item->{$_}, keys %$line_item). "\n"
         if $DEBUG > 1;
 
+      push @buf, ( [ $line_item->{'description'},
+                     $money_char. sprintf("%10.2f", $line_item->{'amount'}),
+                   ],
+                   map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
+                 );
+
       $line_item->{'ref'} = $line_item->{'pkgnum'};
       $line_item->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; # mt()?
       $line_item->{'section'} = $section;
@@ -1183,11 +1194,6 @@ sub print_generic {
       $line_item->{'ext_description'} ||= [];
  
       push @detail_items, $line_item;
-      push @buf, ( [ $line_item->{'description'},
-                     $money_char. sprintf("%10.2f", $line_item->{'amount'}),
-                   ],
-                   map { [ " ". $_, '' ] } @{$line_item->{'ext_description'}},
-                 );
     }
 
     if ( $section->{'description'} ) {
@@ -1845,6 +1851,10 @@ sub _translate_old_latex_format {
   (@template);
 }
 
+=item terms
+
+=cut
+
 sub terms {
   my $self = shift;
   my $conf = $self->conf;
@@ -1867,6 +1877,10 @@ sub terms {
   $conf->config('invoice_default_terms', $agentnum) || '';
 }
 
+=item due_date
+
+=cut
+
 sub due_date {
   my $self = shift;
   my $duedate = '';
@@ -1876,11 +1890,19 @@ sub due_date {
   $duedate;
 }
 
+=item due_date2str
+
+=cut
+
 sub due_date2str {
   my $self = shift;
   $self->due_date ? $self->time2str_local(shift, $self->due_date) : '';
 }
 
+=item balance_due_msg
+
+=cut
+
 sub balance_due_msg {
   my $self = shift;
   my $msg = $self->mt('Balance Due');
@@ -1894,6 +1916,10 @@ sub balance_due_msg {
   $msg;
 }
 
+=item balance_due_date
+
+=cut
+
 sub balance_due_date {
   my $self = shift;
   my $conf = $self->conf;
@@ -1934,6 +1960,348 @@ sub _date_pretty_unlocalized {
   time2str($date_format, $self->_date);
 }
 
+=item email HASHREF
+
+Emails this template.
+
+Options are passed as a hashref.  Available options:
+
+=over 4
+
+=item from
+
+If specified, overrides the default From: address.
+
+=item notice_name
+
+If specified, overrides the name of the sent document ("Invoice" or "Quotation")
+
+=item template
+
+(Deprecated) If specified, is the name of a suffix for alternate template files.
+
+=back
+
+Options accepted by generate_email can also be used.
+
+=cut
+
+sub email {
+  my $self = shift;
+  my $opt = shift || {};
+  if ($opt and !ref($opt)) {
+    die ref($self). '->email called with positional parameters';
+  }
+
+  return if $self->hide;
+
+  my $error = send_email(
+    $self->generate_email(
+      'subject'     => $self->email_subject($opt->{template}),
+      %$opt, # template, etc.
+    )
+  );
+
+  die "can't email: $error\n" if $error;
+}
+
+=item generate_email OPTION => VALUE ...
+
+Options:
+
+=over 4
+
+=item from
+
+sender address, required
+
+=item template
+
+alternate template name, optional
+
+=item print_text
+
+text attachment arrayref, optional
+
+=item subject
+
+email subject, optional
+
+=item notice_name
+
+notice name instead of "Invoice", optional
+
+=back
+
+Returns an argument list to be passed to L<FS::Misc::send_email>.
+
+=cut
+
+use MIME::Entity;
+
+sub generate_email {
+
+  my $self = shift;
+  my %args = @_;
+  my $conf = $self->conf;
+
+  my $me = '[FS::Template_Mixin::generate_email]';
+
+  my %return = (
+    'from'      => $args{'from'},
+    'subject'   => ($args{'subject'} || $self->email_subject),
+    'custnum'   => $self->custnum,
+    'msgtype'   => 'invoice',
+  );
+
+  $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email');
+
+  my $cust_main = $self->cust_main;
+
+  if (ref($args{'to'}) eq 'ARRAY') {
+    $return{'to'} = $args{'to'};
+  } elsif ( $cust_main ) {
+    $return{'to'} = [ $cust_main->invoicing_list_emailonly ];
+  }
+
+  my $tc = $self->template_conf;
+
+  if ( $conf->exists($tc.'html') ) {
+
+    warn "$me creating HTML/text multipart message"
+      if $DEBUG;
+
+    $return{'nobody'} = 1;
+
+    my $alternative = build MIME::Entity
+      'Type'        => 'multipart/alternative',
+      #'Encoding'    => '7bit',
+      'Disposition' => 'inline'
+    ;
+
+    my $data = '';
+    if ( $conf->exists($tc. 'email_pdf')
+         and scalar($conf->config($tc. 'email_pdf_note')) ) {
+
+      warn "$me using '${tc}email_pdf_note' in multipart message"
+        if $DEBUG;
+      $data = [ map { $_ . "\n" }
+                    $conf->config($tc.'email_pdf_note')
+              ];
+
+    } else {
+
+      warn "$me not using '${tc}email_pdf_note' in multipart message"
+        if $DEBUG;
+      if ( ref($args{'print_text'}) eq 'ARRAY' ) {
+        $data = $args{'print_text'};
+      } elsif ( $conf->exists($tc.'template') ) { #plaintext invoice_template
+        $data = [ $self->print_text(\%args) ];
+      }
+
+    }
+
+    if ( $data ) {
+      $alternative->attach(
+        'Type'        => 'text/plain',
+        'Encoding'    => 'quoted-printable',
+        'Charset'     => 'UTF-8',
+        #'Encoding'    => '7bit',
+        'Data'        => $data,
+        'Disposition' => 'inline',
+      );
+    }
+
+    my $htmldata;
+    my $image = '';
+    my $barcode = '';
+    if ( $conf->exists($tc.'email_pdf')
+         and scalar($conf->config($tc.'email_pdf_note')) ) {
+
+      $htmldata = join('<BR>', $conf->config($tc.'email_pdf_note') );
+
+    } else {
+
+      $args{'from'} =~ /\@([\w\.\-]+)/;
+      my $from = $1 || 'example.com';
+      my $content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+
+      my $logo;
+      my $agentnum = $cust_main ? $cust_main->agentnum
+                                : $self->prospect_main->agentnum;
+      if ( defined($args{'template'}) && length($args{'template'})
+           && $conf->exists( 'logo_'. $args{'template'}. '.png', $agentnum )
+         )
+      {
+        $logo = 'logo_'. $args{'template'}. '.png';
+      } else {
+        $logo = "logo.png";
+      }
+      my $image_data = $conf->config_binary( $logo, $agentnum);
+
+      $image = build MIME::Entity
+        'Type'       => 'image/png',
+        'Encoding'   => 'base64',
+        'Data'       => $image_data,
+        'Filename'   => 'logo.png',
+        'Content-ID' => "<$content_id>",
+      ;
+   
+      if ( ref($self) eq 'FS::cust_bill' && $conf->exists('invoice-barcode') ) {
+        my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
+        $barcode = build MIME::Entity
+          'Type'       => 'image/png',
+          'Encoding'   => 'base64',
+          'Data'       => $self->invoice_barcode(0),
+          'Filename'   => 'barcode.png',
+          'Content-ID' => "<$barcode_content_id>",
+        ;
+        $args{'barcode_cid'} = $barcode_content_id;
+      }
+
+      $htmldata = $self->print_html({ 'cid'=>$content_id, %args });
+    }
+
+    $alternative->attach(
+      'Type'        => 'text/html',
+      'Encoding'    => 'quoted-printable',
+      'Data'        => [ '<html>',
+                         '  <head>',
+                         '    <title>',
+                         '      '. encode_entities($return{'subject'}), 
+                         '    </title>',
+                         '  </head>',
+                         '  <body bgcolor="#e8e8e8">',
+                         $htmldata,
+                         '  </body>',
+                         '</html>',
+                       ],
+      'Disposition' => 'inline',
+      #'Filename'    => 'invoice.pdf',
+    );
+
+
+    my @otherparts = ();
+    if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
+
+      push @otherparts, build MIME::Entity
+        'Type'        => 'text/csv',
+        'Encoding'    => '7bit',
+        'Data'        => [ map { "$_\n" }
+                             $self->call_details('prepend_billed_number' => 1)
+                         ],
+        'Disposition' => 'attachment',
+        'Filename'    => 'usage-'. $self->invnum. '.csv',
+      ;
+
+    }
+
+    if ( $conf->exists($tc.'email_pdf') ) {
+
+      #attaching pdf too:
+      # multipart/mixed
+      #   multipart/related
+      #     multipart/alternative
+      #       text/plain
+      #       text/html
+      #     image/png
+      #   application/pdf
+
+      my $related = build MIME::Entity 'Type'     => 'multipart/related',
+                                       'Encoding' => '7bit';
+
+      #false laziness w/Misc::send_email
+      $related->head->replace('Content-type',
+        $related->mime_type.
+        '; boundary="'. $related->head->multipart_boundary. '"'.
+        '; type=multipart/alternative'
+      );
+
+      $related->add_part($alternative);
+
+      $related->add_part($image) if $image;
+
+      my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
+
+      $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
+
+    } else {
+
+      #no other attachment:
+      # multipart/related
+      #   multipart/alternative
+      #     text/plain
+      #     text/html
+      #   image/png
+
+      $return{'content-type'} = 'multipart/related';
+      if ($conf->exists('invoice-barcode') && $barcode) {
+        $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
+      } else {
+        $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
+      }
+      $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
+      #$return{'disposition'} = 'inline';
+
+    }
+  
+  } else {
+
+    if ( $conf->exists($tc.'email_pdf') ) {
+      warn "$me creating PDF attachment"
+        if $DEBUG;
+
+      #mime parts arguments a la MIME::Entity->build().
+      $return{'mimeparts'} = [
+        { $self->mimebuild_pdf(\%args) }
+      ];
+    }
+  
+    if ( $conf->exists($tc.'email_pdf')
+         and scalar($conf->config($tc.'email_pdf_note')) ) {
+
+      warn "$me using '${tc}email_pdf_note'"
+        if $DEBUG;
+      $return{'body'} = [ map { $_ . "\n" }
+                              $conf->config($tc.'email_pdf_note')
+                        ];
+
+    } else {
+
+      warn "$me not using '${tc}email_pdf_note'"
+        if $DEBUG;
+      if ( ref($args{'print_text'}) eq 'ARRAY' ) {
+        $return{'body'} = $args{'print_text'};
+      } else {
+        $return{'body'} = [ $self->print_text(\%args) ];
+      }
+
+    }
+
+  }
+
+  %return;
+
+}
+
+=item mimebuild_pdf
+
+Returns a list suitable for passing to MIME::Entity->build(), representing
+this invoice as PDF attachment.
+
+=cut
+
+sub mimebuild_pdf {
+  my $self = shift;
+  (
+    'Type'        => 'application/pdf',
+    'Encoding'    => 'base64',
+    'Data'        => [ $self->print_pdf(@_) ],
+    'Disposition' => 'attachment',
+    'Filename'    => 'invoice-'. $self->invnum. '.pdf',
+  );
+}
+
 =item _items_sections OPTIONS
 
 Generate section information for all items appearing on this invoice.
@@ -2462,6 +2830,8 @@ 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
@@ -2497,12 +2867,17 @@ sub _items_fee {
     foreach (sort keys(%base_invnums)) {
       next if $_ == $self->invnum;
       push @ext_desc,
-        $self->mt('from invoice \\#[_1] on [_2]', $_, $base_invnums{$_});
+        &{$escape_function}(
+          $self->mt('from invoice #[_1] on [_2]', $_, $base_invnums{$_})
+        );
     }
+    my $desc = $part_fee->itemdesc_locale($self->cust_main->locale);
+    $desc = &{$escape_function}($desc);
+
     push @items,
       { feepart     => $cust_bill_pkg->feepart,
         amount      => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
-        description => $part_fee->itemdesc_locale($self->cust_main->locale),
+        description => $desc,
         ext_description => \@ext_desc
         # sdate/edate?
       };
@@ -2638,14 +3013,14 @@ sub _items_cust_bill_pkg {
                                    # and location labels
 
   my @b = (); # accumulator for the line item hashes that we'll return
-  my ($s, $r, $u, $d) = ( undef, undef, undef );
+  my ($s, $r, $u, $d) = ( undef, undef, undef, undef );
             # the 'current' line item hashes for setup, recur, usage, discount
   foreach my $cust_bill_pkg ( @$cust_bill_pkgs )
   {
     # if the current line item is waiting to go out, and the one we're about
     # to start is not bundled, then push out the current one and start a new
     # one.
-    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ) , $d ) {
+    foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
       if ( $_ && !$cust_bill_pkg->hidden ) {
         $_->{amount}      = sprintf( "%.2f", $_->{amount} );
         $_->{amount}      =~ s/^\-0\.00$/0.00/;
@@ -2729,7 +3104,9 @@ sub _items_cust_bill_pkg {
             if $cust_bill_pkg->recur != 0
             || $discount_show_always
             || $cust_bill_pkg->recur_show_zero;
-          push @b, {
+          #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),
@@ -2742,7 +3119,8 @@ sub _items_cust_bill_pkg {
           };
         }
         if ( $cust_bill_pkg->recur != 0 ) {
-          push @b, {
+          #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),
@@ -3035,66 +3413,6 @@ sub _items_cust_bill_pkg {
 
         } # recurring or usage with recurring charge
 
-        # decide whether to show active discounts here
-        if (
-            # case 1: we are showing a single line for the package
-            ( !$type )
-            # case 2: we are showing a setup line for a package that has
-            # no base recurring fee
-            or ( $type eq 'S' and $cust_bill_pkg->unitrecur == 0 )
-            # case 3: we are showing a recur line for a package that has 
-            # a base recurring fee
-            or ( $type eq 'R' and $cust_bill_pkg->unitrecur > 0 )
-        ) {
-
-          my @discounts = $cust_bill_pkg->cust_bill_pkg_discount;
-          # special case: if there are old "discount details" on this line 
-          # item, don't show discount line items
-          if ( FS::cust_bill_pkg_detail->count(
-              "detail LIKE 'Includes discount%' AND billpkgnum = " .
-              $cust_bill_pkg->billpkgnum
-             ) > 0 ) {
-             @discounts = ();
-          }
-          if( @discounts ) {
-            warn "$me _items_cust_bill_pkg including discounts for ".
-              $cust_bill_pkg->billpkgnum."\n"
-              if $DEBUG;
-            my $discount_amount = sum( map {$_->amount} @discounts );
-            my $orig_amount = $cust_bill_pkg->setup + $cust_bill_pkg->recur
-                              + $discount_amount;
-            # if multiple discounts apply to the same package, how to display
-            # them? ext_description lines, apparently
-            if ( $d and $cust_bill_pkg->hidden ) {
-              $d->{amount}      += $discount_amount;
-              $d->{orig_amount} += $orig_amount;
-            } else {
-              my @ext;
-              # make a placeholder for the original price, if necessary
-              # (if unit prices are enabled, it won't be necessary)
-              push @ext, '' if !$conf->exists('invoice-unitprice');
-              $d = {
-                _is_discount    => 1,
-                description     => $self->mt('Discount included'),
-                amount          => $discount_amount,
-                orig_amount     => $orig_amount,
-                ext_description => \@ext,
-              };
-              foreach my $cust_bill_pkg_discount (@discounts) {
-                my $def = $cust_bill_pkg_discount->cust_pkg_discount->discount;
-                push @ext, &{$escape_function}( $def->description );
-              }
-            }
-
-            # update the placeholder to show the original price in the 
-            # first ext_description line
-            if ( !$conf->exists('invoice-unitprice') ) {
-              $d->{ext_description}->[0] =
-                sprintf('Original price: %.2f', $d->{orig_amount});
-            }
-          } # if there are any discounts
-        } # if this is an appropriate place to show discounts
-
       } else { # taxes and fees
 
         warn "$me _items_cust_bill_pkg cust_bill_pkg is tax\n"
@@ -3109,6 +3427,56 @@ sub _items_cust_bill_pkg {
 
       } # if quotation / 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->{amount}      += $item_discount->{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)
+          #
+          # 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};
+
+          }
+
+        } # 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
@@ -3116,7 +3484,7 @@ sub _items_cust_bill_pkg {
 
   }
 
-  foreach ( $s, $r, ($opt{skip_usage} ? () : $u, $d ) ) {
+  foreach ( $s, $r, ($opt{skip_usage} ? () : $u ), $d ) {
     if ( $_  ) {
       $_->{amount}      = sprintf( "%.2f", $_->{amount} ),
         if exists($_->{amount});