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