ticket 1443 add account type and bank state for echeck processing
[freeside.git] / FS / FS / cust_main.pm
index ea52378..e834d59 100644 (file)
@@ -2,7 +2,7 @@ package FS::cust_main;
 
 use strict;
 use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf @encrypted_fields
-             $import $skip_fuzzyfiles $ignore_expired_card );
+             $import $skip_fuzzyfiles $ignore_expired_card @paytypes);
 use vars qw( $realtime_bop_decline_quiet ); #ugh
 use Safe;
 use Carp;
@@ -21,6 +21,7 @@ use Date::Parse;
 use String::Approx qw(amatch);
 use Business::CreditCard 0.28;
 use Locale::Country;
+use Data::Dumper;
 use FS::UID qw( getotaker dbh );
 use FS::Record qw( qsearchs qsearch dbdef );
 use FS::Misc qw( send_email );
@@ -69,6 +70,7 @@ $skip_fuzzyfiles = 0;
 $ignore_expired_card = 0;
 
 @encrypted_fields = ('payinfo', 'paycvv');
+@paytypes = ('Personal checking', 'Personal savings', 'Business checking', 'Business savings');
 
 #ask FS::UID to run this stuff for us later
 #$FS::UID::callback{'FS::cust_main'} = sub { 
@@ -417,7 +419,7 @@ sub start_copy_skel {
   #'mg_watchlist_header.watchlist_header_id' => { 'mg_watchlist_details.watchlist_details_id' },
   #'mg_user_grid_header.grid_header_id' => { 'mg_user_grid_details.user_grid_details_id' },
   #'mg_portfolio_header.portfolio_header_id' => { 'mg_portfolio_trades.portfolio_trades_id' => { 'mg_portfolio_trades_positions.portfolio_trades_positions_id' } },
-  my @tables = eval($conf->config_binary('cust_main-skeleton_tables'));
+  my @tables = eval(join('\n',$conf->config('cust_main-skeleton_tables')));
   die $@ if $@;
 
   _copy_skel( 'cust_main',                                 #tablename
@@ -1011,7 +1013,9 @@ sub delete {
       my %hash = $cust_pkg->hash;
       $hash{'custnum'} = $new_custnum;
       my $new_cust_pkg = new FS::cust_pkg ( \%hash );
-      my $error = $new_cust_pkg->replace($cust_pkg);
+      my $error = $new_cust_pkg->replace($cust_pkg,
+                                         options => { $cust_pkg->options },
+                                        );
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
         return $error;
@@ -1216,6 +1220,9 @@ sub check {
     || $self->ut_country('country')
     || $self->ut_anything('comments')
     || $self->ut_numbern('referral_custnum')
+    || $self->ut_textn('stateid')
+    || $self->ut_textn('stateid_state')
+    || $self->ut_textn('invoice_terms')
   ;
   #barf.  need message catalogs.  i18n.  etc.
   $error .= "Please select an advertising source."
@@ -1330,6 +1337,7 @@ sub check {
   $error =    $self->ut_numbern('paystart_month')
            || $self->ut_numbern('paystart_year')
            || $self->ut_numbern('payissue')
+           || $self->ut_textn('paytype')
   ;
   return $error if $error;
 
@@ -1900,7 +1908,14 @@ sub bill {
     ###
 
     my $setup = 0;
-    if ( !$cust_pkg->setup || $options{'resetup'} ) {
+    if ( ! $cust_pkg->setup &&
+         (
+           ( $conf->exists('disable_setup_suspended_pkgs') &&
+            ! $cust_pkg->getfield('susp')
+          ) || ! $conf->exists('disable_setup_suspended_pkgs')
+         )
+      || $options{'resetup'}
+    ) {
     
       warn "    bill setup\n" if $DEBUG > 1;
 
@@ -1978,12 +1993,14 @@ sub bill {
     # If $cust_pkg has been modified, update it and create cust_bill_pkg records
     ###
 
-    if ( $cust_pkg->modified ) {
+    if ( $cust_pkg->modified ) {  # hmmm.. and if the options are modified?
 
       warn "  package ". $cust_pkg->pkgnum. " modified; updating\n"
         if $DEBUG >1;
 
-      $error=$cust_pkg->replace($old_cust_pkg);
+      $error=$cust_pkg->replace($old_cust_pkg,
+                                options => { $cust_pkg->options },
+                               );
       if ( $error ) { #just in case
         $dbh->rollback if $oldAutoCommit;
         return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
@@ -2527,8 +2544,9 @@ sub realtime_bop {
     $payname =  "$payfirst $paylast";
   }
 
-  my @invoicing_list = grep { $_ ne 'POST' } $self->invoicing_list;
-  if ( $conf->exists('emailinvoiceauto')
+  my @invoicing_list = $self->invoicing_list_emailonly;
+  if ( $conf->exists('emailinvoiceautoalways')
+       || $conf->exists('emailinvoiceauto') && ! @invoicing_list
        || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
     push @invoicing_list, $self->all_emails;
   }
@@ -2733,10 +2751,12 @@ sub realtime_bop {
        'payinfo'  => $payinfo,
        'paybatch' => $paybatch,
     } );
-    my $error = $cust_pay->insert;
+    my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
     if ( $error ) {
       $cust_pay->invnum(''); #try again with no specific invnum
-      my $error2 = $cust_pay->insert;
+      my $error2 = $cust_pay->insert( $options{'manual'} ?
+                                      ( 'manual' => 1 ) : ()
+                                    );
       if ( $error2 ) {
         # gah, even with transactions.
         my $e = 'WARNING: Card/ACH debited but database not updated - '.
@@ -2753,6 +2773,34 @@ sub realtime_bop {
 
     my $perror = "$processor error: ". $transaction->error_message;
 
+    unless ( $transaction->error_message ) {
+
+      my $t_response;
+      if ( $transaction->can('response_page') ) {
+        $t_response = {
+                        'page'    => ( $transaction->can('response_page')
+                                         ? $transaction->response_page
+                                         : ''
+                                     ),
+                        'code'    => ( $transaction->can('response_code')
+                                         ? $transaction->response_code
+                                         : ''
+                                     ),
+                        'headers' => ( $transaction->can('response_headers')
+                                         ? $transaction->response_headers
+                                         : ''
+                                     ),
+                      };
+      } else {
+        $t_response .=
+          "No additional debugging information available for $processor";
+      }
+
+      $perror .= "No error_message returned from $processor -- ".
+                 ( ref($t_response) ? Dumper($t_response) : $t_response );
+
+    }
+
     if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
          && $conf->exists('emaildecline')
          && grep { $_ ne 'POST' } $self->invoicing_list
@@ -2891,7 +2939,7 @@ sub realtime_refund_bop {
       or return "Unknown paynum $options{'paynum'}";
     $amount ||= $cust_pay->paid;
 
-    $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-]*)(:([\w\-]+))?$/
+    $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
       or return "Can't parse paybatch for paynum $options{'paynum'}: ".
                 $cust_pay->paybatch;
     my $gatewaynum = '';
@@ -3013,8 +3061,9 @@ sub realtime_refund_bop {
     $payname =  "$payfirst $paylast";
   }
 
-  my @invoicing_list = grep { $_ ne 'POST' } $self->invoicing_list;
-  if ( $conf->exists('emailinvoiceauto')
+  my @invoicing_list = $self->invoicing_list_emailonly;
+  if ( $conf->exists('emailinvoiceautoalways')
+       || $conf->exists('emailinvoiceauto') && ! @invoicing_list
        || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
     push @invoicing_list, $self->all_emails;
   }
@@ -3535,9 +3584,25 @@ destinations such as POST and FAX).
 
 sub invoicing_list_emailonly {
   my $self = shift;
+  warn "$me invoicing_list_emailonly called"
+    if $DEBUG;
   grep { $_ !~ /^([A-Z]+)$/ } $self->invoicing_list;
 }
 
+=item invoicing_list_emailonly_scalar
+
+Returns the list of email invoice recipients (invoicing_list without non-email
+destinations such as POST and FAX) as a comma-separated scalar.
+
+=cut
+
+sub invoicing_list_emailonly_scalar {
+  my $self = shift;
+  warn "$me invoicing_list_emailonly_scalar called"
+    if $DEBUG;
+  join(', ', $self->invoicing_list_emailonly);
+}
+
 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
 
 Returns an array of customers referred by this customer (referral_custnum set
@@ -3783,18 +3848,6 @@ sub cust_refund {
     qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
 }
 
-=item select_for_update
-
-Selects this record with the SQL "FOR UPDATE" command.  This can be useful as
-a mutex.
-
-=cut
-
-sub select_for_update {
-  my $self = shift;
-  qsearch('cust_main', { 'custnum' => $self->custnum }, '*', 'FOR UPDATE' );
-}
-
 =item name
 
 Returns a name string for this customer, either "Company (Last, First)" or
@@ -3862,6 +3915,8 @@ sub country_full {
   code2country($self->country);
 }
 
+=item cust_status
+
 =item status
 
 Returns a status string for this customer, currently:
@@ -3882,17 +3937,35 @@ Returns a status string for this customer, currently:
 
 =cut
 
-sub status {
+sub status { shift->cust_status(@_); }
+
+sub cust_status {
   my $self = shift;
   for my $status (qw( prospect active inactive 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;
+    $sth->execute( ($self->custnum) x $numnum )
+      or die "Error executing 'SELECT $sql': ". $sth->errstr;
     return $status if $sth->fetchrow_arrayref->[0];
   }
 }
 
+=item ucfirst_cust_status
+
+=item ucfirst_status
+
+Returns the status with the first character capitalized.
+
+=cut
+
+sub ucfirst_status { shift->ucfirst_cust_status(@_); }
+
+sub ucfirst_cust_status {
+  my $self = shift;
+  ucfirst($self->cust_status);
+}
+
 =item statuscolor
 
 Returns a hex triplet color string for this customer's status.
@@ -3908,9 +3981,11 @@ use vars qw(%statuscolor);
   'cancelled' => 'FF0000', #red
 );
 
-sub statuscolor {
+sub statuscolor { shift->cust_statuscolor(@_); }
+
+sub cust_statuscolor {
   my $self = shift;
-  $statuscolor{$self->status};
+  $statuscolor{$self->cust_status};
 }
 
 =back
@@ -4063,6 +4138,22 @@ sub fuzzy_search {
 
 }
 
+=item masked FIELD
+
+Returns a masked version of the named field
+
+=cut
+
+sub masked {
+my ($self,$field) = @_;
+
+# Show last four
+
+'x'x(length($self->getfield($field))-4).
+  substr($self->getfield($field), (length($self->getfield($field))-4));
+
+}
+
 =back
 
 =head1 SUBROUTINES
@@ -4074,7 +4165,8 @@ sub fuzzy_search {
 Accepts the following options: I<search>, the string to search for.  The string
 will be searched for as a customer number, phone number, name or company name,
 as an exact, or, in some cases, a substring or fuzzy match (see the source code
-for the exact heuristics used).
+for the exact heuristics used); I<no_fuzzy_on_exact>, causes smart_search to
+skip fuzzy matching when an exact match is found.
 
 Any additional options are treated as an additional qualifier on the search
 (i.e. I<agentnum>).
@@ -4091,6 +4183,7 @@ sub smart_search {
 
   my @cust_main = ();
 
+  my $skip_fuzzy = delete $options{'no_fuzzy_on_exact'};
   my $search = delete $options{'search'};
   ( my $alphanum_search = $search ) =~ s/\W//g;
   
@@ -4228,7 +4321,7 @@ sub smart_search {
 
     #always do substring & fuzzy,
     #getting complains searches are not returning enough
-    #unless ( @cust_main ) {  #no exact match, trying substring/fuzzy
+    unless ( @cust_main && $skip_fuzzy ) {  #no exact match, trying substring/fuzzy
 
       #still some false laziness w/ search/cust_main.cgi
 
@@ -4289,7 +4382,7 @@ sub smart_search {
           FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
       }
 
-    #}
+    }
 
     #eliminate duplicates
     my %saw = ();
@@ -4677,6 +4770,94 @@ sub batch_charge {
 
 }
 
+=item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
+
+Sends a templated email notification to the customer (see L<Text::Template>).
+
+OPTIONS is a hash and may include
+
+I<from> - the email sender (default is invoice_from)
+
+I<to> - comma-separated scalar or arrayref of recipients 
+   (default is invoicing_list)
+
+I<subject> - The subject line of the sent email notification
+   (default is "Notice from company_name")
+
+I<extra_fields> - a hashref of name/value pairs which will be substituted
+   into the template
+
+The following variables are vavailable in the template.
+
+I<$first> - the customer first name
+I<$last> - the customer last name
+I<$company> - the customer company
+I<$payby> - a description of the method of payment for the customer
+            # would be nice to use FS::payby::shortname
+I<$payinfo> - the account information used to collect for this customer
+I<$expdate> - the expiration of the customer payment in seconds from epoch
+
+=cut
+
+sub notify {
+  my ($customer, $template, %options) = @_;
+
+  return unless $conf->exists($template);
+
+  my $from = $conf->config('invoice_from') if $conf->exists('invoice_from');
+  $from = $options{from} if exists($options{from});
+
+  my $to = join(',', $customer->invoicing_list_emailonly);
+  $to = $options{to} if exists($options{to});
+  
+  my $subject = "Notice from " . $conf->config('company_name')
+    if $conf->exists('company_name');
+  $subject = $options{subject} if exists($options{subject});
+
+  my $notify_template = new Text::Template (TYPE => 'ARRAY',
+                                            SOURCE => [ map "$_\n",
+                                              $conf->config($template)]
+                                           )
+    or die "can't create new Text::Template object: Text::Template::ERROR";
+  $notify_template->compile()
+    or die "can't compile template: Text::Template::ERROR";
+
+  my $paydate = $customer->paydate;
+  $FS::notify_template::_template::first = $customer->first;
+  $FS::notify_template::_template::last = $customer->last;
+  $FS::notify_template::_template::company = $customer->company;
+  $FS::notify_template::_template::payinfo = $customer->mask_payinfo;
+  my $payby = $customer->payby;
+  my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
+  my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
+
+  #credit cards expire at the end of the month/year of their exp date
+  if ($payby eq 'CARD' || $payby eq 'DCRD') {
+    $FS::notify_template::_template::payby = 'credit card';
+    ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
+    $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
+    $expire_time--;
+  }elsif ($payby eq 'COMP') {
+    $FS::notify_template::_template::payby = 'complimentary account';
+  }else{
+    $FS::notify_template::_template::payby = 'current method';
+  }
+  $FS::notify_template::_template::expdate = $expire_time;
+
+  for (keys %{$options{extra_fields}}){
+    no strict "refs";
+    ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
+  }
+
+  send_email(from => $from,
+             to => $to,
+             subject => $subject,
+             body => $notify_template->fill_in( PACKAGE =>
+                                                'FS::notify_template::_template'                                              ),
+            );
+
+}
+
 =back
 
 =head1 BUGS