RT# 83450 - fixed rateplan export
[freeside.git] / FS / FS / cust_pay_pending.pm
index 7d81754..8f313f4 100644 (file)
@@ -1,16 +1,13 @@
 package FS::cust_pay_pending;
+use base qw( FS::payinfo_transaction_Mixin FS::cust_main_Mixin FS::Record );
 
 use strict;
-use vars qw( @ISA  @encrypted_fields );
-use FS::Record qw( qsearch qsearchs );
-use FS::payby;
-use FS::payinfo_Mixin;
-use FS::cust_main;
+use vars qw( @encrypted_fields );
+use FS::Record qw( qsearchs dbh ); #dbh for _upgrade_data
 use FS::cust_pay;
 
-@ISA = qw(FS::Record FS::payinfo_Mixin);
-
 @encrypted_fields = ('payinfo');
+sub nohistory_fields { ('payinfo'); }
 
 =head1 NAME
 
@@ -40,35 +37,122 @@ following fields are currently supported:
 
 =over 4
 
-=item paypendingnum - primary key
+=item paypendingnum
+
+Primary key
+
+=item custnum
+
+Customer (see L<FS::cust_main>)
+
+=item paid
 
-=item custnum - customer (see L<FS::cust_main>)
+Amount of this payment
 
-=item paid - Amount of this payment
+=item _date
 
-=item _date - specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
+Specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
-=item payby - Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
+=item payby
 
-=item payinfo - Payment Information (See L<FS::payinfo_Mixin> for data format)
+Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
 
-=item paymask - Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
+=item payinfo
 
-=item paydate - Expiration date
+Payment Information (See L<FS::payinfo_Mixin> for data format)
 
-=item payunique - Unique identifer to prevent duplicate transactions.
+=item paymask
 
-=item status - new (acquires basic lock on payunique), pending (transaction is pending with the gateway), authorized (only used for two-stage transactions that require a separate capture step), captured/declined (transaction completed with payment gateway, not yet recorded in the database), done (transaction recorded in database)
+Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
 
-=item statustext - 
+=item paydate
 
-=cut
+Expiration date
+
+=item payunique
+
+Unique identifer to prevent duplicate transactions.
+
+=item pkgnum
+
+Desired pkgnum when using experimental package balances.
+
+=item status
+
+Pending transaction status, one of the following:
+
+=over 4
+
+=item new
+
+Aquires basic lock on payunique
+
+=item pending
 
-#=item cust_balance - 
+Transaction is pending with the gateway
 
-=item paynum - 
+=item thirdparty
 
+Customer has been sent to an off-site payment gateway to complete processing
+
+=item authorized
+
+Only used for two-stage transactions that require a separate capture step
+
+=item captured
+
+Transaction completed with payment gateway (sucessfully), not yet recorded in
+the database
+
+=item declined
+
+Transaction completed with payment gateway (declined), not yet recorded in
+the database
+
+=item done
+
+Transaction recorded in database
+
+=back
+
+=item statustext
+
+Additional status information.
+
+=item failure_status
+
+One of the standard failure status strings defined in 
+L<Business::OnlinePayment>: "expired", "nsf", "stolen", "pickup", 
+"blacklisted", "declined".  If the transaction status is not "declined", 
+this will be empty.
+
+=item gatewaynum
+
+L<FS::payment_gateway> id.
+
+=item paynum
+
+Payment number (L<FS::cust_pay>) of the completed payment.
+
+=item void_paynum
+
+Payment number of the payment if it's been voided.
+
+=item invnum
+
+Invoice number (L<FS::cust_bill>) to try to apply this payment to.
+
+=item manual
+
+Flag for whether this is a "manual" payment (i.e. initiated through 
+self-service or the back-office web interface, rather than from an event
+or a payment batch).  "Manual" payments will cause the customer to be 
+sent a payment receipt rather than a statement.
+
+=item discount_term
+
+Number of months the customer tried to prepay for.
 
 =back
 
@@ -131,19 +215,30 @@ sub check {
 
   my $error = 
     $self->ut_numbern('paypendingnum')
-    || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
+    || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
     || $self->ut_money('paid')
     || $self->ut_numbern('_date')
     || $self->ut_textn('payunique')
     || $self->ut_text('status')
     #|| $self->ut_textn('statustext')
     || $self->ut_anything('statustext')
+    || $self->ut_textn('failure_status')
     #|| $self->ut_money('cust_balance')
+    || $self->ut_hexn('session_id')
     || $self->ut_foreign_keyn('paynum', 'cust_pay', 'paynum' )
+    || $self->ut_foreign_keyn('pkgnum', 'cust_pkg', 'pkgnum')
+    || $self->ut_foreign_keyn('invnum', 'cust_bill', 'invnum')
+    || $self->ut_foreign_keyn('void_paynum', 'cust_pay_void', 'paynum' )
+    || $self->ut_flag('manual')
+    || $self->ut_numbern('discount_term')
     || $self->payinfo_check() #payby/payinfo/paymask/paydate
   ;
   return $error if $error;
 
+  if (!$self->custnum and !$self->get('custnum_pending')) {
+    return 'custnum required';
+  }
+
   $self->_date(time) unless $self->_date;
 
   # UNIQUE index should catch this too, without race conditions, but this
@@ -163,6 +258,265 @@ sub check {
   $self->SUPER::check;
 }
 
+=item cust_main
+
+Returns the associated L<FS::cust_main> record if any.  Otherwise returns false.
+
+=cut
+
+#these two are kind-of false laziness w/cust_main::realtime_bop
+#(currently only used when resolving pending payments manually)
+
+=item insert_cust_pay
+
+Sets the status of this pending pament to "done" (with statustext
+"captured (manual)"), and inserts a payment record (see L<FS::cust_pay>).
+
+Currently only used when resolving pending payments manually.
+
+=cut
+
+sub insert_cust_pay {
+  my $self = shift;
+
+  my $cust_pay = new FS::cust_pay ( {
+     'custnum'  => $self->custnum,
+     'paid'     => $self->paid,
+     '_date'    => $self->_date, #better than passing '' for now
+     'payby'    => $self->payby,
+     'payinfo'  => $self->payinfo,
+     'paybatch' => $self->paybatch,
+     'paydate'  => $self->paydate,
+  } );
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
+
+  my $error = $cust_pay->insert;#($options{'manual'} ? ( 'manual' => 1 ) : () );
+
+  if ( $error ) {
+    # gah.
+    $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+    return $error;
+  }
+
+  $self->status('done');
+  $self->statustext('captured (manual)');
+  $self->paynum($cust_pay->paynum);
+  my $cpp_done_err = $self->replace;
+
+  if ( $cpp_done_err ) {
+
+    $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+    return $cpp_done_err;
+
+  } else {
+
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    return ''; #no error
+
+  }
+
+}
+
+=item approve OPTIONS
+
+Sets the status of this pending payment to "done" and creates a completed 
+payment (L<FS::cust_pay>).  This should be called when a realtime or 
+third-party payment has been approved.
+
+OPTIONS may include any of 'processor', 'payinfo', 'discount_term', 'auth',
+and 'order_number' to set those fields on the completed payment, as well as 
+'apply' to apply payments for this customer after inserting the new payment.
+
+=cut
+
+sub approve {
+  my $self = shift;
+  my %opt = @_;
+
+  my $dbh = dbh;
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+
+  my $cust_pay = FS::cust_pay->new({
+      'custnum'     => $self->custnum,
+      'invnum'      => $self->invnum,
+      'pkgnum'      => $self->pkgnum,
+      'paid'        => $self->paid,
+      '_date'       => '',
+      'payby'       => $self->payby,
+      'payinfo'     => $self->payinfo,
+      'gatewaynum'  => $self->gatewaynum,
+  });
+  foreach my $opt_field (qw(processor payinfo auth order_number))
+  {
+    $cust_pay->set($opt_field, $opt{$opt_field}) if exists $opt{$opt_field};
+  }
+
+  my %insert_opt = (
+    'manual'        => $self->manual,
+    'discount_term' => $self->discount_term,
+  );
+  my $error = $cust_pay->insert( %insert_opt );
+  if ( $error ) {
+    # try it again without invnum or discount
+    # (both of those can make payments fail to insert, and at this point
+    # the payment is a done deal and MUST be recorded)
+    $self->invnum('');
+    my $error2 = $cust_pay->insert('manual' => $self->manual);
+    if ( $error2 ) {
+      # attempt to void the payment?
+      # no, we'll just stop digging at this point.
+      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+      my $e = "WARNING: payment captured but not recorded - error inserting ".
+              "payment (". ($opt{processor} || $self->payby) . 
+              ": $error2\n(previously tried insert with invnum#".$self->invnum.
+              ": $error)\npending payment saved as paypendingnum#".
+              $self->paypendingnum."\n\n";
+      warn $e;
+      return $e;
+    }
+  }
+  if ( my $jobnum = $self->jobnum ) {
+    my $placeholder = FS::queue->by_key($jobnum);
+    my $error;
+    if (!$placeholder) {
+      $error = "not found";
+    } else {
+      $error = $placeholder->delete;
+    }
+
+    if ($error) {
+      $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+      my $e  = "WARNING: payment captured but could not delete job $jobnum ".
+               "for paypendingnum #" . $self->paypendingnum . ": $error\n\n";
+      warn $e;
+      return $e;
+    }
+    
+    $self->set('jobnum','');
+  }
+
+  if ( $opt{'paynum_ref'} ) {
+    ${ $opt{'paynum_ref'} } = $cust_pay->paynum;
+  }
+
+  $self->status('done');
+  $self->statustext('captured');
+  $self->paynum($cust_pay->paynum);
+  my $cpp_done_err = $self->replace;
+
+  if ( $cpp_done_err ) {
+
+    $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+    my $e = "WARNING: payment captured but could not update pending status ".
+            "for paypendingnum ".$self->paypendingnum.": $cpp_done_err \n\n";
+    warn $e;
+    return $e;
+
+  } else {
+
+    # commit at this stage--we don't want to roll back if applying 
+    # payments fails
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+    if ( $opt{'apply'} ) {
+      my $apply_error = $self->apply_payments_and_credits;
+      if ( $apply_error ) {
+        warn "WARNING: error applying payment: $apply_error\n\n";
+      }
+    }
+  }
+  '';
+}
+
+=item decline [ STATUSTEXT [ STATUS ] ]
+
+Sets the status of this pending payment to "done" (with statustext
+"declined (manual)" unless otherwise specified).  The optional STATUS can be
+used to set the failure_status field.
+
+Currently only used when resolving pending payments manually.
+
+=cut
+
+sub decline {
+  my $self = shift;
+  my $statustext = shift || "declined (manual)";
+  my $failure_status = shift || '';
+
+  #could send decline email too?  doesn't seem useful in manual resolution
+  # this is also used for thirdparty payment execution failures, but a decline
+  # email isn't useful there either, and will just confuse people.
+
+  $self->status('done');
+  $self->statustext($statustext);
+  $self->failure_status($failure_status);
+  $self->replace;
+}
+
+=item reverse [ STATUSTEXT ]
+
+Sets the status of this pending payment to "done" (with statustext
+"reversed (manual)" unless otherwise specified).
+
+Currently only used when resolving pending payments manually.
+
+=cut
+
+# almost complete false laziness with decline,
+# but want to avoid confusion, in case any additional steps/defaults are ever added to either
+sub reverse {
+  my $self = shift;
+  my $statustext = shift || "reversed (manual)";
+
+  $self->status('done');
+  $self->statustext($statustext);
+  $self->replace;
+}
+
+# _upgrade_data
+#
+# Used by FS::Upgrade to migrate to a new database.
+
+sub _upgrade_data {  #class method
+  my ($class, %opts) = @_;
+
+  my $sql =
+    "DELETE FROM cust_pay_pending WHERE status = 'new' AND _date < ".(time-600);
+
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute or die $sth->errstr;
+
+}
+
+sub _upgrade_schema {
+  my ($class, %opts) = @_;
+
+  # fix records where jobnum points to a nonexistent queue job
+  my $sql = 'UPDATE cust_pay_pending SET jobnum = NULL
+    WHERE NOT EXISTS (
+      SELECT 1 FROM queue WHERE queue.jobnum = cust_pay_pending.jobnum
+    )';
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute or die $sth->errstr;
+
+  # fix records where custnum points to a nonexistent customer
+  $sql = 'UPDATE cust_pay_pending SET custnum = NULL
+    WHERE NOT EXISTS (
+      SELECT 1 FROM cust_main WHERE cust_main.custnum = cust_pay_pending.custnum
+    )';
+  $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute or die $sth->errstr;
+
+
+  '';
+}
+
 =back
 
 =head1 BUGS