also supress sending invoices w/selfservice-hide_invoices-taxclass, RT#15327
[freeside.git] / FS / FS / cust_bill.pm
index 1d3ddb8..79c78d8 100644 (file)
@@ -243,7 +243,6 @@ sub delete {
     cust_event
     cust_credit_bill
     cust_bill_pay
-    cust_bill_pay
     cust_credit_bill
     cust_pay_batch
     cust_bill_pay_batch
@@ -797,6 +796,23 @@ sub owed_pkgnum {
   $balance;
 }
 
+=item hide
+
+Returns true if this invoice should be hidden.  See the
+selfservice-hide_invoices-taxclass configuraiton setting.
+
+=cut
+
+sub hide {
+  my $self = shift;
+  my $conf = $self->conf;
+  my $hide_taxclass = $conf->config('selfservice-hide_invoices-taxclass')
+    or return '';
+  my @cust_bill_pkg = $self->cust_bill_pkg;
+  my @part_pkg = grep $_, map $_->part_pkg, @cust_bill_pkg;
+  ! grep { $_->taxclass ne $hide_taxclass } @part_pkg;
+}
+
 =item apply_payments_and_credits [ OPTION => VALUE ... ]
 
 Applies unapplied payments and credits to this invoice.
@@ -1336,6 +1352,7 @@ sub queueable_email {
 #sub email_invoice {
 sub email {
   my $self = shift;
+  return if $self->hide;
   my $conf = $self->conf;
 
   my( $template, $invoice_from, $notice_name, $no_coupon );
@@ -1454,7 +1471,9 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 #sub print_invoice {
 sub print {
   my $self = shift;
+  return if $self->hide;
   my $conf = $self->conf;
+
   my( $template, $notice_name );
   if ( ref($_[0]) ) {
     my $opt = shift;
@@ -1494,7 +1513,9 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub fax_invoice {
   my $self = shift;
+  return if $self->hide;
   my $conf = $self->conf;
+
   my( $template, $notice_name );
   if ( ref($_[0]) ) {
     my $opt = shift;
@@ -2363,11 +2384,13 @@ unsquelch_cdr - overrides any per customer cdr squelching when true
 
 notice_name - overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
 
+locale - override customer's locale
+
 =cut
 
 #what's with all the sprintf('%10.2f')'s in here?  will it cause any
 # (alignment in text invoice?) problems to change them all to '%.2f' ?
-# yes: fixed width (dot matrix) text printing will be borked
+# yes: fixed width/plain text printing will be borked
 sub print_generic {
   my( $self, %params ) = @_;
   my $conf = $self->conf;
@@ -2656,13 +2679,14 @@ sub print_generic {
   );
  
   #localization
-  my $lh = FS::L10N->get_handle($cust_main->locale);
+  my $lh = FS::L10N->get_handle( $params{'locale'} || $cust_main->locale );
   $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
   my %info = FS::Locales->locale_info($cust_main->locale || 'en_US');
   # eval to avoid death for unimplemented languages
   my $dh = eval { Date::Language->new($info{'name'}) } ||
            Date::Language->new(); # fall back to English
-  $invoice_data{'time2str'} = sub { $dh->time2str(@_) };
+  # prototype here to silence warnings
+  $invoice_data{'time2str'} = sub ($;$$) { $dh->time2str(@_) };
   # eventually use this date handle everywhere in here, too
 
   my $min_sdate = 999999999999;
@@ -2855,7 +2879,7 @@ sub print_generic {
   my $previous_section = { 'description' => $self->mt('Previous Charges'),
                            'subtotal'    => $other_money_char.
                                             sprintf('%.2f', $pr_total),
-                           'summarized'  => $summarypage ? 'Y' : '',
+                           'summarized'  => '', #why? $summarypage ? 'Y' : '',
                          };
   $previous_section->{posttotal} = '0 / 30 / 60 / 90 days overdue '. 
     join(' / ', map { $cust_main->balance_date_range(@$_) }
@@ -2866,12 +2890,11 @@ sub print_generic {
   my $taxtotal = 0;
   my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
                       'subtotal'    => $taxtotal,   # adjusted below
-                      'summarized'  => $summarypage ? 'Y' : '',
                     };
   my $tax_weight = _pkg_category($tax_section->{description})
                         ? _pkg_category($tax_section->{description})->weight
                         : 0;
-  $tax_section->{'summarized'} = $summarypage && !$tax_weight ? 'Y' : '';
+  $tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
   $tax_section->{'sort_weight'} = $tax_weight;
 
 
@@ -2879,12 +2902,11 @@ sub print_generic {
   my $adjust_section = { 'description' => 
     $self->mt('Credits, Payments, and Adjustments'),
                          'subtotal'    => 0,   # adjusted below
-                         'summarized'  => $summarypage ? 'Y' : '',
                        };
   my $adjust_weight = _pkg_category($adjust_section->{description})
                         ? _pkg_category($adjust_section->{description})->weight
                         : 0;
-  $adjust_section->{'summarized'} = $summarypage && !$adjust_weight ? 'Y' : '';
+  $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';
@@ -2924,7 +2946,8 @@ sub print_generic {
     }
   } else {# not multisection
     # make a default section
-    push @sections, { 'description' => '', 'subtotal' => '' };
+    push @sections, { 'description' => '', 'subtotal' => '', 
+      'no_subtotal' => 1 };
     # and calculate the finance charge total, since it won't get done otherwise.
     # XXX possibly other totals?
     # XXX possibly finance_pkgclass should not be used in this manner?
@@ -2990,9 +3013,9 @@ sub print_generic {
       my ($didsummary,$minutes) = $self->_did_summary;
       my $didsummary_desc = 'DID Activity Summary (since last invoice)';
       push @detail_items, 
-       { 'description' => $didsummary_desc,
-           'ext_description' => [ $didsummary, $minutes ],
-       };
+       { 'description' => $didsummary_desc,
+           'ext_description' => [ $didsummary, $minutes ],
+       };
   }
 
   foreach my $section (@sections, @$late_sections) {
@@ -3033,7 +3056,7 @@ sub print_generic {
     $options{'section'} = $section if $multisection;
     $options{'format'} = $format;
     $options{'escape_function'} = $escape_function;
-    $options{'format_function'} = sub { () } unless $unsquelched;
+    $options{'no_usage'} = 1 unless $unsquelched;
     $options{'unsquelched'} = $unsquelched;
     $options{'summary_page'} = $summarypage;
     $options{'skip_usage'} =
@@ -3088,7 +3111,7 @@ sub print_generic {
     }
   
   }
-  
+
   $invoice_data{current_less_finance} =
     sprintf('%.2f', $self->charged - $invoice_data{finance_amount} );
 
@@ -3267,6 +3290,7 @@ sub print_generic {
         unless $adjust_section->{sort_weight};
     }
 
+    # create Balance Due message
     { 
       my $total;
       $total->{'total_item'} = &$embolden_function($self->balance_due_msg);
@@ -3327,6 +3351,26 @@ sub print_generic {
       if $unsquelched;
   }
 
+  # make a discounts-available section, even without multisection
+  if ( $conf->exists('discount-show_available') 
+       and my @discounts_avail = $self->_items_discounts_avail ) {
+    my $discount_section = {
+      'description' => $self->mt('Discounts Available'),
+      'subtotal'    => '',
+      'no_subtotal' => 1,
+    };
+
+    push @sections, $discount_section;
+    push @detail_items, map { +{
+        'ref'         => '', #should this be something else?
+        'section'     => $discount_section,
+        'description' => &$escape_function( $_->{description} ),
+        'amount'      => $money_char . &$escape_function( $_->{amount} ),
+        'ext_description' => [ &$escape_function($_->{ext_description}) || () ],
+    } } @discounts_avail;
+  }
+
+  # All sections and items are built; now fill in templates.
   my @includelist = ();
   push @includelist, 'summary' if $summarypage;
   foreach my $include ( @includelist ) {
@@ -3389,8 +3433,7 @@ sub print_generic {
     }
 
     #setup subroutine for the template
-    #sub FS::cust_bill::_template::invoice_lines { # good god, no
-    $invoice_data{invoice_lines} = sub { # much better
+    $invoice_data{invoice_lines} = sub {
       my $lines = shift || scalar(@buf);
       map { 
         scalar(@buf)
@@ -4741,6 +4784,7 @@ format: the invoice format.
 
 escape_function: the function used to escape strings.
 
+DEPRECATED? (expensive, mostly unused?)
 format_function: the function used to format CDRs.
 
 section: a hashref containing 'description'; if this is present, 
@@ -4769,6 +4813,7 @@ sub _items_cust_bill_pkg {
   my $format = $opt{format} || '';
   my $escape_function = $opt{escape_function} || sub { shift };
   my $format_function = $opt{format_function} || '';
+  my $no_usage = $opt{no_usage} || '';
   my $unsquelched = $opt{unsquelched} || ''; #unused
   my $section = $opt{section}->{description} if $opt{section};
   my $summary_page = $opt{summary_page} || ''; #unused
@@ -4825,6 +4870,7 @@ sub _items_cust_bill_pkg {
       my %details_opt = ( 'format'          => $format,
                           'escape_function' => $escape_function,
                           'format_function' => $format_function,
+                          'no_usage'        => $opt{'no_usage'},
                         );
 
       if ( $cust_bill_pkg->pkgnum > 0 ) {
@@ -4982,7 +5028,7 @@ sub _items_cust_bill_pkg {
 
             #instead of omitting details entirely in this case (unwanted side
             # effects), just omit CDRs
-            $details_opt{'format_function'} = sub { () }
+            $details_opt{'no_usage'} = 1
               if $type && $type eq 'R';
 
             push @d, $cust_bill_pkg->details(%details_opt);
@@ -5147,6 +5193,122 @@ sub _items_payments {
 
 }
 
+=item _items_discounts_avail
+
+Returns an array of line item hashrefs representing available term discounts
+for this invoice.  This makes the same assumptions that apply to term 
+discounts in general: that the package is billed monthly, at a flat rate, 
+with no usage charges.  A prorated first month will be handled, as will 
+a setup fee if the discount is allowed to apply to setup fees.
+
+=cut
+
+sub _items_discounts_avail {
+  my $self = shift;
+  my %terms;
+  my $list_pkgnums = 0; # if any packages are not eligible for all discounts
+  my ($previous_balance) = $self->previous;
+
+  foreach (qsearch('discount',{ 'months' => { op => '>', value => 1} })) {
+    $terms{$_->months} = {
+      pkgnums       => [],
+      base          => $previous_balance || 0, # pre-discount sum of charges
+      discounted    => $previous_balance || 0, # post-discount sum
+      list_pkgnums  => 0, # whether any packages are not discounted
+    }
+  }
+  foreach my $months (keys %terms) {
+    my $hash = $terms{$months};
+
+    # tricky, because packages may not all be eligible for the same discounts
+    foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+      my $cust_pkg = $cust_bill_pkg->cust_pkg or next;
+      my $part_pkg = $cust_pkg->part_pkg or next;
+      my $freq = $part_pkg->freq;
+      my $setup = $cust_bill_pkg->setup || 0;
+      my $recur = $cust_bill_pkg->recur || 0;
+
+      if ( $freq eq '1' ) { #monthly
+        my $permonth = $part_pkg->base_recur_permonth || 0;
+
+        my ($discount) = grep { $_->months == $months } 
+                         map { $_->discount } $part_pkg->part_pkg_discount;
+
+        $hash->{base} += $setup + $recur + ($months - 1) * $permonth;
+
+        if ( $discount ) {
+
+          my $discountable;
+          if ( $discount->setup ) {
+            $discountable += $setup;
+          }
+          else {
+            $hash->{discounted} += $setup;
+          }
+
+          if ( $discount->percent ) {
+            $discountable += $months * $permonth;
+            $discountable -= ($discountable * $discount->percent / 100);
+            $discountable -= ($permonth - $recur); # correct for prorate
+            $hash->{discounted} += $discountable;
+          }
+          else {
+            $discountable += $recur;
+            $discountable -= $discount->amount * $recur/$permonth;
+
+            $discountable += ($months - 1) * max($permonth - $discount->amount,0);
+          }
+
+          $hash->{discounted} += $discountable;
+          push @{ $hash->{pkgnums} }, $cust_pkg->pkgnum;
+        }
+        else { #no discount
+          $hash->{discounted} += $setup + $recur + ($months - 1) * $permonth;
+          $hash->{list_pkgnums} = 1;
+        }
+      } #if $freq eq '1'
+      else { # all non-monthly packages: include current charges only
+        $hash->{discounted} += $setup + $recur;
+        $hash->{base} += $setup + $recur;
+        $hash->{list_pkgnums} = 1;
+      }
+    } #foreach $cust_bill_pkg
+
+    # don't show this line if no packages have discounts at this term
+    # or if there are no new charges to apply the discount to
+    delete $terms{$months} if $hash->{base} == $hash->{discounted}
+                           or $hash->{base} == 0;
+
+  }
+
+  $list_pkgnums = grep { $_->{list_pkgnums} > 0 } values %terms;
+
+  foreach my $months (keys %terms) {
+    my $hash = $terms{$months};
+    my $term_total = sprintf('%.2f', $hash->{discounted});
+    # possibly shouldn't include previous balance in these?
+    my $percent = sprintf('%.0f', 100 * (1 - $term_total / $hash->{base}) );
+    my $permonth = sprintf('%.2f', $term_total / $months);
+
+    $hash->{description} = $self->mt('Save [_1]% by paying for [_2] months',
+      $percent, $months
+    );
+    $hash->{amount} = $self->mt('[_1] ([_2] per month)', 
+      $term_total, $money_char.$permonth
+    );
+
+    my @detail;
+    if ( $list_pkgnums ) {
+      push @detail, $self->mt('discount on item'). ' '.
+                join(', ', map { "#$_" } @{ $hash->{pkgnums} });
+    }
+    $hash->{ext_description} = join ', ', @detail;
+  }
+
+  map { $terms{$_} } sort {$b <=> $a} keys %terms;
+}
+
 =item call_details [ OPTION => VALUE ... ]
 
 Returns an array of CSV strings representing the call details for this invoice