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