de-transactionize cust_pay_pending updates during card verification, #57135
[freeside.git] / FS / FS / cust_pay_pending.pm
1 package FS::cust_pay_pending;
2
3 use strict;
4 use vars qw( @ISA  @encrypted_fields );
5 use FS::Record qw( qsearch qsearchs dbh ); #dbh for _upgrade_data
6 use FS::payinfo_transaction_Mixin;
7 use FS::cust_main_Mixin;
8 use FS::cust_main;
9 use FS::cust_pkg;
10 use FS::cust_pay;
11
12 @ISA = qw( FS::payinfo_transaction_Mixin FS::cust_main_Mixin FS::Record );
13
14 @encrypted_fields = ('payinfo');
15 sub nohistory_fields { ('payinfo'); }
16
17 =head1 NAME
18
19 FS::cust_pay_pending - Object methods for cust_pay_pending records
20
21 =head1 SYNOPSIS
22
23   use FS::cust_pay_pending;
24
25   $record = new FS::cust_pay_pending \%hash;
26   $record = new FS::cust_pay_pending { 'column' => 'value' };
27
28   $error = $record->insert;
29
30   $error = $new_record->replace($old_record);
31
32   $error = $record->delete;
33
34   $error = $record->check;
35
36 =head1 DESCRIPTION
37
38 An FS::cust_pay_pending object represents an pending payment.  It reflects 
39 local state through the multiple stages of processing a real-time transaction
40 with an external gateway.  FS::cust_pay_pending inherits from FS::Record.  The
41 following fields are currently supported:
42
43 =over 4
44
45 =item paypendingnum
46
47 Primary key
48
49 =item custnum
50
51 Customer (see L<FS::cust_main>)
52
53 =item paid
54
55 Amount of this payment
56
57 =item _date
58
59 Specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
60 L<Time::Local> and L<Date::Parse> for conversion functions.
61
62 =item payby
63
64 Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
65
66 =item payinfo
67
68 Payment Information (See L<FS::payinfo_Mixin> for data format)
69
70 =item paymask
71
72 Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
73
74 =item paydate
75
76 Expiration date
77
78 =item payunique
79
80 Unique identifer to prevent duplicate transactions.
81
82 =item pkgnum
83
84 Desired pkgnum when using experimental package balances.
85
86 =item status
87
88 Pending transaction status, one of the following:
89
90 =over 4
91
92 =item new
93
94 Aquires basic lock on payunique
95
96 =item pending
97
98 Transaction is pending with the gateway
99
100 =item thirdparty
101
102 Customer has been sent to an off-site payment gateway to complete processing
103
104 =item authorized
105
106 Only used for two-stage transactions that require a separate capture step
107
108 =item captured
109
110 Transaction completed with payment gateway (sucessfully), not yet recorded in
111 the database
112
113 =item declined
114
115 Transaction completed with payment gateway (declined), not yet recorded in
116 the database
117
118 =item done
119
120 Transaction recorded in database
121
122 =back
123
124 =item statustext
125
126 Additional status information.
127
128 =item gatewaynum
129
130 L<FS::payment_gateway> id.
131
132 =item paynum
133
134 Payment number (L<FS::cust_pay>) of the completed payment.
135
136 =item void_paynum
137
138 Payment number of the payment if it's been voided.
139
140 =item invnum
141
142 Invoice number (L<FS::cust_bill>) to try to apply this payment to.
143
144 =item manual
145
146 Flag for whether this is a "manual" payment (i.e. initiated through 
147 self-service or the back-office web interface, rather than from an event
148 or a payment batch).  "Manual" payments will cause the customer to be 
149 sent a payment receipt rather than a statement.
150
151 =item discount_term
152
153 Number of months the customer tried to prepay for.
154
155 =back
156
157 =head1 METHODS
158
159 =over 4
160
161 =item new HASHREF
162
163 Creates a new pending payment.  To add the pending payment to the database, see L<"insert">.
164
165 Note that this stores the hash reference, not a distinct copy of the hash it
166 points to.  You can ask the object for a copy with the I<hash> method.
167
168 =cut
169
170 # the new method can be inherited from FS::Record, if a table method is defined
171
172 sub table { 'cust_pay_pending'; }
173
174 =item insert
175
176 Adds this record to the database.  If there is an error, returns the error,
177 otherwise returns false.
178
179 =cut
180
181 # the insert method can be inherited from FS::Record
182
183 =item delete
184
185 Delete this record from the database.
186
187 =cut
188
189 # the delete method can be inherited from FS::Record
190
191 =item replace OLD_RECORD
192
193 Replaces the OLD_RECORD with this one in the database.  If there is an error,
194 returns the error, otherwise returns false.
195
196 =cut
197
198 # the replace method can be inherited from FS::Record
199
200 =item check
201
202 Checks all fields to make sure this is a valid pending payment.  If there is
203 an error, returns the error, otherwise returns false.  Called by the insert
204 and replace methods.
205
206 =cut
207
208 # the check method should currently be supplied - FS::Record contains some
209 # data checking routines
210
211 sub check {
212   my $self = shift;
213
214   my $error = 
215     $self->ut_numbern('paypendingnum')
216     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
217     || $self->ut_money('paid')
218     || $self->ut_numbern('_date')
219     || $self->ut_textn('payunique')
220     || $self->ut_text('status')
221     #|| $self->ut_textn('statustext')
222     || $self->ut_anything('statustext')
223     #|| $self->ut_money('cust_balance')
224     || $self->ut_hexn('session_id')
225     || $self->ut_foreign_keyn('paynum', 'cust_pay', 'paynum' )
226     || $self->ut_foreign_keyn('pkgnum', 'cust_pkg', 'pkgnum')
227     || $self->ut_foreign_keyn('invnum', 'cust_bill', 'invnum')
228     || $self->ut_foreign_keyn('void_paynum', 'cust_pay_void', 'paynum' )
229     || $self->ut_flag('manual')
230     || $self->ut_numbern('discount_term')
231     || $self->payinfo_check() #payby/payinfo/paymask/paydate
232   ;
233   return $error if $error;
234
235   if (!$self->custnum and !$self->get('custnum_pending')) {
236     return 'custnum required';
237   }
238
239   $self->_date(time) unless $self->_date;
240
241   # UNIQUE index should catch this too, without race conditions, but this
242   # should give a better error message the other 99.9% of the time...
243   if ( length($self->payunique) ) {
244     my $cust_pay_pending = qsearchs('cust_pay_pending', {
245       'payunique'     => $self->payunique,
246       'paypendingnum' => { op=>'!=', value=>$self->paypendingnum },
247     });
248     if ( $cust_pay_pending ) {
249       #well, it *could* be a better error message
250       return "duplicate transaction - a payment with unique identifer ".
251              $self->payunique. " already exists";
252     }
253   }
254
255   $self->SUPER::check;
256 }
257
258 =item cust_main
259
260 Returns the associated L<FS::cust_main> record if any.  Otherwise returns false.
261
262 =cut
263
264 sub cust_main {
265   my $self = shift;
266   qsearchs('cust_main', { custnum => $self->custnum } );
267 }
268
269
270 #these two are kind-of false laziness w/cust_main::realtime_bop
271 #(currently only used when resolving pending payments manually)
272
273 =item insert_cust_pay
274
275 Sets the status of this pending pament to "done" (with statustext
276 "captured (manual)"), and inserts a payment record (see L<FS::cust_pay>).
277
278 Currently only used when resolving pending payments manually.
279
280 =cut
281
282 sub insert_cust_pay {
283   my $self = shift;
284
285   my $cust_pay = new FS::cust_pay ( {
286      'custnum'  => $self->custnum,
287      'paid'     => $self->paid,
288      '_date'    => $self->_date, #better than passing '' for now
289      'payby'    => $self->payby,
290      'payinfo'  => $self->payinfo,
291      'paybatch' => $self->paybatch,
292      'paydate'  => $self->paydate,
293   } );
294
295   my $oldAutoCommit = $FS::UID::AutoCommit;
296   local $FS::UID::AutoCommit = 0;
297   my $dbh = dbh;
298
299   #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
300
301   my $error = $cust_pay->insert;#($options{'manual'} ? ( 'manual' => 1 ) : () );
302
303   if ( $error ) {
304     # gah.
305     $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
306     return $error;
307   }
308
309   $self->status('done');
310   $self->statustext('captured (manual)');
311   $self->paynum($cust_pay->paynum);
312   my $cpp_done_err = $self->replace;
313
314   if ( $cpp_done_err ) {
315
316     $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
317     return $cpp_done_err;
318
319   } else {
320
321     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
322     return ''; #no error
323
324   }
325
326 }
327
328 =item approve OPTIONS
329
330 Sets the status of this pending payment to "done" and creates a completed 
331 payment (L<FS::cust_pay>).  This should be called when a realtime or 
332 third-party payment has been approved.
333
334 OPTIONS may include any of 'processor', 'payinfo', 'discount_term', 'auth',
335 and 'order_number' to set those fields on the completed payment, as well as 
336 'apply' to apply payments for this customer after inserting the new payment.
337
338 =cut
339
340 sub approve {
341   my $self = shift;
342   my %opt = @_;
343
344   my $dbh = dbh;
345   my $oldAutoCommit = $FS::UID::AutoCommit;
346   local $FS::UID::AutoCommit = 0;
347
348   my $cust_pay = FS::cust_pay->new({
349       'custnum'     => $self->custnum,
350       'invnum'      => $self->invnum,
351       'pkgnum'      => $self->pkgnum,
352       'paid'        => $self->paid,
353       '_date'       => '',
354       'payby'       => $self->payby,
355       'payinfo'     => $self->payinfo,
356       'gatewaynum'  => $self->gatewaynum,
357   });
358   foreach my $opt_field (qw(processor payinfo auth order_number))
359   {
360     $cust_pay->set($opt_field, $opt{$opt_field}) if exists $opt{$opt_field};
361   }
362
363   my %insert_opt = (
364     'manual'        => $self->manual,
365     'discount_term' => $self->discount_term,
366   );
367   my $error = $cust_pay->insert( %insert_opt );
368   if ( $error ) {
369     # try it again without invnum or discount
370     # (both of those can make payments fail to insert, and at this point
371     # the payment is a done deal and MUST be recorded)
372     $self->invnum('');
373     my $error2 = $cust_pay->insert('manual' => $self->manual);
374     if ( $error2 ) {
375       # attempt to void the payment?
376       # no, we'll just stop digging at this point.
377       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
378       my $e = "WARNING: payment captured but not recorded - error inserting ".
379               "payment (". ($opt{processor} || $self->payby) . 
380               ": $error2\n(previously tried insert with invnum#".$self->invnum.
381               ": $error)\npending payment saved as paypendingnum#".
382               $self->paypendingnum."\n\n";
383       warn $e;
384       return $e;
385     }
386   }
387   if ( my $jobnum = $self->jobnum ) {
388     my $placeholder = FS::queue->by_key($jobnum);
389     my $error;
390     if (!$placeholder) {
391       $error = "not found";
392     } else {
393       $error = $placeholder->delete;
394     }
395
396     if ($error) {
397       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
398       my $e  = "WARNING: payment captured but could not delete job $jobnum ".
399                "for paypendingnum #" . $self->paypendingnum . ": $error\n\n";
400       warn $e;
401       return $e;
402     }
403   }
404
405   if ( $opt{'paynum_ref'} ) {
406     ${ $opt{'paynum_ref'} } = $cust_pay->paynum;
407   }
408
409   $self->status('done');
410   $self->statustext('captured');
411   $self->paynum($cust_pay->paynum);
412   my $cpp_done_err = $self->replace;
413
414   if ( $cpp_done_err ) {
415
416     $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
417     my $e = "WARNING: payment captured but could not update pending status ".
418             "for paypendingnum ".$self->paypendingnum.": $cpp_done_err \n\n";
419     warn $e;
420     return $e;
421
422   } else {
423
424     # commit at this stage--we don't want to roll back if applying 
425     # payments fails
426     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
427
428     if ( $opt{'apply'} ) {
429       my $apply_error = $self->apply_payments_and_credits;
430       if ( $apply_error ) {
431         warn "WARNING: error applying payment: $apply_error\n\n";
432       }
433     }
434   }
435   '';
436 }
437
438 =item decline [ STATUSTEXT ]
439
440 Sets the status of this pending payment to "done" (with statustext
441 "declined (manual)" unless otherwise specified).
442
443 Currently only used when resolving pending payments manually.
444
445 =cut
446
447 sub decline {
448   my $self = shift;
449   my $statustext = shift || "declined (manual)";
450
451   #could send decline email too?  doesn't seem useful in manual resolution
452
453   $self->status('done');
454   $self->statustext($statustext);
455   $self->replace;
456 }
457
458 =item reverse [ STATUSTEXT ]
459
460 Sets the status of this pending payment to "done" (with statustext
461 "reversed (manual)" unless otherwise specified).
462
463 Currently only used when resolving pending payments manually.
464
465 =cut
466
467 # almost complete false laziness with decline,
468 # but want to avoid confusion, in case any additional steps/defaults are ever added to either
469 sub reverse {
470   my $self = shift;
471   my $statustext = shift || "reversed (manual)";
472
473   $self->status('done');
474   $self->statustext($statustext);
475   $self->replace;
476 }
477
478 # _upgrade_data
479 #
480 # Used by FS::Upgrade to migrate to a new database.
481
482 sub _upgrade_data {  #class method
483   my ($class, %opts) = @_;
484
485   my $sql =
486     "DELETE FROM cust_pay_pending WHERE status = 'new' AND _date < ".(time-600);
487
488   my $sth = dbh->prepare($sql) or die dbh->errstr;
489   $sth->execute or die $sth->errstr;
490
491   # For cust_pay_pending records linked to voided payments, move the paynum
492   # to void_paynum.
493   $sql =
494     "UPDATE cust_pay_pending SET void_paynum = paynum, paynum = NULL 
495     WHERE paynum IS NOT NULL AND void_paynum IS NULL AND EXISTS(
496       SELECT 1 FROM cust_pay_void
497       WHERE cust_pay_void.paynum = cust_pay_pending.paynum
498     ) AND NOT EXISTS(
499       SELECT 1 FROM cust_pay
500       WHERE cust_pay.paynum = cust_pay_pending.paynum
501     )";
502   $sth = dbh->prepare($sql) or die dbh->errstr;
503   $sth->execute or die $sth->errstr;
504
505 }
506
507 sub _upgrade_schema {
508   my ($class, %opts) = @_;
509
510   # fix records where jobnum points to a nonexistent queue job
511   my $sql = 'UPDATE cust_pay_pending SET jobnum = NULL
512     WHERE NOT EXISTS (
513       SELECT 1 FROM queue WHERE queue.jobnum = cust_pay_pending.jobnum
514     )';
515   my $sth = dbh->prepare($sql) or die dbh->errstr;
516   $sth->execute or die $sth->errstr;
517   '';
518 }
519
520 =back
521
522 =head1 BUGS
523
524 =head1 SEE ALSO
525
526 L<FS::Record>, schema.html from the base documentation.
527
528 =cut
529
530 1;
531