so Search.tsf and Search.rdf work
[freeside.git] / FS / FS / cust_main.pm
index 2f01111..1edd319 100644 (file)
@@ -1,26 +1,32 @@
 package FS::cust_main;
 
 use strict;
-use vars qw( @ISA $conf $DEBUG $import );
+use vars qw( @ISA @EXPORT_OK $DEBUG $conf @encrypted_fields
+             $import $skip_fuzzyfiles );
 use vars qw( $realtime_bop_decline_quiet ); #ugh
 use Safe;
 use Carp;
+use Exporter;
 BEGIN {
   eval "use Time::Local;";
   die "Time::Local minimum version 1.05 required with Perl versions before 5.6"
     if $] < 5.006 && !defined($Time::Local::VERSION);
-  eval "use Time::Local qw(timelocal timelocal_nocheck);";
+  #eval "use Time::Local qw(timelocal timelocal_nocheck);";
+  eval "use Time::Local qw(timelocal_nocheck);";
 }
 use Date::Format;
 #use Date::Manip;
+use String::Approx qw(amatch);
 use Business::CreditCard;
 use FS::UID qw( getotaker dbh );
 use FS::Record qw( qsearchs qsearch dbdef );
 use FS::Misc qw( send_email );
 use FS::cust_pkg;
+use FS::cust_svc;
 use FS::cust_bill;
 use FS::cust_bill_pkg;
 use FS::cust_pay;
+use FS::cust_pay_void;
 use FS::cust_credit;
 use FS::cust_refund;
 use FS::part_referral;
@@ -40,12 +46,17 @@ use FS::Msgcat qw(gettext);
 
 @ISA = qw( FS::Record );
 
+@EXPORT_OK = qw( smart_search );
+
 $realtime_bop_decline_quiet = 0;
 
 $DEBUG = 0;
 #$DEBUG = 1;
 
 $import = 0;
+$skip_fuzzyfiles = 0;
+
+@encrypted_fields = ('payinfo', 'paycvv');
 
 #ask FS::UID to run this stuff for us later
 #$FS::UID::callback{'FS::cust_main'} = sub { 
@@ -169,11 +180,84 @@ FS::Record.  The following fields are currently supported:
 
 =item ship_fax - phone (optional)
 
-=item payby - I<CARD> (credit card - automatic), I<DCRD> (credit card - on-demand), I<CHEK> (electronic check - automatic), I<DCHK> (electronic check - on-demand), I<LECB> (Phone bill billing), I<BILL> (billing), I<COMP> (free), or I<PREPAY> (special billing type: applies a credit - see L<FS::prepay_credit> and sets billing type to I<BILL>)
+=item payby 
+
+I<CARD> (credit card - automatic), I<DCRD> (credit card - on-demand), I<CHEK> (electronic check - automatic), I<DCHK> (electronic check - on-demand), I<LECB> (Phone bill billing), I<BILL> (billing), I<COMP> (free), or I<PREPAY> (special billing type: applies a credit - see L<FS::prepay_credit> and sets billing type to I<BILL>)
+
+=item payinfo 
+
+Card Number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
+
+=cut 
+
+sub payinfo {
+  my($self,$payinfo) = @_;
+  if ( defined($payinfo) ) {
+    $self->paymask($payinfo);
+    $self->setfield('payinfo', $payinfo); # This is okay since we are the 'setter'
+  } else {
+    $payinfo = $self->getfield('payinfo'); # This is okay since we are the 'getter'
+    return $payinfo;
+  }
+}
+
+
+=item paycvv
+Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
+
+=cut
+
+=item paymask - Masked payment type
+
+=over 4 
+
+=item Credit Cards
+
+Mask all but the last four characters.
+
+=item Checks
+
+Mask all but last 2 of account number and bank routing number.
+
+=item Others
+
+Do nothing, return the unmasked string.
+
+=back
+
+=cut 
+
+sub paymask {
+  my($self,$value)=@_;
+
+  # If it doesn't exist then generate it
+  my $paymask=$self->getfield('paymask');
+  if (!defined($value) && (!defined($paymask) || $paymask eq '')) {
+    $value = $self->payinfo;
+  }
+
+  if ( defined($value) && !$self->is_encrypted($value)) {
+    my $payinfo = $value;
+    my $payby = $self->payby;
+    if ($payby eq 'CARD' || $payby eq 'DCARD') { # Credit Cards (Show last four)
+      $paymask = 'x'x(length($payinfo)-4). substr($payinfo,(length($payinfo)-4));
+    } elsif ($payby eq 'CHEK' ||
+             $payby eq 'DCHK' ) { # Checks (Show last 2 @ bank)
+      my( $account, $aba ) = split('@', $payinfo );
+      $paymask = 'x'x(length($account)-2). substr($account,(length($account)-2))."@".$aba;
+    } else { # Tie up loose ends
+      $paymask = $payinfo;
+    }
+    $self->setfield('paymask', $paymask); # This is okay since we are the 'setter'
+  } elsif (defined($value) && $self->is_encrypted($value)) {
+    $paymask = 'N/A';
+  }
+  return $paymask;
+}
+
 
-=item payinfo - card number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
 
-=item paycvv - Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
 
 =item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
 
@@ -265,20 +349,28 @@ sub insert {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $amount = 0;
+  my $prepay_credit = '';
   my $seconds = 0;
   if ( $self->payby eq 'PREPAY' ) {
     $self->payby('BILL');
-    my $prepay_credit = qsearchs(
+    $prepay_credit = qsearchs(
       'prepay_credit',
       { 'identifier' => $self->payinfo },
       '',
       'FOR UPDATE'
     );
-    warn "WARNING: can't find pre-found prepay_credit: ". $self->payinfo
-      unless $prepay_credit;
-    $amount = $prepay_credit->amount;
+    unless ( $prepay_credit ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Invalid prepaid card: ". $self->payinfo;
+    }
     $seconds = $prepay_credit->seconds;
+    if ( $prepay_credit->agentnum ) {
+      if ( $self->agentnum && $self->agentnum != $prepay_credit->agentnum ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "prepaid card not valid for agent ". $self->agentnum;
+      }
+      $self->agentnum($prepay_credit->agentnum);
+    }
     my $error = $prepay_credit->delete;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
@@ -315,22 +407,27 @@ sub insert {
     return "No svc_acct record to apply pre-paid time";
   }
 
-  if ( $amount ) {
-    my $cust_credit = new FS::cust_credit {
+  if ( $prepay_credit && $prepay_credit->amount ) {
+    my $cust_pay = new FS::cust_pay {
       'custnum' => $self->custnum,
-      'amount'  => $amount,
+      'paid'    => $prepay_credit->amount,
+      #'_date'   => #date the prepaid card was purchased???
+      'payby'   => 'PREP',
+      'payinfo' => $prepay_credit->identifier,
     };
-    $error = $cust_credit->insert;
+    $error = $cust_pay->insert;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      return "inserting credit (transaction rolled back): $error";
+      return "inserting prepayment (transaction rolled back): $error";
     }
   }
 
-  $error = $self->queue_fuzzyfiles_update;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "updating fuzzy search cache: $error";
+  unless ( $import || $skip_fuzzyfiles ) {
+    $error = $self->queue_fuzzyfiles_update;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "updating fuzzy search cache: $error";
+    }
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -353,6 +450,9 @@ be a better explanation of this, but until then, here's an example:
   );
   $cust_main->order_pkgs( \%hash, \'0', 'noexport'=>1 );
 
+Services can be new, in which case they are inserted, or existing unaudited
+services, in which case they are linked to the newly-created package.
+
 Currently available options are: I<depend_jobnum> and I<noexport>.
 
 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
@@ -401,12 +501,19 @@ sub order_pkgs {
       return "inserting cust_pkg (transaction rolled back): $error";
     }
     foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
-      $svc_something->pkgnum( $cust_pkg->pkgnum );
-      if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) {
-        $svc_something->seconds( $svc_something->seconds + $$seconds );
-        $$seconds = 0;
+      if ( $svc_something->svcnum ) {
+        my $old_cust_svc = $svc_something->cust_svc;
+        my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
+        $new_cust_svc->pkgnum( $cust_pkg->pkgnum);
+        $error = $new_cust_svc->replace($old_cust_svc);
+      } else {
+        $svc_something->pkgnum( $cust_pkg->pkgnum );
+        if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) {
+          $svc_something->seconds( $svc_something->seconds + $$seconds );
+          $$seconds = 0;
+        }
+        $error = $svc_something->insert(%svc_options);
       }
-      $error = $svc_something->insert(%svc_options);
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
         #return "inserting svc_ (transaction rolled back): $error";
@@ -585,6 +692,16 @@ sub replace {
   local $SIG{TSTP} = 'IGNORE';
   local $SIG{PIPE} = 'IGNORE';
 
+  # If the mask is blank then try to set it - if we can...
+  if (!defined($self->getfield('paymask')) || $self->getfield('paymask') eq '') {
+    $self->paymask($self->payinfo);
+  }
+
+  # We absolutely have to have an old vs. new record to make this work.
+  if (!defined($old)) {
+    $old = qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
+  }
+
   if ( $self->payby eq 'COMP' && $self->payby ne $old->payby
        && $conf->config('users-allow_comp')                  ) {
     return "You are not permitted to create complimentary accounts."
@@ -622,10 +739,12 @@ sub replace {
     }
   }
 
-  $error = $self->queue_fuzzyfiles_update;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "updating fuzzy search cache: $error";
+  unless ( $import || $skip_fuzzyfiles ) {
+    $error = $self->queue_fuzzyfiles_update;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "updating fuzzy search cache: $error";
+    }
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -678,7 +797,7 @@ sub queue_fuzzyfiles_update {
 
 Checks all fields to make sure this is a valid customer record.  If there is
 an error, returns the error, otherwise returns false.  Called by the insert
-and repalce methods.
+and replace methods.
 
 =cut
 
@@ -714,7 +833,7 @@ sub check {
   return "Unknown refnum"
     unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
 
-  return "Unknown referring custnum ". $self->referral_custnum
+  return "Unknown referring custnum: ". $self->referral_custnum
     unless ! $self->referral_custnum 
            || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
 
@@ -809,9 +928,19 @@ sub check {
 
   $self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY)$/
     or return "Illegal payby: ". $self->payby;
+
+  # If it is encrypted and the private key is not availaible then we can't
+  # check the credit card.
+
+  my $check_payinfo = 1;
+
+  if ($self->is_encrypted($self->payinfo)) {
+    $check_payinfo = 0;
+  }
+
   $self->payby($1);
 
-  if ( $self->payby eq 'CARD' || $self->payby eq 'DCRD' ) {
+  if ( $check_payinfo && ($self->payby eq 'CARD' || $self->payby eq 'DCRD')) {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/\D//g;
@@ -824,7 +953,7 @@ sub check {
     return gettext('unknown_card_type')
       if cardtype($self->payinfo) eq "Unknown";
     if ( defined $self->dbdef_table->column('paycvv') ) {
-      if ( length($self->paycvv) ) {
+      if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
         if ( cardtype($self->payinfo) eq 'American Express card' ) {
           $self->paycvv =~ /^(\d{4})$/
             or return "CVV2 (CID) for American Express cards is four digits.";
@@ -839,7 +968,7 @@ sub check {
       }
     }
 
-  } elsif ( $self->payby eq 'CHEK' || $self->payby eq 'DCHK' ) {
+  } elsif ($check_payinfo && ( $self->payby eq 'CHEK' || $self->payby eq 'DCHK' )) {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/[^\d\@]//g;
@@ -912,7 +1041,7 @@ sub check {
   ) {
     $self->payname( $self->first. " ". $self->getfield('last') );
   } else {
-    $self->payname =~ /^([\w \,\.\-\']+)$/
+    $self->payname =~ /^([\w \,\.\-\'\&]+)$/
       or return gettext('illegal_name'). " payname: ". $self->payname;
     $self->payname($1);
   }
@@ -1003,6 +1132,27 @@ sub unsuspended_pkgs {
   grep { ! $_->susp } $self->ncancelled_pkgs;
 }
 
+=item num_cancelled_pkgs
+
+Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
+customer.
+
+=cut
+
+sub num_cancelled_pkgs {
+  my $self = shift;
+  $self->num_pkgs("cancel IS NOT NULL AND cust_pkg.cancel != 0");
+}
+
+sub num_pkgs {
+  my( $self, $sql ) = @_;
+  my $sth = dbh->prepare(
+    "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? AND $sql"
+  ) or die dbh->errstr;
+  $sth->execute($self->custnum) or die $sth->errstr;
+  $sth->fetchrow_arrayref->[0];
+}
+
 =item unsuspend
 
 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
@@ -1028,6 +1178,38 @@ sub suspend {
   grep { $_->suspend } $self->unsuspended_pkgs;
 }
 
+=item suspend_if_pkgpart PKGPART [ , PKGPART ... ]
+
+Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
+PKGPARTs (see L<FS::part_pkg>).  Always returns a list: an empty list on
+success or a list of errors.
+
+=cut
+
+sub suspend_if_pkgpart {
+  my $self = shift;
+  my @pkgparts = @_;
+  grep { $_->suspend }
+    grep { my $pkgpart = $_->pkgpart; grep { $pkgpart eq $_ } @pkgparts }
+      $self->unsuspended_pkgs;
+}
+
+=item suspend_unless_pkgpart PKGPART [ , PKGPART ... ]
+
+Suspends all unsuspended packages (see L<FS::cust_pkg>) unless they match the
+listed PKGPARTs (see L<FS::part_pkg>).  Always returns a list: an empty list
+on success or a list of errors.
+
+=cut
+
+sub suspend_unless_pkgpart {
+  my $self = shift;
+  my @pkgparts = @_;
+  grep { $_->suspend }
+    grep { my $pkgpart = $_->pkgpart; ! grep { $pkgpart eq $_ } @pkgparts }
+      $self->unsuspended_pkgs;
+}
+
 =item cancel [ OPTION => VALUE ... ]
 
 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
@@ -1082,6 +1264,9 @@ If there is an error, returns the error, otherwise returns false.
 
 sub bill {
   my( $self, %options ) = @_;
+  return '' if $self->payby eq 'COMP';
+  warn "bill customer ". $self->custnum. "\n" if $DEBUG;
+
   my $time = $options{'time'} || time;
 
   my $error;
@@ -1119,14 +1304,14 @@ sub bill {
     #NO!! next if $cust_pkg->cancel;  
     next if $cust_pkg->getfield('cancel');  
 
+    warn "  bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG;
+
     #? to avoid use of uninitialized value errors... ?
     $cust_pkg->setfield('bill', '')
       unless defined($cust_pkg->bill);
  
     my $part_pkg = $cust_pkg->part_pkg;
 
-    #so we don't modify cust_pkg record unnecessarily
-    my $cust_pkg_mod_flag = 0;
     my %hash = $cust_pkg->hash;
     my $old_cust_pkg = new FS::cust_pkg \%hash;
 
@@ -1135,27 +1320,16 @@ sub bill {
     # bill setup
     my $setup = 0;
     if ( !$cust_pkg->setup || $options{'resetup'} ) {
-      my $setup_prog = $part_pkg->getfield('setup');
-      $setup_prog =~ /^(.*)$/ or do {
-        $dbh->rollback if $oldAutoCommit;
-        return "Illegal setup for pkgpart ". $part_pkg->pkgpart.
-               ": $setup_prog";
-      };
-      $setup_prog = $1;
-      $setup_prog = '0' if $setup_prog =~ /^\s*$/;
-
-        #my $cpt = new Safe;
-        ##$cpt->permit(); #what is necessary?
-        #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
-        #$setup = $cpt->reval($setup_prog);
-      $setup = eval $setup_prog;
-      unless ( defined($setup) ) {
+    
+      warn "    bill setup\n" if $DEBUG;
+
+      $setup = eval { $cust_pkg->calc_setup( $time ) };
+      if ( $@ ) {
         $dbh->rollback if $oldAutoCommit;
-        return "Error eval-ing part_pkg->setup pkgpart ". $part_pkg->pkgpart.
-               "(expression $setup_prog): $@";
+        return $@;
       }
+
       $cust_pkg->setfield('setup', $time) unless $cust_pkg->setup;
-      $cust_pkg_mod_flag=1; 
     }
 
     #bill recurring fee
@@ -1165,28 +1339,18 @@ sub bill {
          ! $cust_pkg->getfield('susp') &&
          ( $cust_pkg->getfield('bill') || 0 ) <= $time
     ) {
-      my $recur_prog = $part_pkg->getfield('recur');
-      $recur_prog =~ /^(.*)$/ or do {
-        $dbh->rollback if $oldAutoCommit;
-        return "Illegal recur for pkgpart ". $part_pkg->pkgpart.
-               ": $recur_prog";
-      };
-      $recur_prog = $1;
-      $recur_prog = '0' if $recur_prog =~ /^\s*$/;
 
-      # shared with $recur_prog
+      warn "    bill recur\n" if $DEBUG;
+
+      # XXX shared with $recur_prog
       $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
 
-        #my $cpt = new Safe;
-        ##$cpt->permit(); #what is necessary?
-        #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
-        #$recur = $cpt->reval($recur_prog);
-      $recur = eval $recur_prog;
-      unless ( defined($recur) ) {
+      $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details ) };
+      if ( $@ ) {
         $dbh->rollback if $oldAutoCommit;
-        return "Error eval-ing part_pkg->recur pkgpart ".  $part_pkg->pkgpart.
-               "(expression $recur_prog): $@";
+        return $@;
       }
+
       #change this bit to use Date::Manip? CAREFUL with timezones (see
       # mailing list archive)
       my ($sec,$min,$hour,$mday,$mon,$year) =
@@ -1214,19 +1378,22 @@ sub bill {
       }
       $cust_pkg->setfield('bill',
         timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
-      $cust_pkg_mod_flag = 1; 
     }
 
     warn "\$setup is undefined" unless defined($setup);
     warn "\$recur is undefined" unless defined($recur);
     warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
 
-    if ( $cust_pkg_mod_flag ) {
+    if ( $cust_pkg->modified ) {
+
+      warn "  package ". $cust_pkg->pkgnum. " modified; updating\n" if $DEBUG;
+
       $error=$cust_pkg->replace($old_cust_pkg);
       if ( $error ) { #just in case
         $dbh->rollback if $oldAutoCommit;
         return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
       }
+
       $setup = sprintf( "%.2f", $setup );
       $recur = sprintf( "%.2f", $recur );
       if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) {
@@ -1238,6 +1405,8 @@ sub bill {
         return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
       }
       if ( $setup != 0 || $recur != 0 ) {
+        warn "    charges (setup=$setup, recur=$recur); queueing line items\n"
+          if $DEBUG;
         my $cust_bill_pkg = new FS::cust_bill_pkg ({
           'pkgnum'  => $cust_pkg->pkgnum,
           'setup'   => $setup,
@@ -1297,7 +1466,7 @@ sub bill {
                   || $tax->recurtax =~ /^Y$/i;
             next unless $taxable_charged;
 
-            if ( $tax->exempt_amount > 0 ) {
+            if ( $tax->exempt_amount && $tax->exempt_amount > 0 ) {
               my ($mon,$year) = (localtime($sdate) )[4,5];
               $mon++;
               my $freq = $part_pkg->freq || 1;
@@ -1357,7 +1526,7 @@ sub bill {
 
       } #if $setup != 0 || $recur != 0
       
-    } #if $cust_pkg_mod_flag
+    } #if $cust_pkg->modified
 
   } #foreach my $cust_pkg
 
@@ -1504,7 +1673,7 @@ sub collect {
   $self->select_for_update; #mutex
 
   my $balance = $self->balance;
-  warn "collect customer". $self->custnum. ": balance $balance" if $DEBUG;
+  warn "collect customer ". $self->custnum. ": balance $balance\n" if $DEBUG;
   unless ( $balance > 0 ) { #redundant?????
     $dbh->rollback if $oldAutoCommit; #hmm
     return '';
@@ -1529,7 +1698,7 @@ sub collect {
 
     last if $self->balance <= 0;
 
-    warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ")"
+    warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ")\n"
       if $DEBUG;
 
     foreach my $part_bill_event (
@@ -1537,7 +1706,7 @@ sub collect {
              || $a->weight    <=> $b->weight
              || $a->eventpart <=> $b->eventpart }
         grep { $_->seconds <= ( $invoice_time - $cust_bill->_date )
-               && ! qsearchs( 'cust_bill_event', {
+               && ! qsearch( 'cust_bill_event', {
                                 'invnum'    => $cust_bill->invnum,
                                 'eventpart' => $_->eventpart,
                                 'status'    => 'done',
@@ -1557,6 +1726,7 @@ sub collect {
       my $error;
       {
         local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
+        local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
         $error = eval $part_bill_event->eventcode;
       }
 
@@ -1702,11 +1872,6 @@ sub realtime_bop {
   eval "use Business::OnlinePayment";  
   die $@ if $@;
 
-  #overrides
-  $self->set( $_ => $options{$_} )
-    foreach grep { exists($options{$_}) }
-            qw( payname address1 address2 city state zip payinfo paydate paycvv);
-
   #load up config
   my $bop_config = 'business-onlinepayment';
   $bop_config .= '-ach'
@@ -1721,13 +1886,20 @@ sub realtime_bop {
 
   #massage data
 
-  my $address = $self->address1;
-  $address .= ", ". $self->address2 if $self->address2;
-
+  my $address = exists($options{'address1'})
+                    ? $options{'address1'}
+                    : $self->address1;
+  my $address2 = exists($options{'address2'})
+                    ? $options{'address2'}
+                    : $self->address2;
+  $address .= ", ". $address2 if length($address2);
+
+  my $o_payname = exists($options{'payname'})
+                    ? $options{'payname'}
+                    : $self->payname;
   my($payname, $payfirst, $paylast);
-  if ( $self->payname && $method ne 'ECHECK' ) {
-    $payname = $self->payname;
-    $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
+  if ( $o_payname && $method ne 'ECHECK' ) {
+    ($payname = $o_payname) =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
       or return "Illegal payname $payname";
     ($payfirst, $paylast) = ($1, $2);
   } else {
@@ -1741,43 +1913,58 @@ sub realtime_bop {
        || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
     push @invoicing_list, $self->all_emails;
   }
-  my $email = $invoicing_list[0];
 
-  my %content;
+  my $email = ($conf->exists('business-onlinepayment-email-override'))
+              ? $conf->config('business-onlinepayment-email-override')
+              : $invoicing_list[0];
+
+  my $payinfo = exists($options{'payinfo'})
+                  ? $options{'payinfo'}
+                  : $self->payinfo;
+
+  my %content = ();
   if ( $method eq 'CC' ) { 
 
-    $content{card_number} = $self->payinfo;
-    $self->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+    $content{card_number} = $payinfo;
+    my $paydate = exists($options{'paydate'})
+                    ? $options{'paydate'}
+                    : $self->paydate;
+    $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
     $content{expiration} = "$2/$1";
 
-    $content{cvv2} = $self->paycvv
-      if defined $self->dbdef_table->column('paycvv')
-         && length($self->paycvv);
+    if ( defined $self->dbdef_table->column('paycvv') ) {
+      my $paycvv = exists($options{'paycvv'})
+                     ? $options{'paycvv'}
+                     : $self->paycvv;
+      $content{cvv2} = $self->paycvv
+        if length($paycvv);
+    }
 
     $content{recurring_billing} = 'YES'
       if qsearch('cust_pay', { 'custnum' => $self->custnum,
                                'payby'   => 'CARD',
-                               'payinfo' => $self->payinfo, } );
+                               'payinfo' => $payinfo,
+                             } );
 
   } elsif ( $method eq 'ECHECK' ) {
-    my($account_number,$routing_code) = $self->payinfo;
     ( $content{account_number}, $content{routing_code} ) =
-      split('@', $self->payinfo);
-    $content{bank_name} = $self->payname;
+      split('@', $payinfo);
+    $content{bank_name} = $o_payname;
     $content{account_type} = 'CHECKING';
     $content{account_name} = $payname;
     $content{customer_org} = $self->company ? 'B' : 'I';
-    $content{customer_ssn} = $self->ss;
+    $content{customer_ssn} = exists($options{'ss'})
+                               ? $options{'ss'}
+                               : $self->ss;
   } elsif ( $method eq 'LEC' ) {
-    $content{phone} = $self->payinfo;
+    $content{phone} = $payinfo;
   }
 
   #transaction(s)
 
   my( $action1, $action2 ) = split(/\s*\,\s*/, $action );
 
-  my $transaction =
-    new Business::OnlinePayment( $processor, @bop_options );
+  my $transaction = new Business::OnlinePayment( $processor, @bop_options );
   $transaction->content(
     'type'           => $method,
     'login'          => $login,
@@ -1791,10 +1978,18 @@ sub realtime_bop {
     'first_name'     => $payfirst,
     'name'           => $payname,
     'address'        => $address,
-    'city'           => $self->city,
-    'state'          => $self->state,
-    'zip'            => $self->zip,
-    'country'        => $self->country,
+    'city'           => ( exists($options{'city'})
+                            ? $options{'city'}
+                            : $self->city          ),
+    'state'          => ( exists($options{'state'})
+                            ? $options{'state'}
+                            : $self->state          ),
+    'zip'            => ( exists($options{'zip'})
+                            ? $options{'zip'}
+                            : $self->zip          ),
+    'country'        => ( exists($options{'country'})
+                            ? $options{'country'}
+                            : $self->country          ),
     'referer'        => 'http://cleanwhisker.420.am/',
     'email'          => $email,
     'phone'          => $self->daytime || $self->night,
@@ -1848,12 +2043,9 @@ sub realtime_bop {
   # correctly
   if ( defined $self->dbdef_table->column('paycvv')
        && length($self->paycvv)
-       && ! grep { $_ eq cardtype($self->payinfo) } $conf->config('cvv-save')
-       && ! length($options{'paycvv'})
+       && ! grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save')
   ) {
-    my $new = new FS::cust_main { $self->hash };
-    $new->paycvv('');
-    my $error = $new->replace($self);
+    my $error = $self->remove_cvv;
     if ( $error ) {
       warn "error removing cvv: $error\n";
     }
@@ -1868,14 +2060,19 @@ sub realtime_bop {
       'LEC'    => 'LECB',
     );
 
+    my $paybatch = "$processor:". $transaction->authorization;
+    $paybatch .= ':'. $transaction->order_number
+      if $transaction->can('order_number')
+      && length($transaction->order_number);
+
     my $cust_pay = new FS::cust_pay ( {
        'custnum'  => $self->custnum,
        'invnum'   => $options{'invnum'},
        'paid'     => $amount,
        '_date'     => '',
        'payby'    => $method2payby{$method},
-       'payinfo'  => $self->payinfo,
-       'paybatch' => "$processor:". $transaction->authorization,
+       'payinfo'  => $payinfo,
+       'paybatch' => $paybatch,
     } );
     my $error = $cust_pay->insert;
     if ( $error ) {
@@ -1930,6 +2127,247 @@ sub realtime_bop {
 
 }
 
+=item remove_cvv
+
+Removes the I<paycvv> field from the database directly.
+
+If there is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub remove_cvv {
+  my $self = shift;
+  my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?")
+    or return dbh->errstr;
+  $sth->execute($self->custnum)
+    or return $sth->errstr;
+  $self->paycvv('');
+  '';
+}
+
+=item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
+
+Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
+via a Business::OnlinePayment realtime gateway.  See
+L<http://420.am/business-onlinepayment> for supported gateways.
+
+Available methods are: I<CC>, I<ECHECK> and I<LEC>
+
+Available options are: I<amount>, I<reason>, I<paynum>
+
+Most gateways require a reference to an original payment transaction to refund,
+so you probably need to specify a I<paynum>.
+
+I<amount> defaults to the original amount of the payment if not specified.
+
+I<reason> specifies a reason for the refund.
+
+Implementation note: If I<amount> is unspecified or equal to the amount of the
+orignal payment, first an attempt is made to "void" the transaction via
+the gateway (to cancel a not-yet settled transaction) and then if that fails,
+the normal attempt is made to "refund" ("credit") the transaction via the
+gateway is attempted.
+
+#The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
+#I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
+#if set, will override the value from the customer record.
+
+#If an I<invnum> is specified, this payment (if sucessful) is applied to the
+#specified invoice.  If you don't specify an I<invnum> you might want to
+#call the B<apply_payments> method.
+
+=cut
+
+#some false laziness w/realtime_bop, not enough to make it worth merging
+#but some useful small subs should be pulled out
+sub realtime_refund_bop {
+  my( $self, $method, %options ) = @_;
+  if ( $DEBUG ) {
+    warn "$self $method refund\n";
+    warn "  $_ => $options{$_}\n" foreach keys %options;
+  }
+
+  #pre-requisites
+  die "Real-time processing not enabled\n"
+    unless $conf->exists('business-onlinepayment');
+  eval "use Business::OnlinePayment";  
+  die $@ if $@;
+
+  #load up config
+  my $bop_config = 'business-onlinepayment';
+  $bop_config .= '-ach'
+    if $method eq 'ECHECK' && $conf->exists($bop_config. '-ach');
+  my ( $processor, $login, $password, $unused_action, @bop_options ) =
+    $conf->config($bop_config);
+  #$action ||= 'normal authorization';
+  pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
+  die "No real-time processor is enabled - ".
+      "did you set the business-onlinepayment configuration value?\n"
+    unless $processor;
+
+  my $cust_pay = '';
+  my $amount = $options{'amount'};
+  my( $pay_processor, $auth, $order_number ) = ( '', '', '' );
+  if ( $options{'paynum'} ) {
+    warn "FS::cust_main::realtime_bop: paynum: $options{paynum}\n" if $DEBUG;
+    $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
+      or return "Unknown paynum $options{'paynum'}";
+    $amount ||= $cust_pay->paid;
+    $cust_pay->paybatch =~ /^(\w+):([\w-]*)(:(\w+))?$/
+      or return "Can't parse paybatch for paynum $options{'paynum'}: ".
+                $cust_pay->paybatch;
+    ( $pay_processor, $auth, $order_number ) = ( $1, $2, $4 );
+    return "processor of payment $options{'paynum'} $pay_processor does not".
+           " match current processor $processor"
+      unless $pay_processor eq $processor;
+  }
+  return "neither amount nor paynum specified" unless $amount;
+
+  my %content = (
+    'type'           => $method,
+    'login'          => $login,
+    'password'       => $password,
+    'order_number'   => $order_number,
+    'amount'         => $amount,
+    'referer'        => 'http://cleanwhisker.420.am/',
+  );
+  $content{authorization} = $auth
+    if length($auth); #echeck/ACH transactions have an order # but no auth
+                      #(at least with authorize.net)
+
+  #first try void if applicable
+  if ( $cust_pay && $cust_pay->paid == $amount ) { #and check dates?
+    warn "FS::cust_main::realtime_bop: attempting void\n" if $DEBUG;
+    my $void = new Business::OnlinePayment( $processor, @bop_options );
+    $void->content( 'action' => 'void', %content );
+    $void->submit();
+    if ( $void->is_success ) {
+      my $error = $cust_pay->void($options{'reason'});
+      if ( $error ) {
+        # gah, even with transactions.
+        my $e = 'WARNING: Card/ACH voided but database not updated - '.
+                "error voiding payment: $error";
+        warn $e;
+        return $e;
+      }
+      warn "FS::cust_main::realtime_bop: void successful\n" if $DEBUG;
+      return '';
+    }
+  }
+
+  warn "FS::cust_main::realtime_bop: void unsuccessful, trying refund\n"
+    if $DEBUG;
+
+  #massage data
+  my $address = $self->address1;
+  $address .= ", ". $self->address2 if $self->address2;
+
+  my($payname, $payfirst, $paylast);
+  if ( $self->payname && $method ne 'ECHECK' ) {
+    $payname = $self->payname;
+    $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
+      or return "Illegal payname $payname";
+    ($payfirst, $paylast) = ($1, $2);
+  } else {
+    $payfirst = $self->getfield('first');
+    $paylast = $self->getfield('last');
+    $payname =  "$payfirst $paylast";
+  }
+
+  my $payinfo = '';
+  if ( $method eq 'CC' ) {
+
+    if ( $cust_pay ) {
+      $content{card_number} = $payinfo = $cust_pay->payinfo;
+      #$self->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+      #$content{expiration} = "$2/$1";
+    } else {
+      $content{card_number} = $payinfo = $self->payinfo;
+      $self->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+      $content{expiration} = "$2/$1";
+    }
+
+  } elsif ( $method eq 'ECHECK' ) {
+    ( $content{account_number}, $content{routing_code} ) =
+      split('@', $payinfo = $self->payinfo);
+    $content{bank_name} = $self->payname;
+    $content{account_type} = 'CHECKING';
+    $content{account_name} = $payname;
+    $content{customer_org} = $self->company ? 'B' : 'I';
+    $content{customer_ssn} = $self->ss;
+  } elsif ( $method eq 'LEC' ) {
+    $content{phone} = $payinfo = $self->payinfo;
+  }
+
+  #then try refund
+  my $refund = new Business::OnlinePayment( $processor, @bop_options );
+  my %sub_content = $refund->content(
+    'action'         => 'credit',
+    'customer_id'    => $self->custnum,
+    'last_name'      => $paylast,
+    'first_name'     => $payfirst,
+    'name'           => $payname,
+    'address'        => $address,
+    'city'           => $self->city,
+    'state'          => $self->state,
+    'zip'            => $self->zip,
+    'country'        => $self->country,
+    %content, #after
+  );
+  warn join('', map { "  $_ => $sub_content{$_}\n" } keys %sub_content )
+    if $DEBUG > 1;
+  $refund->submit();
+
+  return "$processor error: ". $refund->error_message
+    unless $refund->is_success();
+
+  my %method2payby = (
+    'CC'     => 'CARD',
+    'ECHECK' => 'CHEK',
+    'LEC'    => 'LECB',
+  );
+
+  my $paybatch = "$processor:". $refund->authorization;
+  $paybatch .= ':'. $refund->order_number
+    if $refund->can('order_number') && $refund->order_number;
+
+  while ( $cust_pay && $cust_pay->unappled < $amount ) {
+    my @cust_bill_pay = $cust_pay->cust_bill_pay;
+    last unless @cust_bill_pay;
+    my $cust_bill_pay = pop @cust_bill_pay;
+    my $error = $cust_bill_pay->delete;
+    last if $error;
+  }
+
+  my $cust_refund = new FS::cust_refund ( {
+    'custnum'  => $self->custnum,
+    'paynum'   => $options{'paynum'},
+    'refund'   => $amount,
+    '_date'    => '',
+    'payby'    => $method2payby{$method},
+    'payinfo'  => $payinfo,
+    'paybatch' => $paybatch,
+    'reason'   => $options{'reason'} || 'card or ACH refund',
+  } );
+  my $error = $cust_refund->insert;
+  if ( $error ) {
+    $cust_refund->paynum(''); #try again with no specific paynum
+    my $error2 = $cust_refund->insert;
+    if ( $error2 ) {
+      # gah, even with transactions.
+      my $e = 'WARNING: Card/ACH refunded but database not updated - '.
+              "error inserting refund ($processor): $error2".
+              " (previously tried insert with paynum #$options{'paynum'}" .
+              ": $error )";
+      warn $e;
+      return $e;
+    }
+  }
+
+  ''; #no error
+
+}
+
 =item total_owed
 
 Returns the total owed for this customer on all invoices
@@ -1963,28 +2401,30 @@ sub total_owed_date {
   sprintf( "%.2f", $total_bill );
 }
 
-=item apply_credits
+=item apply_credits OPTION => VALUE ...
 
 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
-to outstanding invoice balances in chronological order and returns the value
-of any remaining unapplied credits available for refund
-(see L<FS::cust_refund>).
+to outstanding invoice balances in chronological order (or reverse
+chronological order if the I<order> option is set to B<newest>) and returns the
+value of any remaining unapplied credits available for refund (see
+L<FS::cust_refund>).
 
 =cut
 
 sub apply_credits {
   my $self = shift;
+  my %opt = @_;
 
   return 0 unless $self->total_credited;
 
   my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
       qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
 
-  my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
-      qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
+  my @invoices = $self->open_cust_bill;
+  @invoices = sort { $b->_date <=> $a->_date } @invoices
+    if defined($opt{'order'}) && $opt{'order'} eq 'newest';
 
   my $credit;
-
   foreach my $cust_bill ( @invoices ) {
     my $amount;
 
@@ -2142,7 +2582,7 @@ paydate (credit card expiration date for CARD customers)
 
 sub paydate_monthyear {
   my $self = shift;
-  if ( $self->paydate  =~ /^(\d{4})-(\d{2})-\d{2}$/ ) { #Pg date format
+  if ( $self->paydate  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
     ( $2, $1 );
   } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
     ( $1, $3 );
@@ -2153,15 +2593,17 @@ sub paydate_monthyear {
 
 =item payinfo_masked
 
-Returns a "masked" payinfo field with all but the last four characters replaced
-by 'x'es.  Useful for displaying credit cards.
+Returns a "masked" payinfo field appropriate to the payment type.  Masked characters are replaced by 'x'es.  Use this to display publicly accessable account Information.
+
+Credit Cards - Mask all but the last four characters.
+Checks - Mask all but last 2 of account number and bank routing number.
+Others - Do nothing, return the unmasked string.
 
 =cut
 
 sub payinfo_masked {
   my $self = shift;
-  my $payinfo = $self->payinfo;
-  'x'x(length($payinfo)-4). substr($payinfo,(length($payinfo)-4));
+  return $self->paymask;
 }
 
 =item invoicing_list [ ARRAYREF ]
@@ -2233,6 +2675,11 @@ is an error, returns the error, otherwise returns false.
 sub check_invoicing_list {
   my( $self, $arrayref ) = @_;
   foreach my $address ( @{$arrayref} ) {
+
+    if ($address eq 'FAX' and $self->getfield('fax') eq '') {
+      return 'Can\'t add FAX invoice destination with a blank FAX number.';
+    }
+
     my $cust_main_invoice = new FS::cust_main_invoice ( {
       'custnum' => $self->custnum,
       'dest'    => $address,
@@ -2350,6 +2797,19 @@ sub referral_cust_pkg {
       $self->referral_cust_main($depth);
 }
 
+=item referring_cust_main
+
+Returns the single cust_main record for the customer who referred this customer
+(referral_custnum), or false.
+
+=cut
+
+sub referring_cust_main {
+  my $self = shift;
+  return '' unless $self->referral_custnum;
+  qsearchs('cust_main', { 'custnum' => $self->referral_custnum } );
+}
+
 =item credit AMOUNT, REASON
 
 Applies a credit to this customer.  If there is an error, returns the error,
@@ -2394,9 +2854,11 @@ sub charge {
   my $part_pkg = new FS::part_pkg ( {
     'pkg'      => $pkg,
     'comment'  => $comment,
-    'setup'    => $amount,
+    #'setup'    => $amount,
+    #'recur'    => '0',
+    'plan'     => 'flat',
+    'plandata' => "setup_fee=$amount",
     'freq'     => 0,
-    'recur'    => '0',
     'disabled' => 'Y',
     'taxclass' => $taxclass,
   } );
@@ -2482,6 +2944,19 @@ sub cust_pay {
     qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
 }
 
+=item cust_pay_void
+
+Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
+
+=cut
+
+sub cust_pay_void {
+  my $self = shift;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
+}
+
+
 =item cust_refund
 
 Returns all the refunds (see L<FS::cust_refund>) for this customer.
@@ -2506,12 +2981,276 @@ sub select_for_update {
   qsearch('cust_main', { 'custnum' => $self->custnum }, '*', 'FOR UPDATE' );
 }
 
+=item name
+
+Returns a name string for this customer, either "Company (Last, First)" or
+"Last, First".
+
+=cut
+
+sub name {
+  my $self = shift;
+  my $name = $self->get('last'). ', '. $self->first;
+  $name = $self->company. " ($name)" if $self->company;
+  $name;
+}
+
+=item status
+
+Returns a status string for this customer, currently:
+
+=over 4
+
+=item prospect - No packages have ever been ordered
+
+=item active - One or more recurring packages is active
+
+=item suspended - All non-cancelled recurring packages are suspended
+
+=item cancelled - All recurring packages are cancelled
+
+=back
+
+=cut
+
+sub status {
+  my $self = shift;
+  for my $status (qw( prospect active suspended cancelled )) {
+    my $method = $status.'_sql';
+    my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
+    my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
+    $sth->execute( ($self->custnum) x $numnum ) or die $sth->errstr;
+    return $status if $sth->fetchrow_arrayref->[0];
+  }
+}
+
+=item statuscolor
+
+Returns a hex triplet color string for this customer's status.
+
+=cut
+
+my %statuscolor = (
+  'prospect'  => '000000',
+  'active'    => '00CC00',
+  'suspended' => 'FF9900',
+  'cancelled' => 'FF0000',
+);
+sub statuscolor {
+  my $self = shift;
+  $statuscolor{$self->status};
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item prospect_sql
+
+Returns an SQL expression identifying prospective cust_main records (customers
+with no packages ever ordered)
+
+=cut
+
+sub prospect_sql { "
+  0 = ( SELECT COUNT(*) FROM cust_pkg
+          WHERE cust_pkg.custnum = cust_main.custnum
+      )
+"; }
+
+=item active_sql
+
+Returns an SQL expression identifying active cust_main records.
+
+=cut
+
+my $recurring_sql = "
+  '0' != ( select freq from part_pkg
+             where cust_pkg.pkgpart = part_pkg.pkgpart )
+";
+
+sub active_sql { "
+  0 < ( SELECT COUNT(*) FROM cust_pkg
+          WHERE cust_pkg.custnum = cust_main.custnum
+            AND $recurring_sql
+            AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+            AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
+      )
+"; }
+
+=item susp_sql
+=item suspended_sql
+
+Returns an SQL expression identifying suspended cust_main records.
+
+=cut
+
+sub suspended_sql { susp_sql(@_); }
+sub susp_sql { "
+    0 < ( SELECT COUNT(*) FROM cust_pkg
+            WHERE cust_pkg.custnum = cust_main.custnum
+              AND $recurring_sql
+              AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+        )
+    AND 0 = ( SELECT COUNT(*) FROM cust_pkg
+                WHERE cust_pkg.custnum = cust_main.custnum
+                  AND $recurring_sql
+                  AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
+                  AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+            )
+"; }
+
+=item cancel_sql
+=item cancelled_sql
+
+Returns an SQL expression identifying cancelled cust_main records.
+
+=cut
+
+sub cancelled_sql { cancel_sql(@_); }
+sub cancel_sql { "
+  0 < ( SELECT COUNT(*) FROM cust_pkg
+          WHERE cust_pkg.custnum = cust_main.custnum
+      )
+  AND 0 = ( SELECT COUNT(*) FROM cust_pkg
+              WHERE cust_pkg.custnum = cust_main.custnum
+                AND $recurring_sql
+                AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+          )
+"; }
+
+=item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
+
+Performs a fuzzy (approximate) search and returns the matching FS::cust_main
+records.  Currently, only I<last> or I<company> may be specified (the
+appropriate ship_ field is also searched if applicable).
+
+Additional options are the same as FS::Record::qsearch
+
+=cut
+
+sub fuzzy_search {
+  my( $self, $fuzzy, $hash, @opt) = @_;
+  #$self
+  $hash ||= {};
+  my @cust_main = ();
+
+  check_and_rebuild_fuzzyfiles();
+  foreach my $field ( keys %$fuzzy ) {
+    my $sub = \&{"all_$field"};
+    my %match = ();
+    $match{$_}=1 foreach ( amatch($fuzzy->{$field}, ['i'], @{ &$sub() } ) );
+
+    foreach ( keys %match ) {
+      push @cust_main, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
+      push @cust_main, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt)
+        if defined dbdef->table('cust_main')->column('ship_last');
+    }
+  }
+
+  my %saw = ();
+  @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
+
+  @cust_main;
+
+}
+
 =back
 
 =head1 SUBROUTINES
 
 =over 4
 
+=item smart_search OPTION => VALUE ...
+
+Accepts the following options: I<search>, the string to search for.  The string
+will be searched for as a customer number, last name or company name, first
+searching for an exact match then fuzzy and substring matches.
+
+Any additional options treated as an additional qualifier on the search
+(i.e. I<agentnum>).
+
+Returns a (possibly empty) array of FS::cust_main objects.
+
+=cut
+
+sub smart_search {
+  my %options = @_;
+  my $search = delete $options{'search'};
+  my @cust_main = ();
+
+  if ( $search =~ /^\s*(\d+)\s*$/ ) { # customer # search
+
+    push @cust_main, qsearch('cust_main', { 'custnum' => $1, %options } );
+
+  } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { #value search
+
+    my $value = lc($1);
+    my $q_value = dbh->quote($value);
+
+    #exact
+    my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
+    $sql .= " ( LOWER(last) = $q_value OR LOWER(company) = $q_value";
+    $sql .= " OR LOWER(ship_last) = $q_value OR LOWER(ship_company) = $q_value"
+      if defined dbdef->table('cust_main')->column('ship_last');
+    $sql .= ' )';
+
+    push @cust_main, qsearch( 'cust_main', \%options, '', $sql );
+
+    unless ( @cust_main ) {  #no exact match, trying substring/fuzzy
+
+      #still some false laziness w/ search/cust_main.cgi
+
+      #substring
+      push @cust_main, qsearch( 'cust_main',
+                                { 'last'     => { 'op'    => 'ILIKE',
+                                                  'value' => "%$q_value%" },
+                                  %options,
+                                }
+                              );
+      push @cust_main, qsearch( 'cust_main',
+                                { 'ship_last' => { 'op'    => 'ILIKE',
+                                                   'value' => "%$q_value%" },
+                                  %options,
+
+                                }
+                              )
+        if defined dbdef->table('cust_main')->column('ship_last');
+
+      push @cust_main, qsearch( 'cust_main',
+                                { 'company'  => { 'op'    => 'ILIKE',
+                                                  'value' => "%$q_value%" },
+                                  %options,
+                                }
+                              );
+      push @cust_main, qsearch( 'cust_main',
+                                { 'ship_company' => { 'op' => 'ILIKE',
+                                                   'value' => "%$q_value%" },
+                                  %options,
+                                }
+                              )
+        if defined dbdef->table('cust_main')->column('ship_last');
+
+      #fuzzy
+      push @cust_main, FS::cust_main->fuzzy_search(
+        { 'last'     => $value },
+        \%options,
+      );
+      push @cust_main, FS::cust_main->fuzzy_search(
+        { 'company'  => $value },
+        \%options,
+      );
+
+    }
+
+  }
+
+  @cust_main;
+
+}
+
 =item check_and_rebuild_fuzzyfiles
 
 =cut