[freeside-commits] branch master updated. 86d3bab91d8baadcbe33e5bbceeb607990efa1eb

Ivan ivan at 420.am
Sun Feb 16 17:23:53 PST 2014


The branch, master has been updated
       via  86d3bab91d8baadcbe33e5bbceeb607990efa1eb (commit)
       via  d01d5826b8a8b64c5ccc64b0ee8e8c3db3e9ea57 (commit)
       via  94aa5503bd56d53b99d99521a77ede066be6a0f1 (commit)
       via  c4e26585cdbd2cd086ed813a02b963ffccfebc55 (commit)
      from  af0778d5f900697e0a523c6f88b3b250d5a0d1c9 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 86d3bab91d8baadcbe33e5bbceeb607990efa1eb
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Sun Feb 16 17:23:51 2014 -0800

    credit limit for CDR prerating, RT#27267

diff --git a/FS/FS.pm b/FS/FS.pm
index c19d2a9..41d5138 100644
--- a/FS/FS.pm
+++ b/FS/FS.pm
@@ -376,6 +376,8 @@ L<FS::cust_main_Mixin> - Mixin class for records that contain fields from cust_m
 
 L<FS::cust_main_invoice> - Invoice destination class
 
+L<FS::cust_main_credit_limit> - Customer credit limit events class
+
 L<FS::cust_class> - Customer classification class
 
 L<FS::cust_category> - Customer category class
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 4211ee2..bd58698 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -1879,6 +1879,26 @@ sub tables_hashref {
                         ],
     },
 
+    'cust_main_credit_limit' => {
+      'columns' => [
+        'creditlimitnum',   'serial', '', '', '', '',
+        'custnum',             'int', '', '', '', '', 
+        '_date',          @date_type,         '', '', 
+        'amount',       @money_typen,         '', '',
+        #'amount_currency', 'char', 'NULL',  3, '', '',
+        'credit_limit', @money_typen,         '', '',
+        #'credit_limit_currency', 'char', 'NULL',  3, '', '',
+      ],
+      'primary_key'  => 'creditlimitnum',
+      'unique'       => [],
+      'index'        => [ ['custnum'], ],
+      'foreign_keys' => [
+                          { columns    => [ 'custnum' ],
+                            table      => 'cust_main',
+                          },
+                        ],
+    },
+
     'cust_main_note' => {
       'columns' => [
         'notenum',  'serial',  '',     '', '', '', 
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 57c0095..b37b0da 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -7,6 +7,7 @@ use base qw( FS::cust_main::Packages
              FS::cust_main::Billing_Discount
              FS::cust_main::Billing_ThirdParty
              FS::cust_main::Location
+             FS::cust_main::Credit_Limit
              FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
              FS::geocode_Mixin FS::Quotable_Mixin FS::Sales_Mixin
              FS::o2m_Common
diff --git a/FS/FS/cust_main/Credit_Limit.pm b/FS/FS/cust_main/Credit_Limit.pm
new file mode 100644
index 0000000..238d885
--- /dev/null
+++ b/FS/FS/cust_main/Credit_Limit.pm
@@ -0,0 +1,87 @@
+package FS::cust_main::Credit_Limit;
+
+use strict;
+use vars qw( $conf $default_credit_limit $credit_limit_delay );
+use FS::UID qw( dbh );
+use FS::Record qw( qsearchs );
+use FS::cust_main_credit_limit;
+
+#ask FS::UID to run this stuff for us later
+install_callback FS::UID sub { 
+  $conf = new FS::Conf;
+  #yes, need it for stuff below (prolly should be cached)
+  $default_credit_limit = $conf->config('default_credit_limit') || 0;
+};
+
+$credit_limit_delay = 6 * 60 * 60; #6 hours?  conf?
+
+sub check_credit_limit {
+  my $self = shift;
+
+  my $credit_limit = $self->credit_limit || $default_credit_limit;
+
+  return '' unless $credit_limit > 0;
+
+  #see if we've already triggered this credit limit recently
+  return ''
+    if qsearchs({
+         'table'    => 'cust_main_credit_limit',
+         'hashref'  => {
+           'custnum'      => $self->custnum,
+           'credit_limit' => { op=>'>=', value=> $credit_limit },
+           '_date'        => { op=>'>=', value=> time - $credit_limit_delay, },
+         },
+         'order_by' => 'LIMIT 1',
+       });
+
+  #count up prerated CDRs
+
+  my @cust_svc = map $_->cust_svc_unsorted( 'svcdb'=>'svc_phone' ),
+                   $self->all_pkgs;
+  my @svcnum = map $_->svcnum, @cust_svc;
+
+  #false laziness  w/svc_phone->sum_cdrs / psearch_cdrs
+  my $sum = qsearchs( {
+    'select'  => 'SUM(rated_price) AS rated_price',
+    'table'   => 'cdr',
+    'hashref' => { 'status' => 'rated',
+                   'svcnum' => { op    => 'IN',
+                                 value => '('. join(',', at svcnum). ')',
+                               },
+                 },
+  } );
+
+  return '' unless $sum->rated_price > $credit_limit;
+
+  #XXX trigger an alert
+  # (email send / ticket create / nagios alert export) ?
+  # maybe an over_credit_limit cust_main export or some such?
+
+  # record we did it so we don't do it continuously
+  my $cust_main_credit_limit = new FS::cust_main_credit_limit {
+    'custnum'      => $self->custnum,
+    '_date'        => time,
+    'credit_limit' => $credit_limit,
+    'amount'       => sprintf('%.2f', $sum->rated_price ),
+  };
+  my $error = $cust_main_credit_limit->insert;
+  if ( $error ) {
+    #"should never happen", but better to survive e.g. database going
+    # away and coming back and resume doing our thing
+    warn $error;
+    sleep 30;
+  }
+
+}
+
+sub num_cust_main_credit_limit {
+  my $self = shift;
+
+  my $sql = 'SELECT COUNT(*) FROM cust_main_credit_limit WHERE custnum = ?';
+  my $sth = dbh->prepare($sql)   or die  dbh->errstr;
+  $sth->execute( $self->custnum) or die $sth->errstr;
+
+  $sth->fetchrow_arrayref->[0];
+}
+
+1;
diff --git a/FS/FS/cust_main_credit_limit.pm b/FS/FS/cust_main_credit_limit.pm
new file mode 100644
index 0000000..5a5181d
--- /dev/null
+++ b/FS/FS/cust_main_credit_limit.pm
@@ -0,0 +1,121 @@
+package FS::cust_main_credit_limit;
+use base qw( FS::Record );
+
+use strict;
+#use FS::Record qw( qsearch qsearchs );
+use FS::cust_main;
+
+=head1 NAME
+
+FS::cust_main_credit_limit - Object methods for cust_main_credit_limit records
+
+=head1 SYNOPSIS
+
+  use FS::cust_main_credit_limit;
+
+  $record = new FS::cust_main_credit_limit \%hash;
+  $record = new FS::cust_main_credit_limit { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_main_credit_limit object represents a specific incident where a
+customer exceeds their credit limit.  FS::cust_main_credit_limit inherits from
+FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item creditlimitnum
+
+primary key
+
+=item custnum
+
+Customer (see L<FS::cust_main>)
+
+=item _date
+
+Ppecified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
+L<Time::Local> and L<Date::Parse> for conversion functions.
+
+=item amount
+
+Amount of credit of the incident
+
+=item credit_limit
+
+Appliable customer or default credit_limit at the time of the incident
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_main_credit_limit'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('creditlimitnum')
+    || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
+    || $self->ut_number('_date')
+    || $self->ut_money('amount')
+    || $self->ut_money('credit_limit')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/bin/freeside-cdrrated b/FS/bin/freeside-cdrrated
index 99ea675..1333240 100644
--- a/FS/bin/freeside-cdrrated
+++ b/FS/bin/freeside-cdrrated
@@ -37,7 +37,7 @@ our %svcnum = ();   # phonenum => svcnum
 our %pkgnum = ();   # phonenum => pkgnum
 our %cust_pkg = (); # pkgnum   => cust_pkg (NOT phonenum => cust_pkg!)
 our %pkgpart = ();  # phonenum => pkgpart
-our %part_pkg = (); # phonenum => part_pkg
+our %part_pkg = (); # pkgpart  => part_pkg
 
 #some false laziness w/freeside-cdrrewrited
 
@@ -127,10 +127,12 @@ while (1) {
 
     } 
 
-    #unless ( $part_pkg{$pkgpart{$number}} ) {
-    #}
-
-    #XXX if $part_pkg->option('min_included') then we can't prerate this CDR
+    if ( $part_pkg{ $pkgpart{$number} }->option('min_included') ) {
+      #then we can't prerate this CDR
+      #some sort of warning?
+      # (sucks if you're depending on credit limit fraud warnings)
+      next;
+    }
     
     my $error = $cdr->rate(
       'part_pkg' => $part_pkg{ $pkgpart{$number} },
@@ -141,6 +143,21 @@ while (1) {
       #XXX ???
       warn $error;
       sleep 30;
+    } else {
+
+      #this could get expensive on a per-call basis
+      # trigger in a separate process with less frequency?
+      
+      my $cust_main = $cust_pkg{ $pkgnum{$number} }->cust_main;
+
+      my $error = $cust_main->check_credit_limit;
+      if ( $error ) {
+        #"should never happen" normally, but as a daemon, better to survive
+        # e.g. database going away and coming back and resume doing our thing
+        warn $error;
+        sleep 30;
+      }
+
     }
 
     last if sigterm() || sigint();
diff --git a/FS/t/cust_main_credit_limit.t b/FS/t/cust_main_credit_limit.t
new file mode 100644
index 0000000..11f8adf
--- /dev/null
+++ b/FS/t/cust_main_credit_limit.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_main_credit_limit;
+$loaded=1;
+print "ok 1\n";
diff --git a/httemplate/search/cust_main_credit_limit.html b/httemplate/search/cust_main_credit_limit.html
new file mode 100644
index 0000000..b2a0c9b
--- /dev/null
+++ b/httemplate/search/cust_main_credit_limit.html
@@ -0,0 +1,63 @@
+<& elements/search.html,
+     'title'         => 'Credit limit incidents',
+     'name_singular' => 'incident',
+     'query'         => { table     => 'cust_main_credit_limit',
+                          hashref   => \%hash,
+                          extra_sql => " AND $dates_sql ",
+                          order_by  => 'ORDER BY _date ASC',
+                        },
+     'count_query'   => "SELECT COUNT(*) FROM cust_main_credit_limit",
+     'header'        => [ 'Date',
+
+                          #XXX should use cust_fields etc.
+                          '#',
+                          'Customer',
+
+                          'Amount',
+                          'Limit',
+                        ],
+     'fields'        => [ sub { time2str($date_format, shift->_date); },
+
+                          #XXX should use cust_fields etc.
+                          sub { shift->cust_main->display_custnum },
+                          sub { shift->cust_main->name },
+
+                          sub { $money_char. shift->amount },
+                          sub { $money_char. shift->credit_limit },
+                        ],
+
+     'links'         => [ '',
+
+                          #XXX should use cust_fields etc.
+                          $cust_link,
+                          $cust_link,
+
+                          '',
+                          '',
+                        ],
+&>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('List rating data');
+
+my $conf = new FS::Conf;
+
+my $date_format = $conf->config('date_format') || '%m/%d/%Y';
+
+my $money_char = $conf->config('money_char') || '$';
+
+my $cust_link = [ "${p}view/cust_main.cgi?", 'custnum' ];
+
+my ($begin, $end) = FS::UI::Web::parse_beginning_ending($cgi);
+my $dates_sql = "_date >= $begin AND _date < $end";
+
+my $count_query= "SELECT COUNT(*) FROM cust_main_credit_limit WHERE $dates_sql";
+
+my %hash = ();
+if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+  $hash{custnum} = $1;
+  $count_query .= " AND custnum = $1";
+}
+
+</%init>
diff --git a/httemplate/search/report_cust_main_credit_limit.html b/httemplate/search/report_cust_main_credit_limit.html
new file mode 100644
index 0000000..8503fb3
--- /dev/null
+++ b/httemplate/search/report_cust_main_credit_limit.html
@@ -0,0 +1,24 @@
+<& /elements/header.html, 'Credit limit incidents' &>
+
+<FORM ACTION="cust_main_credit_limit.html" METHOD="GET">
+
+<TABLE BGCOLOR="#cccccc" CELLSPACING=0>
+
+<& /elements/tr-search-cust_main.html, 'label' => 'Customer' &>
+
+<TR><TD></TD><TD><FONT SIZE="-1">(leave blank for all customers)</FONT></TD></TR>
+
+<& /elements/tr-input-beginning_ending.html &>
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="Get Report">
+
+<& /elements/footer.html &>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('List rating data');
+
+</%init>
diff --git a/httemplate/view/cust_main/billing.html b/httemplate/view/cust_main/billing.html
index 382fdac..2c4b3fb 100644
--- a/httemplate/view/cust_main/billing.html
+++ b/httemplate/view/cust_main/billing.html
@@ -273,6 +273,9 @@
            ? "Default ($money_char". sprintf("%.2f", $default_credit_limit). ")"
            : emt('Unlimited')
     %>
+%   if ( $cust_main->num_cust_main_credit_limit ) {
+      <A HREF="<% $p %>search/cust_main_credit_limit.html?custnum=<% $cust_main->custnum %>">(incidents)</A>
+%   }
   </TD>
 </TR>
 

commit d01d5826b8a8b64c5ccc64b0ee8e8c3db3e9ea57
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Sun Feb 16 16:08:40 2014 -0800

    move cust_pkg::search to a file of its own

diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm
index 392bab3..668de75 100644
--- a/FS/FS/cust_pkg.pm
+++ b/FS/FS/cust_pkg.pm
@@ -1,7 +1,9 @@
 package FS::cust_pkg;
-use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::Sales_Mixin
+use base qw( FS::cust_pkg::Search
+             FS::otaker_Mixin FS::cust_main_Mixin FS::Sales_Mixin
              FS::contact_Mixin FS::location_Mixin
-             FS::m2m_Common FS::option_Common );
+             FS::m2m_Common FS::option_Common
+           );
 
 use strict;
 use Carp qw(cluck);
@@ -33,7 +35,6 @@ use FS::reason;
 use FS::cust_pkg_usageprice;
 use FS::cust_pkg_discount;
 use FS::discount;
-use FS::UI::Web;
 use FS::sales;
 # for modify_charge
 use FS::cust_credit;
@@ -4187,519 +4188,6 @@ sub status_sql {
 END"
 }
 
-=item search HASHREF
-
-(Class method)
-
-Returns a qsearch hash expression to search for parameters specified in HASHREF.
-Valid parameters are
-
-=over 4
-
-=item agentnum
-
-=item magic
-
-active, inactive, suspended, cancel (or cancelled)
-
-=item status
-
-active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
-
-=item custom
-
- boolean selects custom packages
-
-=item classnum
-
-=item pkgpart
-
-pkgpart or arrayref or hashref of pkgparts
-
-=item setup
-
-arrayref of beginning and ending epoch date
-
-=item last_bill
-
-arrayref of beginning and ending epoch date
-
-=item bill
-
-arrayref of beginning and ending epoch date
-
-=item adjourn
-
-arrayref of beginning and ending epoch date
-
-=item susp
-
-arrayref of beginning and ending epoch date
-
-=item expire
-
-arrayref of beginning and ending epoch date
-
-=item cancel
-
-arrayref of beginning and ending epoch date
-
-=item query
-
-pkgnum or APKG_pkgnum
-
-=item cust_fields
-
-a value suited to passing to FS::UI::Web::cust_header
-
-=item CurrentUser
-
-specifies the user for agent virtualization
-
-=item fcc_line
-
-boolean; if true, returns only packages with more than 0 FCC phone lines.
-
-=item state, country
-
-Limit to packages with a service location in the specified state and country.
-For FCC 477 reporting, mostly.
-
-=item location_cust
-
-Limit to packages whose service locations are the same as the customer's 
-default service location.
-
-=item location_nocust
-
-Limit to packages whose service locations are not the customer's default 
-service location.
-
-=item location_census
-
-Limit to packages whose service locations have census tracts.
-
-=item location_nocensus
-
-Limit to packages whose service locations do not have a census tract.
-
-=item location_geocode
-
-Limit to packages whose locations have geocodes.
-
-=item location_geocode
-
-Limit to packages whose locations do not have geocodes.
-
-=back
-
-=cut
-
-sub search {
-  my ($class, $params) = @_;
-  my @where = ();
-
-  ##
-  # parse agent
-  ##
-
-  if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
-    push @where,
-      "cust_main.agentnum = $1";
-  }
-
-  ##
-  # parse cust_status
-  ##
-
-  if ( $params->{'cust_status'} =~ /^([a-z]+)$/ ) {
-    push @where, FS::cust_main->cust_status_sql . " = '$1' ";
-  }
-
-  ##
-  # parse customer sales person
-  ##
-
-  if ( $params->{'cust_main_salesnum'} =~ /^(\d+)$/ ) {
-    push @where, ($1 > 0) ? "cust_main.salesnum = $1"
-                          : 'cust_main.salesnum IS NULL';
-  }
-
-
-  ##
-  # parse sales person
-  ##
-
-  if ( $params->{'salesnum'} =~ /^(\d+)$/ ) {
-    push @where, ($1 > 0) ? "cust_pkg.salesnum = $1"
-                          : 'cust_pkg.salesnum IS NULL';
-  }
-
-  ##
-  # parse custnum
-  ##
-
-  if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
-    push @where,
-      "cust_pkg.custnum = $1";
-  }
-
-  ##
-  # custbatch
-  ##
-
-  if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
-    push @where,
-      "cust_pkg.pkgbatch = '$1'";
-  }
-
-  ##
-  # parse status
-  ##
-
-  if (    $params->{'magic'}  eq 'active'
-       || $params->{'status'} eq 'active' ) {
-
-    push @where, FS::cust_pkg->active_sql();
-
-  } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
-            || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
-
-    push @where, FS::cust_pkg->not_yet_billed_sql();
-
-  } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
-            || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
-
-    push @where, FS::cust_pkg->inactive_sql();
-
-  } elsif (    $params->{'magic'}  eq 'suspended'
-            || $params->{'status'} eq 'suspended'  ) {
-
-    push @where, FS::cust_pkg->suspended_sql();
-
-  } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
-            || $params->{'status'} =~ /^cancell?ed$/ ) {
-
-    push @where, FS::cust_pkg->cancelled_sql();
-
-  }
-
-  ###
-  # parse package class
-  ###
-
-  if ( exists($params->{'classnum'}) ) {
-
-    my @classnum = ();
-    if ( ref($params->{'classnum'}) ) {
-
-      if ( ref($params->{'classnum'}) eq 'HASH' ) {
-        @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
-      } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
-        @classnum = @{ $params->{'classnum'} };
-      } else {
-        die 'unhandled classnum ref '. $params->{'classnum'};
-      }
-
-
-    } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
-      @classnum = ( $1 );
-    }
-
-    if ( @classnum ) {
-
-      my @c_where = ();
-      my @nums = grep $_, @classnum;
-      push @c_where, 'part_pkg.classnum IN ('. join(',', at nums). ')' if @nums;
-      my $null = scalar( grep { $_ eq '' } @classnum );
-      push @c_where, 'part_pkg.classnum IS NULL' if $null;
-
-      if ( scalar(@c_where) == 1 ) {
-        push @where, @c_where;
-      } elsif ( @c_where ) {
-        push @where, ' ( '. join(' OR ', @c_where). ' ) ';
-      }
-
-    }
-    
-
-  }
-
-  ###
-  # parse package report options
-  ###
-
-  my @report_option = ();
-  if ( exists($params->{'report_option'}) ) {
-    if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
-      @report_option = @{ $params->{'report_option'} };
-    } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
-      @report_option = split(',', $1);
-    }
-
-  }
-
-  if (@report_option) {
-    # this will result in the empty set for the dangling comma case as it should
-    push @where, 
-      map{ "0 < ( SELECT count(*) FROM part_pkg_option
-                    WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
-                    AND optionname = 'report_option_$_'
-                    AND optionvalue = '1' )"
-         } @report_option;
-  }
-
-  foreach my $any ( grep /^report_option_any/, keys %$params ) {
-
-    my @report_option_any = ();
-    if ( ref($params->{$any}) eq 'ARRAY' ) {
-      @report_option_any = @{ $params->{$any} };
-    } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
-      @report_option_any = split(',', $1);
-    }
-
-    if (@report_option_any) {
-      # this will result in the empty set for the dangling comma case as it should
-      push @where, ' ( '. join(' OR ',
-        map{ "0 < ( SELECT count(*) FROM part_pkg_option
-                      WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
-                      AND optionname = 'report_option_$_'
-                      AND optionvalue = '1' )"
-           } @report_option_any
-      ). ' ) ';
-    }
-
-  }
-
-  ###
-  # parse custom
-  ###
-
-  push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
-
-  ###
-  # parse fcc_line
-  ###
-
-  push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
-                                                        if $params->{fcc_line};
-
-  ###
-  # parse censustract
-  ###
-
-  if ( exists($params->{'censustract'}) ) {
-    $params->{'censustract'} =~ /^([.\d]*)$/;
-    my $censustract = "cust_location.censustract = '$1'";
-    $censustract .= ' OR cust_location.censustract is NULL' unless $1;
-    push @where,  "( $censustract )";
-  }
-
-  ###
-  # parse censustract2
-  ###
-  if ( exists($params->{'censustract2'})
-       && $params->{'censustract2'} =~ /^(\d*)$/
-     )
-  {
-    if ($1) {
-      push @where, "cust_location.censustract LIKE '$1%'";
-    } else {
-      push @where,
-        "( cust_location.censustract = '' OR cust_location.censustract IS NULL )";
-    }
-  }
-
-  ###
-  # parse country/state
-  ###
-  for (qw(state country)) { # parsing rules are the same for these
-  if ( exists($params->{$_}) 
-    && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
-    {
-      # XXX post-2.3 only--before that, state/country may be in cust_main
-      push @where, "cust_location.$_ = '$1'";
-    }
-  }
-
-  ###
-  # location_* flags
-  ###
-  if ( $params->{location_cust} xor $params->{location_nocust} ) {
-    my $op = $params->{location_cust} ? '=' : '!=';
-    push @where, "cust_location.locationnum $op cust_main.ship_locationnum";
-  }
-  if ( $params->{location_census} xor $params->{location_nocensus} ) {
-    my $op = $params->{location_census} ? "IS NOT NULL" : "IS NULL";
-    push @where, "cust_location.censustract $op";
-  }
-  if ( $params->{location_geocode} xor $params->{location_nogeocode} ) {
-    my $op = $params->{location_geocode} ? "IS NOT NULL" : "IS NULL";
-    push @where, "cust_location.geocode $op";
-  }
-
-  ###
-  # parse part_pkg
-  ###
-
-  if ( ref($params->{'pkgpart'}) ) {
-
-    my @pkgpart = ();
-    if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
-      @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
-    } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
-      @pkgpart = @{ $params->{'pkgpart'} };
-    } else {
-      die 'unhandled pkgpart ref '. $params->{'pkgpart'};
-    }
-
-    @pkgpart = grep /^(\d+)$/, @pkgpart;
-
-    push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
-
-  } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
-    push @where, "pkgpart = $1";
-  } 
-
-  ###
-  # parse dates
-  ###
-
-  my $orderby = '';
-
-  #false laziness w/report_cust_pkg.html
-  my %disable = (
-    'all'             => {},
-    'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
-    'active'          => { 'susp'=>1, 'cancel'=>1 },
-    'suspended'       => { 'cancel' => 1 },
-    'cancelled'       => {},
-    ''                => {},
-  );
-
-  if( exists($params->{'active'} ) ) {
-    # This overrides all the other date-related fields
-    my($beginning, $ending) = @{$params->{'active'}};
-    push @where,
-      "cust_pkg.setup IS NOT NULL",
-      "cust_pkg.setup <= $ending",
-      "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
-      "NOT (".FS::cust_pkg->onetime_sql . ")";
-  }
-  else {
-    foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) {
-
-      next unless exists($params->{$field});
-
-      my($beginning, $ending) = @{$params->{$field}};
-
-      next if $beginning == 0 && $ending == 4294967295;
-
-      push @where,
-        "cust_pkg.$field IS NOT NULL",
-        "cust_pkg.$field >= $beginning",
-        "cust_pkg.$field <= $ending";
-
-      $orderby ||= "ORDER BY cust_pkg.$field";
-
-    }
-  }
-
-  $orderby ||= 'ORDER BY bill';
-
-  ###
-  # parse magic, legacy, etc.
-  ###
-
-  if ( $params->{'magic'} &&
-       $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
-  ) {
-
-    $orderby = 'ORDER BY pkgnum';
-
-    if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
-      push @where, "pkgpart = $1";
-    }
-
-  } elsif ( $params->{'query'} eq 'pkgnum' ) {
-
-    $orderby = 'ORDER BY pkgnum';
-
-  } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
-
-    $orderby = 'ORDER BY pkgnum';
-
-    push @where, '0 < (
-      SELECT count(*) FROM pkg_svc
-       WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
-         AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
-                                   WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
-                                     AND cust_svc.svcpart = pkg_svc.svcpart
-                                )
-    )';
-  
-  }
-
-  ##
-  # setup queries, links, subs, etc. for the search
-  ##
-
-  # here is the agent virtualization
-  if ($params->{CurrentUser}) {
-    my $access_user =
-      qsearchs('access_user', { username => $params->{CurrentUser} });
-
-    if ($access_user) {
-      push @where, $access_user->agentnums_sql('table'=>'cust_main');
-    } else {
-      push @where, "1=0";
-    }
-  } else {
-    push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
-  }
-
-  my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
-
-  my $addl_from = 'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
-                  'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
-                  'LEFT JOIN cust_location USING ( locationnum ) '.
-                  FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg');
-
-  my $select;
-  my $count_query;
-  if ( $params->{'select_zip5'} ) {
-    my $zip = 'cust_location.zip';
-
-    $select = "DISTINCT substr($zip,1,5) as zip";
-    $orderby = "ORDER BY substr($zip,1,5)";
-    $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
-  } else {
-    $select = join(', ',
-                         'cust_pkg.*',
-                         ( map "part_pkg.$_", qw( pkg freq ) ),
-                         'pkg_class.classname',
-                         'cust_main.custnum AS cust_main_custnum',
-                         FS::UI::Web::cust_sql_fields(
-                           $params->{'cust_fields'}
-                         ),
-                  );
-    $count_query = 'SELECT COUNT(*)';
-  }
-
-  $count_query .= " FROM cust_pkg $addl_from $extra_sql";
-
-  my $sql_query = {
-    'table'       => 'cust_pkg',
-    'hashref'     => {},
-    'select'      => $select,
-    'extra_sql'   => $extra_sql,
-    'order_by'    => $orderby,
-    'addl_from'   => $addl_from,
-    'count_query' => $count_query,
-  };
-
-}
-
 =item fcc_477_count
 
 Returns a list of two package counts.  The first is a count of packages
diff --git a/FS/FS/cust_pkg/Search.pm b/FS/FS/cust_pkg/Search.pm
new file mode 100644
index 0000000..43b8703
--- /dev/null
+++ b/FS/FS/cust_pkg/Search.pm
@@ -0,0 +1,523 @@
+package FS::cust_pkg::Search;
+
+use strict;
+use FS::CurrentUser;
+use FS::UI::Web;
+use FS::cust_main;
+use FS::cust_pkg;
+
+=item search HASHREF
+
+(Class method)
+
+Returns a qsearch hash expression to search for parameters specified in HASHREF.
+Valid parameters are
+
+=over 4
+
+=item agentnum
+
+=item magic
+
+active, inactive, suspended, cancel (or cancelled)
+
+=item status
+
+active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
+
+=item custom
+
+ boolean selects custom packages
+
+=item classnum
+
+=item pkgpart
+
+pkgpart or arrayref or hashref of pkgparts
+
+=item setup
+
+arrayref of beginning and ending epoch date
+
+=item last_bill
+
+arrayref of beginning and ending epoch date
+
+=item bill
+
+arrayref of beginning and ending epoch date
+
+=item adjourn
+
+arrayref of beginning and ending epoch date
+
+=item susp
+
+arrayref of beginning and ending epoch date
+
+=item expire
+
+arrayref of beginning and ending epoch date
+
+=item cancel
+
+arrayref of beginning and ending epoch date
+
+=item query
+
+pkgnum or APKG_pkgnum
+
+=item cust_fields
+
+a value suited to passing to FS::UI::Web::cust_header
+
+=item CurrentUser
+
+specifies the user for agent virtualization
+
+=item fcc_line
+
+boolean; if true, returns only packages with more than 0 FCC phone lines.
+
+=item state, country
+
+Limit to packages with a service location in the specified state and country.
+For FCC 477 reporting, mostly.
+
+=item location_cust
+
+Limit to packages whose service locations are the same as the customer's 
+default service location.
+
+=item location_nocust
+
+Limit to packages whose service locations are not the customer's default 
+service location.
+
+=item location_census
+
+Limit to packages whose service locations have census tracts.
+
+=item location_nocensus
+
+Limit to packages whose service locations do not have a census tract.
+
+=item location_geocode
+
+Limit to packages whose locations have geocodes.
+
+=item location_geocode
+
+Limit to packages whose locations do not have geocodes.
+
+=back
+
+=cut
+
+sub search {
+  my ($class, $params) = @_;
+  my @where = ();
+
+  ##
+  # parse agent
+  ##
+
+  if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
+    push @where,
+      "cust_main.agentnum = $1";
+  }
+
+  ##
+  # parse cust_status
+  ##
+
+  if ( $params->{'cust_status'} =~ /^([a-z]+)$/ ) {
+    push @where, FS::cust_main->cust_status_sql . " = '$1' ";
+  }
+
+  ##
+  # parse customer sales person
+  ##
+
+  if ( $params->{'cust_main_salesnum'} =~ /^(\d+)$/ ) {
+    push @where, ($1 > 0) ? "cust_main.salesnum = $1"
+                          : 'cust_main.salesnum IS NULL';
+  }
+
+
+  ##
+  # parse sales person
+  ##
+
+  if ( $params->{'salesnum'} =~ /^(\d+)$/ ) {
+    push @where, ($1 > 0) ? "cust_pkg.salesnum = $1"
+                          : 'cust_pkg.salesnum IS NULL';
+  }
+
+  ##
+  # parse custnum
+  ##
+
+  if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
+    push @where,
+      "cust_pkg.custnum = $1";
+  }
+
+  ##
+  # custbatch
+  ##
+
+  if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
+    push @where,
+      "cust_pkg.pkgbatch = '$1'";
+  }
+
+  ##
+  # parse status
+  ##
+
+  if (    $params->{'magic'}  eq 'active'
+       || $params->{'status'} eq 'active' ) {
+
+    push @where, FS::cust_pkg->active_sql();
+
+  } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
+            || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
+
+    push @where, FS::cust_pkg->not_yet_billed_sql();
+
+  } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
+            || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
+
+    push @where, FS::cust_pkg->inactive_sql();
+
+  } elsif (    $params->{'magic'}  eq 'suspended'
+            || $params->{'status'} eq 'suspended'  ) {
+
+    push @where, FS::cust_pkg->suspended_sql();
+
+  } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
+            || $params->{'status'} =~ /^cancell?ed$/ ) {
+
+    push @where, FS::cust_pkg->cancelled_sql();
+
+  }
+
+  ###
+  # parse package class
+  ###
+
+  if ( exists($params->{'classnum'}) ) {
+
+    my @classnum = ();
+    if ( ref($params->{'classnum'}) ) {
+
+      if ( ref($params->{'classnum'}) eq 'HASH' ) {
+        @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
+      } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
+        @classnum = @{ $params->{'classnum'} };
+      } else {
+        die 'unhandled classnum ref '. $params->{'classnum'};
+      }
+
+
+    } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
+      @classnum = ( $1 );
+    }
+
+    if ( @classnum ) {
+
+      my @c_where = ();
+      my @nums = grep $_, @classnum;
+      push @c_where, 'part_pkg.classnum IN ('. join(',', at nums). ')' if @nums;
+      my $null = scalar( grep { $_ eq '' } @classnum );
+      push @c_where, 'part_pkg.classnum IS NULL' if $null;
+
+      if ( scalar(@c_where) == 1 ) {
+        push @where, @c_where;
+      } elsif ( @c_where ) {
+        push @where, ' ( '. join(' OR ', @c_where). ' ) ';
+      }
+
+    }
+    
+
+  }
+
+  ###
+  # parse package report options
+  ###
+
+  my @report_option = ();
+  if ( exists($params->{'report_option'}) ) {
+    if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
+      @report_option = @{ $params->{'report_option'} };
+    } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
+      @report_option = split(',', $1);
+    }
+
+  }
+
+  if (@report_option) {
+    # this will result in the empty set for the dangling comma case as it should
+    push @where, 
+      map{ "0 < ( SELECT count(*) FROM part_pkg_option
+                    WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
+                    AND optionname = 'report_option_$_'
+                    AND optionvalue = '1' )"
+         } @report_option;
+  }
+
+  foreach my $any ( grep /^report_option_any/, keys %$params ) {
+
+    my @report_option_any = ();
+    if ( ref($params->{$any}) eq 'ARRAY' ) {
+      @report_option_any = @{ $params->{$any} };
+    } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
+      @report_option_any = split(',', $1);
+    }
+
+    if (@report_option_any) {
+      # this will result in the empty set for the dangling comma case as it should
+      push @where, ' ( '. join(' OR ',
+        map{ "0 < ( SELECT count(*) FROM part_pkg_option
+                      WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
+                      AND optionname = 'report_option_$_'
+                      AND optionvalue = '1' )"
+           } @report_option_any
+      ). ' ) ';
+    }
+
+  }
+
+  ###
+  # parse custom
+  ###
+
+  push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
+
+  ###
+  # parse fcc_line
+  ###
+
+  push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
+                                                        if $params->{fcc_line};
+
+  ###
+  # parse censustract
+  ###
+
+  if ( exists($params->{'censustract'}) ) {
+    $params->{'censustract'} =~ /^([.\d]*)$/;
+    my $censustract = "cust_location.censustract = '$1'";
+    $censustract .= ' OR cust_location.censustract is NULL' unless $1;
+    push @where,  "( $censustract )";
+  }
+
+  ###
+  # parse censustract2
+  ###
+  if ( exists($params->{'censustract2'})
+       && $params->{'censustract2'} =~ /^(\d*)$/
+     )
+  {
+    if ($1) {
+      push @where, "cust_location.censustract LIKE '$1%'";
+    } else {
+      push @where,
+        "( cust_location.censustract = '' OR cust_location.censustract IS NULL )";
+    }
+  }
+
+  ###
+  # parse country/state
+  ###
+  for (qw(state country)) { # parsing rules are the same for these
+  if ( exists($params->{$_}) 
+    && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
+    {
+      # XXX post-2.3 only--before that, state/country may be in cust_main
+      push @where, "cust_location.$_ = '$1'";
+    }
+  }
+
+  ###
+  # location_* flags
+  ###
+  if ( $params->{location_cust} xor $params->{location_nocust} ) {
+    my $op = $params->{location_cust} ? '=' : '!=';
+    push @where, "cust_location.locationnum $op cust_main.ship_locationnum";
+  }
+  if ( $params->{location_census} xor $params->{location_nocensus} ) {
+    my $op = $params->{location_census} ? "IS NOT NULL" : "IS NULL";
+    push @where, "cust_location.censustract $op";
+  }
+  if ( $params->{location_geocode} xor $params->{location_nogeocode} ) {
+    my $op = $params->{location_geocode} ? "IS NOT NULL" : "IS NULL";
+    push @where, "cust_location.geocode $op";
+  }
+
+  ###
+  # parse part_pkg
+  ###
+
+  if ( ref($params->{'pkgpart'}) ) {
+
+    my @pkgpart = ();
+    if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
+      @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
+    } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
+      @pkgpart = @{ $params->{'pkgpart'} };
+    } else {
+      die 'unhandled pkgpart ref '. $params->{'pkgpart'};
+    }
+
+    @pkgpart = grep /^(\d+)$/, @pkgpart;
+
+    push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
+
+  } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
+    push @where, "pkgpart = $1";
+  } 
+
+  ###
+  # parse dates
+  ###
+
+  my $orderby = '';
+
+  #false laziness w/report_cust_pkg.html
+  my %disable = (
+    'all'             => {},
+    'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
+    'active'          => { 'susp'=>1, 'cancel'=>1 },
+    'suspended'       => { 'cancel' => 1 },
+    'cancelled'       => {},
+    ''                => {},
+  );
+
+  if( exists($params->{'active'} ) ) {
+    # This overrides all the other date-related fields
+    my($beginning, $ending) = @{$params->{'active'}};
+    push @where,
+      "cust_pkg.setup IS NOT NULL",
+      "cust_pkg.setup <= $ending",
+      "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
+      "NOT (".FS::cust_pkg->onetime_sql . ")";
+  }
+  else {
+    foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) {
+
+      next unless exists($params->{$field});
+
+      my($beginning, $ending) = @{$params->{$field}};
+
+      next if $beginning == 0 && $ending == 4294967295;
+
+      push @where,
+        "cust_pkg.$field IS NOT NULL",
+        "cust_pkg.$field >= $beginning",
+        "cust_pkg.$field <= $ending";
+
+      $orderby ||= "ORDER BY cust_pkg.$field";
+
+    }
+  }
+
+  $orderby ||= 'ORDER BY bill';
+
+  ###
+  # parse magic, legacy, etc.
+  ###
+
+  if ( $params->{'magic'} &&
+       $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
+  ) {
+
+    $orderby = 'ORDER BY pkgnum';
+
+    if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
+      push @where, "pkgpart = $1";
+    }
+
+  } elsif ( $params->{'query'} eq 'pkgnum' ) {
+
+    $orderby = 'ORDER BY pkgnum';
+
+  } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
+
+    $orderby = 'ORDER BY pkgnum';
+
+    push @where, '0 < (
+      SELECT count(*) FROM pkg_svc
+       WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
+         AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
+                                   WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
+                                     AND cust_svc.svcpart = pkg_svc.svcpart
+                                )
+    )';
+  
+  }
+
+  ##
+  # setup queries, links, subs, etc. for the search
+  ##
+
+  # here is the agent virtualization
+  if ($params->{CurrentUser}) {
+    my $access_user =
+      qsearchs('access_user', { username => $params->{CurrentUser} });
+
+    if ($access_user) {
+      push @where, $access_user->agentnums_sql('table'=>'cust_main');
+    } else {
+      push @where, "1=0";
+    }
+  } else {
+    push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
+  }
+
+  my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
+
+  my $addl_from = 'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
+                  'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
+                  'LEFT JOIN cust_location USING ( locationnum ) '.
+                  FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg');
+
+  my $select;
+  my $count_query;
+  if ( $params->{'select_zip5'} ) {
+    my $zip = 'cust_location.zip';
+
+    $select = "DISTINCT substr($zip,1,5) as zip";
+    $orderby = "ORDER BY substr($zip,1,5)";
+    $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
+  } else {
+    $select = join(', ',
+                         'cust_pkg.*',
+                         ( map "part_pkg.$_", qw( pkg freq ) ),
+                         'pkg_class.classname',
+                         'cust_main.custnum AS cust_main_custnum',
+                         FS::UI::Web::cust_sql_fields(
+                           $params->{'cust_fields'}
+                         ),
+                  );
+    $count_query = 'SELECT COUNT(*)';
+  }
+
+  $count_query .= " FROM cust_pkg $addl_from $extra_sql";
+
+  my $sql_query = {
+    'table'       => 'cust_pkg',
+    'hashref'     => {},
+    'select'      => $select,
+    'extra_sql'   => $extra_sql,
+    'order_by'    => $orderby,
+    'addl_from'   => $addl_from,
+    'count_query' => $count_query,
+  };
+
+}
+
+1;
+
diff --git a/FS/MANIFEST b/FS/MANIFEST
index e3d5ff8..a0a71c9 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -85,6 +85,8 @@ FS/cust_bill_pay.pm
 FS/cust_pay_batch.pm
 FS/cust_pay_refund.pm
 FS/cust_pkg.pm
+FS/cust_pkg/Import.pm
+FS/cust_pkg/Search.pm
 FS/cust_refund.pm
 FS/cust_credit_refund.pm
 FS/cust_svc.pm

commit 94aa5503bd56d53b99d99521a77ede066be6a0f1
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Sun Feb 16 15:30:31 2014 -0800

    move cust_main::batch_charge to a file of its own

diff --git a/FS/MANIFEST b/FS/MANIFEST
index bedc26c..e3d5ff8 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -73,6 +73,7 @@ FS/cust_main/Billing.pm
 FS/cust_main/Billing_Discount.pm
 FS/cust_main/Billing_Realtime.pm
 FS/cust_main/Import.pm
+FS/cust_main/Import_Charges.pm
 FS/cust_main/Packages.pm
 FS/cust_main/Search.pm
 FS/cust_main_Mixin.pm
@@ -455,8 +456,6 @@ FS/tax_rate_location.pm
 t/tax_rate_location.t
 FS/cust_bill_pkg_tax_rate_location.pm
 t/cust_bill_pkg_tax_rate_location.t
-FS/cust_recon.pm
-t/cust_recon.t
 FS/part_pkg_report_option.pm
 t/part_pkg_report_option.t
 FS/cust_main_exemption.pm
@@ -755,3 +754,5 @@ FS/pbx_device.pm
 t/pbx_device.t
 FS/extension_device.pm
 t/extension_device.t
+FS/cust_main_credit_limit.pm
+t/cust_main_credit_limit.t

commit c4e26585cdbd2cd086ed813a02b963ffccfebc55
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Sun Feb 16 15:18:40 2014 -0800

    move cust_main::batch_charge to a file of its own

diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index a4eac45..73d7556 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -165,6 +165,7 @@ if ( -e $addl_handler_use_file ) {
   use FS::h_cust_main;
   use FS::cust_main::Search qw(smart_search);
   use FS::cust_main::Import;
+  use FS::cust_main::Import_Charges;
   use FS::cust_main_county;
   use FS::cust_location;
   use FS::cust_pay;
@@ -371,6 +372,7 @@ if ( -e $addl_handler_use_file ) {
   use FS::pbx_extension;
   use FS::pbx_device;
   use FS::extension_device;
+  use FS::cust_main_credit_limit;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 054d6c2..57c0095 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -4598,121 +4598,6 @@ sub search {
 
 =over 4
 
-=item batch_charge
-
-=cut
-
-sub batch_charge {
-  my $param = shift;
-  #warn join('-',keys %$param);
-  my $fh = $param->{filehandle};
-  my $agentnum = $param->{agentnum};
-  my $format = $param->{format};
-
-  my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
-
-  my @fields;
-  if ( $format eq 'simple' ) {
-    @fields = qw( custnum agent_custid amount pkg );
-  } else {
-    die "unknown format $format";
-  }
-
-  eval "use Text::CSV_XS;";
-  die $@ if $@;
-
-  my $csv = new Text::CSV_XS;
-  #warn $csv;
-  #warn $fh;
-
-  my $imported = 0;
-  #my $columns;
-
-  local $SIG{HUP} = 'IGNORE';
-  local $SIG{INT} = 'IGNORE';
-  local $SIG{QUIT} = 'IGNORE';
-  local $SIG{TERM} = 'IGNORE';
-  local $SIG{TSTP} = 'IGNORE';
-  local $SIG{PIPE} = 'IGNORE';
-
-  my $oldAutoCommit = $FS::UID::AutoCommit;
-  local $FS::UID::AutoCommit = 0;
-  my $dbh = dbh;
-  
-  #while ( $columns = $csv->getline($fh) ) {
-  my $line;
-  while ( defined($line=<$fh>) ) {
-
-    $csv->parse($line) or do {
-      $dbh->rollback if $oldAutoCommit;
-      return "can't parse: ". $csv->error_input();
-    };
-
-    my @columns = $csv->fields();
-    #warn join('-', at columns);
-
-    my %row = ();
-    foreach my $field ( @fields ) {
-      $row{$field} = shift @columns;
-    }
-
-    if ( $row{custnum} && $row{agent_custid} ) {
-      dbh->rollback if $oldAutoCommit;
-      return "can't specify custnum with agent_custid $row{agent_custid}";
-    }
-
-    my %hash = ();
-    if ( $row{agent_custid} && $agentnum ) {
-      %hash = ( 'agent_custid' => $row{agent_custid},
-                'agentnum'     => $agentnum,
-              );
-    }
-
-    if ( $row{custnum} ) {
-      %hash = ( 'custnum' => $row{custnum} );
-    }
-
-    unless ( scalar(keys %hash) ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "can't find customer without custnum or agent_custid and agentnum";
-    }
-
-    my $cust_main = qsearchs('cust_main', { %hash } );
-    unless ( $cust_main ) {
-      $dbh->rollback if $oldAutoCommit;
-      my $custnum = $row{custnum} || $row{agent_custid};
-      return "unknown custnum $custnum";
-    }
-
-    if ( $row{'amount'} > 0 ) {
-      my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
-      $imported++;
-    } elsif ( $row{'amount'} < 0 ) {
-      my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
-                                      $row{'pkg'}                         );
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
-      $imported++;
-    } else {
-      #hmm?
-    }
-
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
-  return "Empty file!" unless $imported;
-
-  ''; #no error
-
-}
-
 =item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
 
 Deprecated.  Use event notification and message templates 
diff --git a/FS/FS/cust_main/Import_Charges.pm b/FS/FS/cust_main/Import_Charges.pm
new file mode 100644
index 0000000..312a606
--- /dev/null
+++ b/FS/FS/cust_main/Import_Charges.pm
@@ -0,0 +1,149 @@
+package FS::cust_main::Import_Charges;
+#actually no specific reason it lives under FS::cust_main:: othan than it calls
+# a thing on cust_main objects.  not part of the inheritence, just providess a
+# subroutine for misc/process/cust_main-import_charges.cgi
+
+use strict;
+use Text::CSV_XS;
+use FS::UID qw( dbh );
+use FS::CurrentUser;
+use FS::Record qw( qsearchs );
+use FS::cust_main;
+
+=head1 NAME
+
+FS::cust_main::Import_Charges - Batch charge importing
+
+=head1 SYNOPSIS
+
+  use FS::cust_main::Import_Charges;
+
+  my $error = 
+    FS::cust_main::Import_charges::batch_charge( {
+      filehandle => $fh,
+      'agentnum' => scalar($cgi->param('agentnum')),
+      'format'   => scalar($cgi->param('format')),
+    } );
+
+=head1 DESCRIPTION
+
+Batch customer charging.
+
+
+=head1 SUBROUTINES
+
+=over 4
+
+=item batch_charge
+
+=cut
+
+sub batch_charge {
+  my $param = shift;
+  #warn join('-',keys %$param);
+  my $fh = $param->{filehandle};
+  my $agentnum = $param->{agentnum};
+  my $format = $param->{format};
+
+  my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
+
+  my @fields;
+  if ( $format eq 'simple' ) {
+    @fields = qw( custnum agent_custid amount pkg );
+  } else {
+    die "unknown format $format";
+  }
+
+  my $csv = new Text::CSV_XS;
+  #warn $csv;
+  #warn $fh;
+
+  my $imported = 0;
+  #my $columns;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+  
+  #while ( $columns = $csv->getline($fh) ) {
+  my $line;
+  while ( defined($line=<$fh>) ) {
+
+    $csv->parse($line) or do {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't parse: ". $csv->error_input();
+    };
+
+    my @columns = $csv->fields();
+    #warn join('-', at columns);
+
+    my %row = ();
+    foreach my $field ( @fields ) {
+      $row{$field} = shift @columns;
+    }
+
+    if ( $row{custnum} && $row{agent_custid} ) {
+      dbh->rollback if $oldAutoCommit;
+      return "can't specify custnum with agent_custid $row{agent_custid}";
+    }
+
+    my %hash = ();
+    if ( $row{agent_custid} && $agentnum ) {
+      %hash = ( 'agent_custid' => $row{agent_custid},
+                'agentnum'     => $agentnum,
+              );
+    }
+
+    if ( $row{custnum} ) {
+      %hash = ( 'custnum' => $row{custnum} );
+    }
+
+    unless ( scalar(keys %hash) ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "can't find customer without custnum or agent_custid and agentnum";
+    }
+
+    my $cust_main = qsearchs('cust_main', { %hash } );
+    unless ( $cust_main ) {
+      $dbh->rollback if $oldAutoCommit;
+      my $custnum = $row{custnum} || $row{agent_custid};
+      return "unknown custnum $custnum";
+    }
+
+    if ( $row{'amount'} > 0 ) {
+      my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+      $imported++;
+    } elsif ( $row{'amount'} < 0 ) {
+      my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
+                                      $row{'pkg'}                         );
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+      $imported++;
+    } else {
+      #hmm?
+    }
+
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  return "Empty file!" unless $imported;
+
+  ''; #no error
+
+}
+
+1;
diff --git a/httemplate/misc/process/cust_main-import_charges.cgi b/httemplate/misc/process/cust_main-import_charges.cgi
index bda3e3b..d877ad1 100644
--- a/httemplate/misc/process/cust_main-import_charges.cgi
+++ b/httemplate/misc/process/cust_main-import_charges.cgi
@@ -14,7 +14,7 @@ my $fh = $cgi->upload('csvfile');
 #warn $fh;
 
 my $error = defined($fh)
-  ? FS::cust_main::batch_charge( {
+  ? FS::cust_main::Import_Charges::batch_charge( {
       filehandle => $fh,
       'agentnum' => scalar($cgi->param('agentnum')),
       'format'   => scalar($cgi->param('format')),

-----------------------------------------------------------------------

Summary of changes:
 FS/FS.pm                                           |    2 +
 FS/FS/Mason.pm                                     |    2 +
 FS/FS/Schema.pm                                    |   20 +
 FS/FS/cust_main.pm                                 |  116 +-----
 FS/FS/cust_main/Credit_Limit.pm                    |   87 ++++
 FS/FS/cust_main/Import_Charges.pm                  |  149 ++++++
 ...tension_device.pm => cust_main_credit_limit.pm} |   43 +-
 FS/FS/cust_pkg.pm                                  |  520 +-------------------
 FS/FS/cust_pkg/Search.pm                           |  523 ++++++++++++++++++++
 FS/MANIFEST                                        |    7 +-
 FS/bin/freeside-cdrrated                           |   27 +-
 FS/t/{AccessRight.t => cust_main_credit_limit.t}   |    2 +-
 .../misc/process/cust_main-import_charges.cgi      |    2 +-
 httemplate/search/cust_main_credit_limit.html      |   63 +++
 .../search/report_cust_main_credit_limit.html      |   24 +
 httemplate/view/cust_main/billing.html             |    3 +
 16 files changed, 934 insertions(+), 656 deletions(-)
 create mode 100644 FS/FS/cust_main/Credit_Limit.pm
 create mode 100644 FS/FS/cust_main/Import_Charges.pm
 copy FS/FS/{extension_device.pm => cust_main_credit_limit.pm} (55%)
 create mode 100644 FS/FS/cust_pkg/Search.pm
 copy FS/t/{AccessRight.t => cust_main_credit_limit.t} (75%)
 create mode 100644 httemplate/search/cust_main_credit_limit.html
 create mode 100644 httemplate/search/report_cust_main_credit_limit.html




More information about the freeside-commits mailing list