multiple payment options (new complimentary flag), RT#23741
[freeside.git] / FS / FS / cust_pay.pm
index 4491f78..e8f9aee 100644 (file)
@@ -9,8 +9,9 @@ use vars qw( $DEBUG $me $conf @encrypted_fields
 use Date::Format;
 use Business::CreditCard;
 use Text::Template;
 use Date::Format;
 use Business::CreditCard;
 use Text::Template;
-use FS::Misc qw( send_email );
+use FS::Misc::DateTime qw( parse_datetime ); #for batch_import
 use FS::Record qw( dbh qsearch qsearchs );
 use FS::Record qw( dbh qsearch qsearchs );
+use FS::UID qw( driver_name );
 use FS::CurrentUser;
 use FS::payby;
 use FS::cust_main_Mixin;
 use FS::CurrentUser;
 use FS::payby;
 use FS::cust_main_Mixin;
@@ -22,6 +23,7 @@ use FS::cust_main;
 use FS::cust_pkg;
 use FS::cust_pay_void;
 use FS::upgrade_journal;
 use FS::cust_pkg;
 use FS::cust_pay_void;
 use FS::upgrade_journal;
+use FS::Cursor;
 
 $DEBUG = 0;
 
 
 $DEBUG = 0;
 
@@ -36,6 +38,7 @@ FS::UID->install_callback( sub {
 } );
 
 @encrypted_fields = ('payinfo');
 } );
 
 @encrypted_fields = ('payinfo');
+sub nohistory_fields { ('payinfo'); }
 
 =head1 NAME
 
 
 =head1 NAME
 
@@ -168,7 +171,7 @@ Creates a new payment.  To add the payment to the databse, see L<"insert">.
 =cut
 
 sub table { 'cust_pay'; }
 =cut
 
 sub table { 'cust_pay'; }
-sub cust_linked { $_[0]->cust_main_custnum; } 
+sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum; } 
 sub cust_unlinked_msg {
   my $self = shift;
   "WARNING: can't find cust_main.custnum ". $self->custnum.
 sub cust_unlinked_msg {
   my $self = shift;
   "WARNING: can't find cust_main.custnum ". $self->custnum.
@@ -187,10 +190,32 @@ If the additional field discount_term is defined then a prepayment discount
 is taken for that length of time.  It is an error for the customer to owe
 after this payment is made.
 
 is taken for that length of time.  It is an error for the customer to owe
 after this payment is made.
 
-A hash of optional arguments may be passed.  Currently "manual" is supported.
+A hash of optional arguments may be passed.  The following arguments are
+supported:
+
+=over 4
+
+=item manual
+
 If true, a payment receipt is sent instead of a statement when
 'payment_receipt_email' configuration option is set.
 
 If true, a payment receipt is sent instead of a statement when
 'payment_receipt_email' configuration option is set.
 
+About the "manual" flag: Normally, if the 'payment_receipt' config option 
+is set, and the customer has an invoice email address, inserting a payment
+causes a I<statement> to be emailed to the customer.  If the payment is 
+considered "manual" (or if the customer has no invoices), then it will 
+instead send a I<payment receipt>.  "manual" should be true whenever a 
+payment is created directly from the web interface, from a user-initiated
+realtime payment, or from a third-party payment via self-service.  It should
+be I<false> when creating a payment from a billing event or from a batch.
+
+=item noemail
+
+Don't send an email receipt.  (Note: does not currently work when
+payment_receipt-trigger is set to something other than default / cust_bill)
+
+=back
+
 =cut
 
 sub insert {
 =cut
 
 sub insert {
@@ -367,6 +392,7 @@ sub insert {
   if ( $trigger eq 'cust_pay' ) {
     my $error = $self->send_receipt(
       'manual'    => $options{'manual'},
   if ( $trigger eq 'cust_pay' ) {
     my $error = $self->send_receipt(
       'manual'    => $options{'manual'},
+      'noemail'   => $options{'noemail'},
       'cust_bill' => $cust_bill,
       'cust_main' => $cust_main,
     );
       'cust_bill' => $cust_bill,
       'cust_main' => $cust_main,
     );
@@ -403,12 +429,17 @@ sub void {
   } );
   $cust_pay_void->reason(shift) if scalar(@_);
   my $error = $cust_pay_void->insert;
   } );
   $cust_pay_void->reason(shift) if scalar(@_);
   my $error = $cust_pay_void->insert;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return $error;
+
+  my $cust_pay_pending =
+    qsearchs('cust_pay_pending', { paynum => $self->paynum });
+  if ( $cust_pay_pending ) {
+    $cust_pay_pending->set('void_paynum', $self->paynum);
+    $cust_pay_pending->set('paynum', '');
+    $error ||= $cust_pay_pending->replace;
   }
 
   }
 
-  $error = $self->delete;
+  $error ||= $self->delete;
+
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -459,38 +490,6 @@ sub delete {
     return $error;
   }
 
     return $error;
   }
 
-  if (    $conf->exists('deletepayments')
-       && $conf->config('deletepayments') ne '' ) {
-
-    my $cust_main = $self->cust_main;
-
-    my $error = send_email(
-      'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
-                                 #invoice_from??? well as good as any
-      'to'      => $conf->config('deletepayments'),
-      'subject' => 'FREESIDE NOTIFICATION: Payment deleted',
-      'body'    => [
-        "This is an automatic message from your Freeside installation\n",
-        "informing you that the following payment has been deleted:\n",
-        "\n",
-        'paynum: '. $self->paynum. "\n",
-        'custnum: '. $self->custnum.
-          " (". $cust_main->last. ", ". $cust_main->first. ")\n",
-        'paid: $'. sprintf("%.2f", $self->paid). "\n",
-        'date: '. time2str("%a %b %e %T %Y", $self->_date). "\n",
-        'payby: '. $self->payby. "\n",
-        'payinfo: '. $self->paymask. "\n",
-        'paybatch: '. $self->paybatch. "\n",
-      ],
-    );
-
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "can't send payment deletion notification: $error";
-    }
-
-  }
-
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   '';
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   '';
@@ -596,6 +595,12 @@ will be assumed.
 
 Customer (FS::cust_main) object (for efficiency).
 
 
 Customer (FS::cust_main) object (for efficiency).
 
+=item noemail
+
+Don't send an email receipt.
+
+=cut
+
 =back
 
 =cut
 =back
 
 =cut
@@ -625,11 +630,23 @@ sub send_receipt {
   {
     my $msgnum = $conf->config('payment_receipt_msgnum', $cust_main->agentnum);
     if ( $msgnum ) {
   {
     my $msgnum = $conf->config('payment_receipt_msgnum', $cust_main->agentnum);
     if ( $msgnum ) {
-      my $msg_template = FS::msg_template->by_key($msgnum);
-      $error = $msg_template->send(
-        'cust_main'   => $cust_main,
-        'object'      => $self,
-        'from_config' => 'payment_receipt_from',
+
+      my %substitutions = ();
+      $substitutions{invnum} = $opt->{cust_bill}->invnum if $opt->{cust_bill};
+
+      my $queue = new FS::queue {
+        'job'     => 'FS::Misc::process_send_email',
+        'paynum'  => $self->paynum,
+        'custnum' => $cust_main->custnum,
+      };
+      $error = $queue->insert(
+        FS::msg_template->by_key($msgnum)->prepare(
+          'cust_main'     => $cust_main,
+          'object'        => $self,
+          'from_config'   => 'payment_receipt_from',
+          'substitutions' => \%substitutions,
+        ),
+        'msgtype' => 'receipt', # override msg_template's default
       );
 
     } elsif ( $conf->exists('payment_receipt_email') ) {
       );
 
     } elsif ( $conf->exists('payment_receipt_email') ) {
@@ -663,13 +680,21 @@ sub send_receipt {
         'company_name' => $conf->config('company_name', $cust_main->agentnum),
       );
 
         'company_name' => $conf->config('company_name', $cust_main->agentnum),
       );
 
+      $fill_in{'invnum'} = $opt->{cust_bill}->invnum if $opt->{cust_bill};
+
       if ( $opt->{'cust_pkg'} ) {
         $fill_in{'pkg'} = $opt->{'cust_pkg'}->part_pkg->pkg;
         #setup date, other things?
       }
 
       if ( $opt->{'cust_pkg'} ) {
         $fill_in{'pkg'} = $opt->{'cust_pkg'}->part_pkg->pkg;
         #setup date, other things?
       }
 
-      $error = send_email(
-        'from'    => $conf->config('invoice_from', $cust_main->agentnum),
+      my $queue = new FS::queue {
+        'job'     => 'FS::Misc::process_send_generated_email',
+        'paynum'  => $self->paynum,
+        'custnum' => $cust_main->custnum,
+        'msgtype' => 'receipt',
+      };
+      $error = $queue->insert(
+        'from'    => $conf->invoice_from_full( $cust_main->agentnum ),
                                    #invoice_from??? well as good as any
         'to'      => \@invoicing_list,
         'subject' => 'Payment receipt',
                                    #invoice_from??? well as good as any
         'to'      => \@invoicing_list,
         'subject' => 'Payment receipt',
@@ -682,11 +707,13 @@ sub send_receipt {
 
     }
 
 
     }
 
-  } elsif ( ! $cust_main->invoice_noemail ) { #not manual
+  #not manual and no noemail flag (here or on the customer)
+  } elsif ( ! $opt->{'noemail'} && ! $cust_main->invoice_noemail ) {
 
     my $queue = new FS::queue {
 
     my $queue = new FS::queue {
-       'paynum' => $self->paynum,
-       'job'    => 'FS::cust_bill::queueable_email',
+       'job'     => 'FS::cust_bill::queueable_email',
+       'paynum'  => $self->paynum,
+       'custnum' => $cust_main->custnum,
     };
 
     $error = $queue->insert(
     };
 
     $error = $queue->insert(
@@ -698,7 +725,7 @@ sub send_receipt {
 
   }
   
 
   }
   
-    warn "send_receipt: $error\n" if $error;
+  warn "send_receipt: $error\n" if $error;
 }
 
 =item cust_bill_pay
 }
 
 =item cust_bill_pay
@@ -887,6 +914,13 @@ sub unapplied_sql {
 
 }
 
 
 }
 
+sub API_getinfo {
+ my $self = shift;
+ my @fields = grep { $_ ne 'payinfo' } $self->fields;
+ +{ ( map { $_=>$self->$_ } @fields ),
+  };
+}
+
 # _upgrade_data
 #
 # Used by FS::Upgrade to migrate to a new database.
 # _upgrade_data
 #
 # Used by FS::Upgrade to migrate to a new database.
@@ -894,7 +928,7 @@ sub unapplied_sql {
 use FS::h_cust_pay;
 
 sub _upgrade_data {  #class method
 use FS::h_cust_pay;
 
 sub _upgrade_data {  #class method
-  my ($class, %opts) = @_;
+  my ($class, %opt) = @_;
 
   warn "$me upgrading $class\n" if $DEBUG;
 
 
   warn "$me upgrading $class\n" if $DEBUG;
 
@@ -908,10 +942,11 @@ sub _upgrade_data {  #class method
 
     #not the most efficient, but hey, it only has to run once
 
 
     #not the most efficient, but hey, it only has to run once
 
-    my $where = "WHERE ( otaker IS NULL OR otaker = '' OR otaker = 'ivan' ) ".
-                "  AND usernum IS NULL ".
-                "  AND 0 < ( SELECT COUNT(*) FROM cust_main                 ".
-                "              WHERE cust_main.custnum = cust_pay.custnum ) ";
+    my $where = " WHERE ( otaker IS NULL OR otaker = '' OR otaker = 'ivan' )
+                    AND usernum IS NULL
+                    AND EXISTS ( SELECT 1 FROM cust_main                    
+                                   WHERE cust_main.custnum = cust_pay.custnum )
+                ";
 
     my $count_sql = "SELECT COUNT(*) FROM cust_pay $where";
 
 
     my $count_sql = "SELECT COUNT(*) FROM cust_pay $where";
 
@@ -943,7 +978,6 @@ sub _upgrade_data {  #class method
         $cust_pay->set('otaker', 'legacy');
       }
 
         $cust_pay->set('otaker', 'legacy');
       }
 
-      delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
       my $error = $cust_pay->replace;
 
       if ( $error ) {
       my $error = $cust_pay->replace;
 
       if ( $error ) {
@@ -952,8 +986,6 @@ sub _upgrade_data {  #class method
         next;
       }
 
         next;
       }
 
-      $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
-
       $count++;
       if ( $DEBUG > 1 && $lastprog + 30 < time ) {
         warn "$me $count/$total (".sprintf('%.2f',100*$count/$total). '%)'."\n";
       $count++;
       if ( $DEBUG > 1 && $lastprog + 30 < time ) {
         warn "$me $count/$total (".sprintf('%.2f',100*$count/$total). '%)'."\n";
@@ -1007,18 +1039,41 @@ sub _upgrade_data {  #class method
   # otaker->usernum upgrade
   ###
 
   # otaker->usernum upgrade
   ###
 
-  delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
-  $class->_upgrade_otaker(%opts);
-  $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
+  $class->_upgrade_otaker(%opt);
+
+  # if we do this anywhere else, it should become an FS::Upgrade method
+  my $num_to_upgrade = $class->count('paybatch is not null');
+  my $num_jobs = FS::queue->count('job = \'FS::cust_pay::process_upgrade_paybatch\' and status != \'failed\'');
+  if ( $num_to_upgrade > 0 ) {
+    warn "Need to migrate paybatch field in $num_to_upgrade payments.\n";
+    if ( $opt{queue} ) {
+      if ( $num_jobs > 0 ) {
+        warn "Upgrade already queued.\n";
+      } else {
+        warn "Scheduling upgrade.\n";
+        my $job = FS::queue->new({ job => 'FS::cust_pay::process_upgrade_paybatch' });
+        $job->insert;
+      }
+    } else {
+      process_upgrade_paybatch();
+    }
+  }
+}
+
+sub process_upgrade_paybatch {
+  my $dbh = dbh;
+  local $FS::payinfo_Mixin::ignore_masked_payinfo = 1;
+  local $FS::UID::AutoCommit = 1;
 
   ###
   # migrate batchnums from the misused 'paybatch' field to 'batchnum'
   ###
 
   ###
   # migrate batchnums from the misused 'paybatch' field to 'batchnum'
   ###
-  my @cust_pay = qsearch( {
-      'table'     => 'cust_pay',
-      'addl_from' => ' JOIN pay_batch ON cust_pay.paybatch = CAST(pay_batch.batchnum AS text) ',
+  my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
+  my $search = FS::Cursor->new( {
+    'table'     => 'cust_pay',
+    'addl_from' => " JOIN pay_batch ON cust_pay.paybatch = CAST(pay_batch.batchnum AS $text) ",
   } );
   } );
-  foreach my $cust_pay (@cust_pay) {
+  while (my $cust_pay = $search->fetch) {
     $cust_pay->set('batchnum' => $cust_pay->paybatch);
     $cust_pay->set('paybatch' => '');
     my $error = $cust_pay->replace;
     $cust_pay->set('batchnum' => $cust_pay->paybatch);
     $cust_pay->set('paybatch' => '');
     my $error = $cust_pay->replace;
@@ -1037,14 +1092,16 @@ sub _upgrade_data {  #class method
     foreach my $table (qw(cust_pay cust_pay_void cust_refund)) {
       my $and_batchnum_is_null =
         ( $table =~ /^cust_pay/ ? ' AND batchnum IS NULL' : '' );
     foreach my $table (qw(cust_pay cust_pay_void cust_refund)) {
       my $and_batchnum_is_null =
         ( $table =~ /^cust_pay/ ? ' AND batchnum IS NULL' : '' );
-      foreach my $object ( qsearch({
-            table     => $table,
-            extra_sql => "WHERE payby IN('CARD','CHEK') ".
-                         "AND (paybatch IS NOT NULL ".
-                         "OR (paybatch IS NULL AND auth IS NULL
-                         $and_batchnum_is_null ) )",
-          }) )
-      {
+      my $pkey = ($table =~ /^cust_pay/ ? 'paynum' : 'refundnum');
+      my $search = FS::Cursor->new({
+        table     => $table,
+        extra_sql => "WHERE payby IN('CARD','CHEK') ".
+                     "AND (paybatch IS NOT NULL ".
+                     "OR (paybatch IS NULL AND auth IS NULL
+                     $and_batchnum_is_null ) )
+                     ORDER BY $pkey DESC"
+      });
+      while ( my $object = $search->fetch ) {
         if ( $object->paybatch eq '' ) {
           # repair for a previous upgrade that didn't save 'auth'
           my $pkey = $object->primary_key;
         if ( $object->paybatch eq '' ) {
           # repair for a previous upgrade that didn't save 'auth'
           my $pkey = $object->primary_key;
@@ -1062,6 +1119,8 @@ sub _upgrade_data {  #class method
             warn "couldn't find paybatch history record for $table ".$object->$pkey."\n";
             next;
           }
             warn "couldn't find paybatch history record for $table ".$object->$pkey."\n";
             next;
           }
+          # if the paybatch didn't have an auth string, then it's fine
+          $h->paybatch =~ /:(\w+):/ or next;
           # set paybatch to what it was in that record
           $object->set('paybatch', $h->paybatch)
           # and then upgrade it like the old records
           # set paybatch to what it was in that record
           $object->set('paybatch', $h->paybatch)
           # and then upgrade it like the old records
@@ -1079,7 +1138,7 @@ sub _upgrade_data {  #class method
         }
       } #$object
     } #$table
         }
       } #$object
     } #$table
-    FS::upgrade_journal->set_done('cust_pay__parse_paybatch');
+    FS::upgrade_journal->set_done('cust_pay__parse_paybatch_1');
   }
 }
 
   }
 }
 
@@ -1098,18 +1157,24 @@ Inserts new payments.
 sub batch_import {
   my $param = shift;
 
 sub batch_import {
   my $param = shift;
 
-  my $fh = $param->{filehandle};
+  my $fh       = $param->{filehandle};
+  my $format   = $param->{'format'};
+
   my $agentnum = $param->{agentnum};
   my $agentnum = $param->{agentnum};
-  my $format = $param->{'format'};
+  my $_date    = $param->{_date};
+  $_date = parse_datetime($_date) if $_date && $_date =~ /\D/;
   my $paybatch = $param->{'paybatch'};
 
   my $paybatch = $param->{'paybatch'};
 
+  my $custnum_prefix = $conf->config('cust_main-custnum-display_prefix');
+  my $custnum_length = $conf->config('cust_main-custnum-display_length') || 8;
+
   # here is the agent virtualization
   my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
 
   my @fields;
   my $payby;
   if ( $format eq 'simple' ) {
   # here is the agent virtualization
   my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
 
   my @fields;
   my $payby;
   if ( $format eq 'simple' ) {
-    @fields = qw( custnum agent_custid paid payinfo );
+    @fields = qw( custnum agent_custid paid payinfo invnum );
     $payby = 'BILL';
   } elsif ( $format eq 'extended' ) {
     die "unimplemented\n";
     $payby = 'BILL';
   } elsif ( $format eq 'extended' ) {
     die "unimplemented\n";
@@ -1151,6 +1216,7 @@ sub batch_import {
       payby    => $payby,
       paybatch => $paybatch,
     );
       payby    => $payby,
       paybatch => $paybatch,
     );
+    $cust_pay{_date} = $_date if $_date;
 
     my $cust_main;
     foreach my $field ( @fields ) {
 
     my $cust_main;
     foreach my $field ( @fields ) {
@@ -1188,9 +1254,25 @@ sub batch_import {
       $cust_pay{$field} = shift @columns; 
     }
 
       $cust_pay{$field} = shift @columns; 
     }
 
+    if ( $custnum_prefix && $cust_pay{custnum} =~ /^$custnum_prefix(0*([1-9]\d*))$/
+                         && length($1) == $custnum_length ) {
+      $cust_pay{custnum} = $2;
+    }
+
+    my $custnum = $cust_pay{custnum};
+
     my $cust_pay = new FS::cust_pay( \%cust_pay );
     my $error = $cust_pay->insert;
 
     my $cust_pay = new FS::cust_pay( \%cust_pay );
     my $error = $cust_pay->insert;
 
+    if ( ! $error && $cust_pay->custnum != $custnum ) {
+      #invnum was defined, and ->insert set custnum to the customer for that
+      #invoice, but it wasn't the one the import specified.
+      $dbh->rollback if $oldAutoCommit;
+      $error = "specified invoice #". $cust_pay{invnum}.
+               " is for custnum ". $cust_pay->custnum.
+               ", not specified custnum $custnum";
+    }
+
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "can't insert payment for $line: $error";
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "can't insert payment for $line: $error";