change calculation method for prepaid income report, #13289
[freeside.git] / FS / FS / cust_bill_pkg.pm
index 9cc6e7c..f196a0a 100644 (file)
@@ -3,6 +3,7 @@ package FS::cust_bill_pkg;
 use strict;
 use vars qw( @ISA $DEBUG $me );
 use Carp;
+use Text::CSV_XS;
 use FS::Record qw( qsearch qsearchs dbdef dbh );
 use FS::cust_main_Mixin;
 use FS::cust_pkg;
@@ -18,6 +19,8 @@ use FS::cust_bill_pkg_tax_location;
 use FS::cust_bill_pkg_tax_rate_location;
 use FS::cust_tax_adjustment;
 
+use List::Util qw(sum);
+
 @ISA = qw( FS::cust_main_Mixin FS::Record );
 
 $DEBUG = 0;
@@ -146,30 +149,8 @@ sub insert {
 
   if ( $self->get('details') ) {
     foreach my $detail ( @{$self->get('details')} ) {
-      my %hash = ();
-      if ( ref($detail) ) {
-        if ( ref($detail) eq 'ARRAY' ) {
-          #carp "this way sucks, use a hash"; #but more useful/friendly
-          $hash{'format'}      = $detail->[0];
-          $hash{'detail'}      = $detail->[1];
-          $hash{'amount'}      = $detail->[2];
-          $hash{'classnum'}    = $detail->[3];
-          $hash{'phonenum'}    = $detail->[4];
-          $hash{'accountcode'} = $detail->[5];
-          $hash{'startdate'}   = $detail->[6];
-          $hash{'duration'}    = $detail->[7];
-          $hash{'regionname'}  = $detail->[8];
-        } elsif ( ref($detail) eq 'HASH' ) {
-          %hash = %$detail;
-        } else {
-          die "unknow detail type ". ref($detail);
-        }
-      } else {
-        $hash{'detail'} = $detail;
-      }
-      $hash{'billpkgnum'} = $self->billpkgnum;
-      my $cust_bill_pkg_detail = new FS::cust_bill_pkg_detail \%hash;
-      $error = $cust_bill_pkg_detail->insert;
+      $detail->billpkgnum($self->billpkgnum);
+      $error = $detail->insert;
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
         return "error inserting cust_bill_pkg_detail: $error";
@@ -350,6 +331,8 @@ sub check {
   ;
   return $error if $error;
 
+  $self->regularize_details;
+
   #if ( $self->pkgnum != 0 ) { #allow unchecked pkgnum 0 for tax! (add to part_pkg?)
   if ( $self->pkgnum > 0 ) { #allow -1 for non-pkg line items and 0 for tax (add to part_pkg?)
     return "Unknown pkgnum ". $self->pkgnum
@@ -362,6 +345,50 @@ sub check {
   $self->SUPER::check;
 }
 
+=item regularize_details
+
+Converts the contents of the 'details' pseudo-field to 
+L<FS::cust_bill_pkg_detail> objects, if they aren't already.
+
+=cut
+
+sub regularize_details {
+  my $self = shift;
+  if ( $self->get('details') ) {
+    foreach my $detail ( @{$self->get('details')} ) {
+      if ( ref($detail) ne 'FS::cust_bill_pkg_detail' ) {
+        # then turn it into one
+        my %hash = ();
+        if ( ! ref($detail) ) {
+          $hash{'detail'} = $detail;
+        }
+        elsif ( ref($detail) eq 'HASH' ) {
+          %hash = %$detail;
+        }
+        elsif ( ref($detail) eq 'ARRAY' ) {
+          carp "passing invoice details as arrays is deprecated";
+          #carp "this way sucks, use a hash"; #but more useful/friendly
+          $hash{'format'}      = $detail->[0];
+          $hash{'detail'}      = $detail->[1];
+          $hash{'amount'}      = $detail->[2];
+          $hash{'classnum'}    = $detail->[3];
+          $hash{'phonenum'}    = $detail->[4];
+          $hash{'accountcode'} = $detail->[5];
+          $hash{'startdate'}   = $detail->[6];
+          $hash{'duration'}    = $detail->[7];
+          $hash{'regionname'}  = $detail->[8];
+        }
+        else {
+          die "unknown detail type ". ref($detail);
+        }
+        $detail = new FS::cust_bill_pkg_detail \%hash;
+      }
+      $detail->billpkgnum($self->billpkgnum) if $self->billpkgnum;
+    }
+  }
+  return;
+}
+
 =item cust_pkg
 
 Returns the package (see L<FS::cust_pkg>) for this invoice line item.
@@ -444,61 +471,100 @@ to skip usage detail:
 
 sub details {
   my ( $self, %opt ) = @_;
-  my $format = $opt{format} || '';
   my $escape_function = $opt{escape_function} || sub { shift };
-  return () unless defined dbdef->table('cust_bill_pkg_detail');
 
-  eval "use Text::CSV_XS;";
-  die $@ if $@;
   my $csv = new Text::CSV_XS;
 
-  my $format_sub = sub { my $detail = shift;
-                         $csv->parse($detail) or return "can't parse $detail";
-                         join(' - ', map { &$escape_function($_) }
-                                     $csv->fields
-                             );
-                       };
-
-  $format_sub = sub { my $detail = shift;
-                      $csv->parse($detail) or return "can't parse $detail";
-                      join('</TD><TD>', map { &$escape_function($_) }
-                                        $csv->fields
-                          );
-                    }
-    if $format eq 'html';
-
-  $format_sub = sub { my $detail = shift;
-                      $csv->parse($detail) or return "can't parse $detail";
-                      #join(' & ', map { '\small{'. &$escape_function($_). '}' }
-                      #            $csv->fields );
-                      my $result = '';
-                      my $column = 1;
-                      foreach ($csv->fields) {
-                        $result .= ' & ' if $column > 1;
-                        if ($column > 6) {                     # KLUDGE ALERT!
-                          $result .= '\multicolumn{1}{l}{\scriptsize{'.
-                                     &$escape_function($_). '}}';
-                        }else{
-                          $result .= '\scriptsize{'.  &$escape_function($_). '}';
-                        }
-                        $column++;
-                      }
-                      $result;
-                    }
-    if $format eq 'latex';
-
-  $format_sub = $opt{format_function} if $opt{format_function};
-
-  map { ( $_->format eq 'C'
-          ? &{$format_sub}( $_->detail, $_ )
-          : &{$escape_function}( $_->detail )
-        )
-      }
-    qsearch ({ 'table'    => 'cust_bill_pkg_detail',
-               'hashref'  => { 'billpkgnum' => $self->billpkgnum },
-               'order_by' => 'ORDER BY detailnum',
-            });
-    #qsearch ( 'cust_bill_pkg_detail', { 'lineitemnum' => $self->lineitemnum });
+  if ( $opt{format_function} ) {
+
+    #this still expects to be passed a cust_bill_pkg_detail object as the
+    #second argument, which is expensive
+    carp "deprecated format_function passed to cust_bill_pkg->details";
+    my $format_sub = $opt{format_function} if $opt{format_function};
+
+    map { ( $_->format eq 'C'
+              ? &{$format_sub}( $_->detail, $_ )
+              : &{$escape_function}( $_->detail )
+          )
+        }
+      qsearch ({ 'table'    => 'cust_bill_pkg_detail',
+                 'hashref'  => { 'billpkgnum' => $self->billpkgnum },
+                 'order_by' => 'ORDER BY detailnum',
+              });
+
+  } elsif ( $opt{'no_usage'} ) {
+
+    my $sql = "SELECT detail FROM cust_bill_pkg_detail ".
+              "  WHERE billpkgnum = ". $self->billpkgnum.
+              "    AND ( format IS NULL OR format != 'C' ) ".
+              "  ORDER BY detailnum";
+    my $sth = dbh->prepare($sql) or die dbh->errstr;
+    $sth->execute or die $sth->errstr;
+
+    map &{$escape_function}( $_->[0] ), @{ $sth->fetchall_arrayref };
+
+  } else {
+
+    my $format_sub;
+    my $format = $opt{format} || '';
+    if ( $format eq 'html' ) {
+
+      $format_sub = sub { my $detail = shift;
+                          $csv->parse($detail) or return "can't parse $detail";
+                          join('</TD><TD>', map { &$escape_function($_) }
+                                            $csv->fields
+                              );
+                        };
+
+    } elsif ( $format eq 'latex' ) {
+
+      $format_sub = sub {
+        my $detail = shift;
+        $csv->parse($detail) or return "can't parse $detail";
+        #join(' & ', map { '\small{'. &$escape_function($_). '}' }
+        #            $csv->fields );
+        my $result = '';
+        my $column = 1;
+        foreach ($csv->fields) {
+          $result .= ' & ' if $column > 1;
+          if ($column > 6) {                     # KLUDGE ALERT!
+            $result .= '\multicolumn{1}{l}{\scriptsize{'.
+                       &$escape_function($_). '}}';
+          }else{
+            $result .= '\scriptsize{'.  &$escape_function($_). '}';
+          }
+          $column++;
+        }
+        $result;
+      };
+
+    } else {
+
+      $format_sub = sub { my $detail = shift;
+                          $csv->parse($detail) or return "can't parse $detail";
+                          join(' - ', map { &$escape_function($_) }
+                                      $csv->fields
+                              );
+                        };
+
+    }
+
+    my $sql = "SELECT format, detail FROM cust_bill_pkg_detail ".
+              "  WHERE billpkgnum = ". $self->billpkgnum.
+              "  ORDER BY detailnum";
+    my $sth = dbh->prepare($sql) or die dbh->errstr;
+    $sth->execute or die $sth->errstr;
+
+    #avoid the fetchall_arrayref and loop for less memory usage?
+
+    map { (defined($_->[0]) && $_->[0] eq 'C')
+            ? &{$format_sub}(      $_->[1] )
+            : &{$escape_function}( $_->[1] );
+        }
+      @{ $sth->fetchall_arrayref };
+
+  }
+
 }
 
 =item details_header [ OPTION => VALUE ... ]
@@ -514,8 +580,6 @@ sub details_header {
   my $self = shift;
   return '' unless defined dbdef->table('cust_bill_pkg_detail');
 
-  eval "use Text::CSV_XS;";
-  die $@ if $@;
   my $csv = new Text::CSV_XS;
 
   my @detail = 
@@ -825,29 +889,15 @@ usage.
 
 sub usage {
   my( $self, $classnum ) = @_;
-  my $sum = 0;
+  $self->regularize_details;
 
   if ( $self->get('details') ) {
 
-    foreach my $value (
-      map { ref($_) eq 'HASH'
-              ? $_->{'amount'}
-              : $_->[2] 
-          }
-      grep { ref($_) && ( defined($classnum)
-                            ? $classnum eq ( ref($_) eq 'HASH'
-                                               ? $_->{'classnum'}
-                                               : $_->[3]
-                                           )
-                            : 1
-                        )
-           }
+    return sum( 0, 
+      map { $_->amount || 0 }
+      grep { !defined($classnum) or $classnum eq $_->classnum }
       @{ $self->get('details') }
-    ) {
-      $sum += $value if $value;
-    }
-
-    return $sum;
+    );
 
   } else {
 
@@ -858,7 +908,7 @@ sub usage {
     my $sth = dbh->prepare($sql) or die dbh->errstr;
     $sth->execute or die $sth->errstr;
 
-    return $sth->fetchrow_arrayref->[0];
+    return $sth->fetchrow_arrayref->[0] || 0;
 
   }
 
@@ -873,16 +923,11 @@ details.
 
 sub usage_classes {
   my( $self ) = @_;
+  $self->regularize_details;
 
   if ( $self->get('details') ) {
 
-    my %seen = ();
-    foreach my $detail ( grep { ref($_) } @{$self->get('details')} ) {
-      $seen{ ref($detail) eq 'HASH'
-               ? $detail->{'classnum'}
-               : $detail->[3]
-           } = 1;
-    }
+    my %seen = ( map { $_->classnum => 1 } @{ $self->get('details') } );
     keys %seen;
 
   } else {
@@ -1022,6 +1067,86 @@ sub _X_show_zero {
 
 =back
 
+=head1 CLASS METHODS
+
+=over 4
+
+=item owed_sql [ BEFORE, AFTER, OPTIONS ]
+
+Returns an SQL expression for the amount owed.  BEFORE and AFTER specify
+a date window.  OPTIONS may include 'no_usage' (excludes usage charges)
+and 'setuprecur' (set to "setup" or "recur" to limit to one or the other).
+
+=cut
+
+sub owed_sql {
+  my ($class, $start, $end, %opt) = @_;
+  my $charged = 
+    $opt{setuprecur} =~ /^s/ ? 'setup' :
+    $opt{setuprecur} =~ /^r/ ? 'recur' :
+    'setup + recur';
+
+  if ( $opt{no_usage} ) {
+    $charged .= ' - ' . $class->usage_sql;
+  }
+
+  '(' . $charged . 
+  ' - ' . $class->paid_sql($start, $end, %opt) .
+  ' - ' . $class->credited_sql($start, $end, %opt) . ')'
+}
+
+=item usage_sql
+
+Returns an SQL expression for the total usage charges in details on
+an item.
+
+=cut
+
+sub usage_sql {
+  my $class = shift;
+  "(SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0) 
+    FROM cust_bill_pkg_detail 
+    WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum)"
+}
+
+=item paid_sql [ BEFORE, AFTER, OPTIONS ]
+
+Returns an SQL expression for the sum of payments applied to this item.
+
+=cut
+
+sub paid_sql {
+  my ($class, $start, $end, %opt) = @_;
+  $start = $start ? "AND cust_bill_pay._date <= $start" : '';
+  $end   = $end   ? "AND cust_bill_pay._date >  $end"   : '';
+  my $setuprecur = 
+    $opt{setuprecur} =~ /^s/ ? 'setup' :
+    $opt{setuprecur} =~ /^r/ ? 'recur' :
+    '';
+  $setuprecur &&= "AND setuprecur = '$setuprecur'";
+  "( SELECT COALESCE(SUM(cust_bill_pay_pkg.amount),0)
+     FROM cust_bill_pay_pkg JOIN cust_bill_pay USING (billpaynum)
+     WHERE cust_bill_pay_pkg.billpkgnum = cust_bill_pkg.billpkgnum
+           $start $end $setuprecur )";
+}
+
+sub credited_sql {
+  my ($class, $start, $end, %opt) = @_;
+  $start = $start ? "AND cust_credit_bill._date <= $start" : '';
+  $end   = $end   ? "AND cust_credit_bill._date >  $end"   : '';
+  my $setuprecur = 
+    $opt{setuprecur} =~ /^s/ ? 'setup' :
+    $opt{setuprecur} =~ /^r/ ? 'recur' :
+    '';
+  $setuprecur &&= "AND setuprecur = '$setuprecur'";
+  "( SELECT COALESCE(SUM(cust_credit_bill_pkg.amount),0)
+     FROM cust_credit_bill_pkg JOIN cust_credit_bill USING (creditbillnum)
+     WHERE cust_credit_bill_pkg.billpkgnum = cust_bill_pkg.billpkgnum
+           $start $end $setuprecur )";
+}
+
+=back
+
 =head1 BUGS
 
 setup and recur shouldn't be separate fields.  There should be one "amount"