deal with the data we have as best we can without erroring out, RT#71513
[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     unless ( $self->paycardtype ) {
201
202       if ( $self->tokenized ) {
203         if ( $self->paymask =~ /^\d+x/ ) {
204           $self->set('paycardtype', cardtype($self->paymask));
205         } else {
206           $self->set('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     }
215
216     if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
217       # allow it
218     } else {
219       my $payinfo = $self->payinfo;
220       $payinfo =~ s/\D//g;
221       $self->payinfo($payinfo);
222       if ( $self->payinfo ) {
223         $self->payinfo =~ /^(\d{13,16}|\d{8,9})$/
224           or return "Illegal (mistyped?) credit card number (payinfo)";
225         $self->payinfo($1);
226         validate($self->payinfo) or return "Illegal credit card number";
227         return "Unknown card type" if $self->paycardtype eq "Unknown";
228       } else {
229         $self->payinfo('N/A'); #??? re-masks card
230       }
231     }
232
233   } else {
234
235     unless ( $self->paycardtype ) {
236
237       if ( $self->payby eq 'CARD' && $self->paymask =~ /^\d+x/  ) {
238         # if we can't decrypt the card, at least detect the cardtype
239         $self->set('paycardtype', cardtype($self->paymask));
240       } else {
241         $self->set('paycardtype', '');
242         # return "paycardtype required ".
243         #        "(can't derive from a token and no paymask w/prefix provided)";
244       }
245
246     }
247
248     if ( $self->is_encrypted($self->payinfo) ) {
249       #something better?  all it would cause is a decryption error anyway?
250       my $error = $self->ut_anything('payinfo');
251       return $error if $error;
252     } else {
253       my $error = $self->ut_textn('payinfo');
254       return $error if $error;
255     }
256   }
257
258   return '';
259 }
260
261 =item payby_payinfo_pretty [ LOCALE ]
262
263 Returns payment method and information (suitably masked, if applicable) as
264 a human-readable string, such as:
265
266   Card #54xxxxxxxxxxxx32
267
268 or
269
270   Check #119006
271
272 =cut
273
274 sub payby_payinfo_pretty {
275   my $self = shift;
276   my $locale = shift;
277   my $lh = FS::L10N->get_handle($locale);
278   if ( $self->payby eq 'CARD' ) {
279     if ($self->paymask =~ /tokenized/) {
280       $lh->maketext('Tokenized Card');
281     } else {
282       $lh->maketext('Card #') . $self->paymask;
283     }
284   } elsif ( $self->payby eq 'CHEK' ) {
285
286     #false laziness w/view/cust_main/payment_history.html::translate_payinfo
287     my( $account, $aba ) = split('@', $self->paymask );
288
289     if ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #blame canada
290       my($branch, $routing) = ($1, $2);
291       $lh->maketext("Routing [_1], Branch [_2], Acct [_3]",
292                      $routing, $branch, $account);
293     } else {
294       $lh->maketext("Routing [_1], Acct [_2]", $aba, $account);
295     }
296
297   } elsif ( $self->payby eq 'BILL' ) {
298     $lh->maketext('Check #') . $self->payinfo;
299   } elsif ( $self->payby eq 'PREP' ) {
300     $lh->maketext('Prepaid card #') . $self->payinfo;
301   } elsif ( $self->payby eq 'CASH' ) {
302     $lh->maketext('Cash') . ' ' . $self->payinfo;
303   } elsif ( $self->payby eq 'WEST' ) {
304     # does Western Union localize their name?
305     $lh->maketext('Western Union');
306   } elsif ( $self->payby eq 'MCRD' ) {
307     $lh->maketext('Manual credit card');
308   } elsif ( $self->payby eq 'MCHK' ) {
309     $lh->maketext('Manual electronic check');
310   } elsif ( $self->payby eq 'EDI' ) {
311     $lh->maketext('EDI') . ' ' . $self->paymask;
312   } elsif ( $self->payby eq 'PPAL' ) {
313     $lh->maketext('PayPal transaction#') . $self->order_number;
314   } else {
315     $self->payby. ' '. $self->payinfo;
316   }
317 }
318
319 =item payinfo_used [ PAYINFO ]
320
321 Returns 1 if there's an existing payment using this payinfo.  This can be 
322 used to set the 'recurring payment' flag required by some processors.
323
324 =cut
325
326 sub payinfo_used {
327   my $self = shift;
328   my $payinfo = shift || $self->payinfo;
329   my %hash = (
330     'custnum' => $self->custnum,
331     'payby'   => $self->payby,
332   );
333
334   return 1
335   if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } )
336   || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo } )
337   ;
338
339   return 0;
340 }
341
342 =item display_status
343
344 For transactions that have both 'status' and 'failure_status', shows the
345 status in a single, display-friendly string.
346
347 =cut
348
349 sub display_status {
350   my $self = shift;
351   my %status = (
352     'done'        => 'Approved',
353     'expired'     => 'Card Expired',
354     'stolen'      => 'Lost/Stolen',
355     'pickup'      => 'Pick Up Card',
356     'nsf'         => 'Insufficient Funds',
357     'inactive'    => 'Inactive Account',
358     'blacklisted' => 'Blacklisted',
359     'declined'    => 'Declined',
360     'approved'    => 'Approved',
361   );
362   if ( $self->failure_status ) {
363     return $status{$self->failure_status};
364   } else {
365     return $status{$self->status};
366   }
367 }
368
369 =item paydate_monthyear
370
371 Returns a two-element list consisting of the month and year of this customer's
372 paydate (credit card expiration date for CARD customers)
373
374 =cut
375
376 sub paydate_monthyear {
377   my $self = shift;
378   if ( $self->paydate  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
379     ( $2, $1 );
380   } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
381     ( $1, $3 );
382   } else {
383     ('', '');
384   }
385 }
386
387 =item paydate_epoch
388
389 Returns the exact time in seconds corresponding to the payment method 
390 expiration date.  For CARD/DCRD customers this is the end of the month;
391 for others (COMP is the only other payby that uses paydate) it's the start.
392 Returns 0 if the paydate is empty or set to the far future.
393
394 =cut
395
396 sub paydate_epoch {
397   my $self = shift;
398   my ($month, $year) = $self->paydate_monthyear;
399   return 0 if !$year or $year >= 2037;
400   if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) {
401     $month++;
402     if ( $month == 13 ) {
403       $month = 1;
404       $year++;
405     }
406     return timelocal(0,0,0,1,$month-1,$year) - 1;
407   }
408   else {
409     return timelocal(0,0,0,1,$month-1,$year);
410   }
411 }
412
413 =item paydate_epoch_sql
414
415 Class method.  Returns an SQL expression to obtain the payment expiration date
416 as a number of seconds.
417
418 =cut
419
420 # Special expiration date behavior for non-CARD/DCRD customers has been 
421 # carefully preserved.  Do we really use that?
422 sub paydate_epoch_sql {
423   my $class = shift;
424   my $table = $class->table;
425   my ($case1, $case2);
426   if ( driver_name eq 'Pg' ) {
427     $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1";
428     $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )";
429   }
430   elsif ( lc(driver_name) eq 'mysql' ) {
431     $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1";
432     $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )";
433   }
434   else { return '' }
435   return "CASE WHEN $table.payby IN('CARD','DCRD') 
436   THEN ($case1)
437   ELSE ($case2)
438   END"
439 }
440
441 =item upgrade_set_cardtype
442
443 Find all records with a credit card payment type and no paycardtype, and
444 replace them in order to set their paycardtype.
445
446 This method actually just starts a queue job.
447
448 =cut
449
450 sub upgrade_set_cardtype {
451   my $class = shift;
452   my $table = $class->table or die "upgrade_set_cardtype needs a table";
453
454   if ( ! FS::upgrade_journal->is_done("${table}__set_cardtype") ) {
455     my $job = FS::queue->new({ job => 'FS::payinfo_Mixin::process_set_cardtype' });
456     my $error = $job->insert($table);
457     die $error if $error;
458     FS::upgrade_journal->set_done("${table}__set_cardtype");
459   }
460 }
461
462 sub process_set_cardtype {
463   my $table = shift;
464
465   # assign cardtypes to CARD/DCRDs that need them; check_payinfo_cardtype
466   # will do this. ignore any problems with the cards.
467   local $ignore_masked_payinfo = 1;
468   my $search = FS::Cursor->new({
469     table     => $table,
470     extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ],
471   });
472   while (my $record = $search->fetch) {
473     my $error = $record->replace;
474     die $error if $error;
475   }
476 }
477
478 =item tokenized [ PAYINFO ]
479
480 Returns true if object payinfo is tokenized
481
482 Optionally, an arbitrary payby and payinfo can be passed.
483
484 =cut
485
486 sub tokenized {
487   my $self = shift;
488   my $payinfo = scalar(@_) ? shift : $self->payinfo;
489   return 0 unless $payinfo; #avoid uninitialized value error
490   $payinfo =~ /^99\d{14}$/;
491 }
492
493 =back
494
495 =head1 BUGS
496
497 =head1 SEE ALSO
498
499 L<FS::payby>, L<FS::Record>
500
501 =cut
502
503 1;
504