optimize declined payment event condition, RT#81305
[freeside.git] / FS / FS / payinfo_Mixin.pm
1 package FS::payinfo_Mixin;
2
3 use strict;
4 use Business::CreditCard;
5 use FS::payby;
6 use FS::Record qw(qsearch);
7 use FS::UID qw(driver_name);
8 use FS::Cursor;
9 use Time::Local qw(timelocal);
10
11 # allow_closed_replace only relevant to cust_pay/cust_refund, for upgrade tokenizing
12 use vars qw( $ignore_masked_payinfo $allow_closed_replace );
13
14 =head1 NAME
15
16 FS::payinfo_Mixin - Mixin class for records in tables that contain payinfo.  
17
18 =head1 SYNOPSIS
19
20 package FS::some_table;
21 use vars qw(@ISA);
22 @ISA = qw( FS::payinfo_Mixin FS::Record );
23
24 =head1 DESCRIPTION
25
26 This is a mixin class for records that contain payinfo. 
27
28 =head1 FIELDS
29
30 =over 4
31
32 =item payby
33
34 The following payment types (payby) are supported:
35
36 For Customers (cust_main):
37 'CARD' (credit card - automatic), 'DCRD' (credit card - on-demand),
38 'CHEK' (electronic check - automatic), 'DCHK' (electronic check - on-demand),
39 'LECB' (Phone bill billing), 'BILL' (billing), 'COMP' (free), or
40 'PREPAY' (special billing type: applies a credit and sets billing type to I<BILL> - see L<FS::prepay_credit>)
41
42 For Refunds (cust_refund):
43 'CARD' (credit cards), 'CHEK' (electronic check/ACH),
44 'LECB' (Phone bill billing), 'BILL' (billing), 'CASH' (cash),
45 'WEST' (Western Union), 'MCRD' (Manual credit card), 'MCHK' (Manual electronic
46 check), 'CBAK' Chargeback, or 'COMP' (free)
47
48
49 For Payments (cust_pay):
50 'CARD' (credit cards), 'CHEK' (electronic check/ACH),
51 'LECB' (phone bill billing), 'BILL' (billing), 'PREP' (prepaid card),
52 'CASH' (cash), 'WEST' (Western Union), 'MCRD' (Manual credit card), 'MCHK'
53 (Manual electronic check), 'PPAL' (PayPal)
54 'COMP' (free) is depricated as a payment type in cust_pay
55
56 =cut 
57
58 =item payinfo
59
60 Payment information (payinfo) can be one of the following types:
61
62 Card Number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) 
63 prepayment identifier (see L<FS::prepay_credit>), PayPal transaction ID
64
65 =cut
66
67 sub payinfo {
68   my($self,$payinfo) = @_;
69
70   if ( defined($payinfo) ) {
71     $self->paymask($self->mask_payinfo) unless $self->getfield('paymask') || $self->tokenized; #make sure old mask is set
72     $self->setfield('payinfo', $payinfo);
73     $self->paymask($self->mask_payinfo) unless $self->tokenized($payinfo); #remask unless tokenizing
74   } else {
75     $self->getfield('payinfo');
76   }
77 }
78
79 =item paycvv
80
81 Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
82
83 =cut
84
85 #this prevents encrypting empty values on insert?
86 sub paycvv {
87   my($self,$paycvv) = @_;
88   # This is only allowed in cust_payby (formerly cust_main)
89   #  It shouldn't be stored longer than necessary to run the first transaction
90   if ( defined($paycvv) ) {
91     $self->setfield('paycvv', $paycvv);
92   } else {
93     $self->getfield('paycvv');
94   }
95 }
96
97 =item paymask
98
99 =cut
100
101 sub paymask {
102   my($self, $paymask) = @_;
103
104   if ( defined($paymask) ) {
105     $self->setfield('paymask', $paymask);
106   } else {
107     $self->getfield('paymask') || $self->mask_payinfo;
108   }
109 }
110
111 =back
112
113 =head1 METHODS
114
115 =over 4
116
117 =item mask_payinfo [ PAYBY, PAYINFO ]
118
119 This method converts the payment info (credit card, bank account, etc.) into a
120 masked string.
121
122 Optionally, an arbitrary payby and payinfo can be passed.
123
124 =cut
125
126 sub mask_payinfo {
127   my $self = shift;
128   my $payby   = scalar(@_) ? shift : $self->payby;
129   my $payinfo = scalar(@_) ? shift : $self->payinfo;
130
131   # Check to see if it's encrypted...
132   if ( ref($self) && $self->is_encrypted($payinfo) ) {
133     return 'N/A';
134   } elsif ( $self->tokenized($payinfo) || $payinfo eq 'N/A' ) { #token
135     return 'N/A (tokenized)'; #?
136   } else { # if not, mask it...
137
138     if ($payby eq 'CARD' || $payby eq 'DCRD') {
139                                                 #|| $payby eq 'MCRD') {
140                                                 #MCRD isn't a card in payinfo,
141                                                 #its a record of an _offline_
142                                                 #card
143
144       # Credit Cards
145
146       # special handling for Local Isracards: always show last 4 
147       if ( $payinfo =~ /^(\d{8,9})$/ ) {
148
149         return 'x'x(length($payinfo)-4).
150                substr($payinfo,(length($payinfo)-4));
151
152       }
153
154       my $conf = new FS::Conf;
155       my $mask_method = $conf->config('card_masking_method') || 'first6last4';
156       $mask_method =~ /^first(\d+)last(\d+)$/
157         or die "can't parse card_masking_method $mask_method";
158       my($first, $last) = ($1, $2);
159
160       return substr($payinfo,0,$first).
161              'x'x(length($payinfo)-$first-$last).
162              substr($payinfo,(length($payinfo)-$last));
163
164     } elsif ($payby eq 'CHEK' || $payby eq 'DCHK' ) {
165
166       # Checks (Show last 2 @ bank)
167       my( $account, $aba ) = split('@', $payinfo );
168       return 'x'x(length($account)-2).
169              substr($account,(length($account)-2)).
170              ( length($aba) ? "@".$aba : '');
171
172     } elsif ($payby eq 'EDI') {
173       # EDI.
174       # These numbers have been seen anywhere from 8 to 30 digits, and 
175       # possibly more.  Lacking any better idea I'm going to mask all but
176       # the last 4 digits.
177       return 'x' x (length($payinfo) - 4) . substr($payinfo, -4);
178
179     } else { # Tie up loose ends
180       return $payinfo;
181     }
182   }
183   #die "shouldn't be reached";
184 }
185
186 =item payinfo_check
187
188 Checks payby and payinfo.
189
190 =cut
191
192 sub payinfo_check {
193   my $self = shift;
194
195   FS::payby->can_payby($self->table, $self->payby)
196     or return "Illegal payby: ". $self->payby;
197
198   if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) {
199
200     # see parallel checks in cust_payby::check & cust_payby::check_payinfo_cardtype
201     if ( $self->tokenized ) {
202       $self->set('is_tokenized', 'Y'); #so we don't try to do it again
203       if ( $self->paymask =~ /^\d+x/ ) {
204         $self->set('paycardtype', cardtype($self->paymask));
205       } else {
206         $self->set('paycardtype', '') unless $self->paycardtype;
207         #return "paycardtype required ".
208         #       "(can't derive from a token and no paymask w/prefix provided)";
209       }
210     } else {
211       $self->set('paycardtype', cardtype($self->payinfo));
212     }
213
214     if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
215       # allow it
216     } else {
217       my $payinfo = $self->payinfo;
218       $payinfo =~ s/\D//g;
219       $self->payinfo($payinfo);
220       if ( $self->payinfo ) {
221         $self->payinfo =~ /^(\d{13,19}|\d{8,9})$/
222           or return "Illegal (mistyped?) credit card number (payinfo)";
223         $self->payinfo($1);
224         validate($self->payinfo) or return "Illegal credit card number";
225         return "Unknown card type" if $self->paycardtype eq "Unknown";
226       } else {
227         $self->payinfo('N/A'); #??? re-masks card
228       }
229     }
230
231   } else {
232
233     if ( $self->payby eq 'CARD' && $self->paymask =~ /^\d+x/  ) {
234       # if we can't decrypt the card, at least detect the cardtype
235       $self->set('paycardtype', cardtype($self->paymask));
236     } else {
237       $self->set('paycardtype', '') unless $self->paycardtype;
238       # return "paycardtype required ".
239       #        "(can't derive from a token and no paymask w/prefix provided)";
240     }
241
242     if ( $self->is_encrypted($self->payinfo) ) {
243       #something better?  all it would cause is a decryption error anyway?
244       my $error = $self->ut_anything('payinfo');
245       return $error if $error;
246     } else {
247       my $error = $self->ut_textn('payinfo');
248       return $error if $error;
249     }
250   }
251
252   return '';
253 }
254
255 =item payby_payinfo_pretty [ LOCALE ]
256
257 Returns payment method and information (suitably masked, if applicable) as
258 a human-readable string, such as:
259
260   Card #54xxxxxxxxxxxx32
261
262 or
263
264   Check #119006
265
266 =cut
267
268 sub payby_payinfo_pretty {
269   my $self = shift;
270   my $locale = shift;
271   my $lh = FS::L10N->get_handle($locale);
272   if ( $self->payby eq 'CARD' ) {
273     if ($self->paymask =~ /tokenized/) {
274       $lh->maketext('Tokenized Card');
275     } else {
276       $lh->maketext('Card #') . $self->paymask;
277     }
278   } elsif ( $self->payby eq 'CHEK' ) {
279
280     #false laziness w/view/cust_main/payment_history.html::translate_payinfo
281     my( $account, $aba ) = split('@', $self->paymask );
282
283     if ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #blame canada
284       my($branch, $routing) = ($1, $2);
285       $lh->maketext("Routing [_1], Branch [_2], Acct [_3]",
286                      $routing, $branch, $account);
287     } else {
288       $lh->maketext("Routing [_1], Acct [_2]", $aba, $account);
289     }
290
291   } elsif ( $self->payby eq 'BILL' ) {
292     $lh->maketext('Check #') . $self->payinfo;
293   } elsif ( $self->payby eq 'PREP' ) {
294     $lh->maketext('Prepaid card #') . $self->payinfo;
295   } elsif ( $self->payby eq 'CASH' ) {
296     $lh->maketext('Cash') . ' ' . $self->payinfo;
297   } elsif ( $self->payby eq 'WEST' ) {
298     # does Western Union localize their name?
299     $lh->maketext('Western Union');
300   } elsif ( $self->payby eq 'MCRD' ) {
301     $lh->maketext('Manual credit card');
302   } elsif ( $self->payby eq 'MCHK' ) {
303     $lh->maketext('Manual electronic check');
304   } elsif ( $self->payby eq 'EDI' ) {
305     $lh->maketext('EDI') . ' ' . $self->paymask;
306   } elsif ( $self->payby eq 'PPAL' ) {
307     $lh->maketext('PayPal transaction#') . $self->order_number;
308   } else {
309     $self->payby. ' '. $self->payinfo;
310   }
311 }
312
313 =item payinfo_used [ PAYINFO ]
314
315 Returns 1 if there's an existing payment using this payinfo.  This can be 
316 used to set the 'recurring payment' flag required by some processors.
317
318 =cut
319
320 sub payinfo_used {
321   my $self = shift;
322   my $payinfo = shift || $self->payinfo;
323   my %hash = (
324     'custnum' => $self->custnum,
325     'payby'   => $self->payby,
326   );
327
328   return 1
329   if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } )
330   || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo } )
331   ;
332
333   return 0;
334 }
335
336 =item display_status
337
338 For transactions that have both 'status' and 'failure_status', shows the
339 status in a single, display-friendly string.
340
341 =cut
342
343 sub display_status {
344   my $self = shift;
345   my %status = (
346     'done'        => 'Approved',
347     'expired'     => 'Card Expired',
348     'stolen'      => 'Lost/Stolen',
349     'pickup'      => 'Pick Up Card',
350     'nsf'         => 'Insufficient Funds',
351     'inactive'    => 'Inactive Account',
352     'blacklisted' => 'Blacklisted',
353     'declined'    => 'Declined',
354     'approved'    => 'Approved',
355   );
356   if ( $self->failure_status ) {
357     return $status{$self->failure_status};
358   } else {
359     return $status{$self->status};
360   }
361 }
362
363 =item paydate_monthyear
364
365 Returns a two-element list consisting of the month and year of this customer's
366 paydate (credit card expiration date for CARD customers)
367
368 =cut
369
370 sub paydate_monthyear {
371   my $self = shift;
372   if ( $self->paydate  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
373     ( $2, $1 );
374   } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
375     ( $1, $3 );
376   } else {
377     ('', '');
378   }
379 }
380
381 =item paydate_epoch
382
383 Returns the exact time in seconds corresponding to the payment method 
384 expiration date.  For CARD/DCRD customers this is the end of the month;
385 for others (COMP is the only other payby that uses paydate) it's the start.
386 Returns 0 if the paydate is empty or set to the far future.
387
388 =cut
389
390 sub paydate_epoch {
391   my $self = shift;
392   my ($month, $year) = $self->paydate_monthyear;
393   return 0 if !$year or $year >= 2037;
394   if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) {
395     $month++;
396     if ( $month == 13 ) {
397       $month = 1;
398       $year++;
399     }
400     return timelocal(0,0,0,1,$month-1,$year) - 1;
401   }
402   else {
403     return timelocal(0,0,0,1,$month-1,$year);
404   }
405 }
406
407 =item paydate_epoch_sql
408
409 Class method.  Returns an SQL expression to obtain the payment expiration date
410 as a number of seconds.
411
412 =cut
413
414 # Special expiration date behavior for non-CARD/DCRD customers has been 
415 # carefully preserved.  Do we really use that?
416 sub paydate_epoch_sql {
417   my $class = shift;
418   my $table = $class->table;
419   my ($case1, $case2);
420   if ( driver_name eq 'Pg' ) {
421     $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1";
422     $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )";
423   }
424   elsif ( lc(driver_name) eq 'mysql' ) {
425     $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1";
426     $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )";
427   }
428   else { return '' }
429   return "CASE WHEN $table.payby IN('CARD','DCRD') 
430   THEN ($case1)
431   ELSE ($case2)
432   END"
433 }
434
435 =item upgrade_set_cardtype
436
437 Find all records with a credit card payment type and no paycardtype, and
438 replace them in order to set their paycardtype.
439
440 This method actually just starts a queue job.
441
442 =cut
443
444 sub upgrade_set_cardtype {
445   my $class = shift;
446   my $table = $class->table or die "upgrade_set_cardtype needs a table";
447
448   if ( ! FS::upgrade_journal->is_done("${table}__set_cardtype") ) {
449     my $job = FS::queue->new({ job => 'FS::payinfo_Mixin::process_set_cardtype' });
450     my $error = $job->insert($table);
451     die $error if $error;
452     FS::upgrade_journal->set_done("${table}__set_cardtype");
453   }
454 }
455
456 sub process_set_cardtype {
457   my $table = shift;
458
459   # assign cardtypes to CARD/DCRDs that need them; check_payinfo_cardtype
460   # will do this. ignore any problems with the cards.
461   local $ignore_masked_payinfo = 1;
462   my $search = FS::Cursor->new({
463     table     => $table,
464     extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ],
465   });
466   while (my $record = $search->fetch) {
467     my $error = $record->replace;
468     die $error if $error;
469   }
470 }
471
472 =item tokenized [ PAYINFO ]
473
474 Returns true if object payinfo is tokenized
475
476 Optionally, an arbitrary payby and payinfo can be passed.
477
478 =cut
479
480 sub tokenized {
481   my $self = shift;
482   my $payinfo = scalar(@_) ? shift : $self->payinfo;
483   return 0 unless $payinfo; #avoid uninitialized value error
484   $payinfo =~ /^99\d{14}$/;
485 }
486
487 =back
488
489 =head1 BUGS
490
491 =head1 SEE ALSO
492
493 L<FS::payby>, L<FS::Record>
494
495 =cut
496
497 1;
498