71513: Card tokenization [v3 backport]
[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 sub paycvv {
86   my($self,$paycvv) = @_;
87   # This is only allowed in cust_main... Even then it really shouldn't be stored...
88   if ($self->table eq 'cust_main') {
89     if ( defined($paycvv) ) {
90       $self->setfield('paycvv', $paycvv); # This is okay since we are the 'setter'
91     } else {
92       $paycvv = $self->getfield('paycvv'); # This is okay since we are the 'getter'
93       return $paycvv;
94     }
95   } else {
96 #    warn "This doesn't work for other tables besides cust_main
97     '';
98   } 
99 }
100
101 =item paymask
102
103 =cut
104
105 sub paymask {
106   my($self, $paymask) = @_;
107
108   if ( defined($paymask) ) {
109     $self->setfield('paymask', $paymask);
110   } else {
111     $self->getfield('paymask') || $self->mask_payinfo;
112   }
113 }
114
115 =back
116
117 =head1 METHODS
118
119 =over 4
120
121 =item mask_payinfo [ PAYBY, PAYINFO ]
122
123 This method converts the payment info (credit card, bank account, etc.) into a
124 masked string.
125
126 Optionally, an arbitrary payby and payinfo can be passed.
127
128 =cut
129
130 sub mask_payinfo {
131   my $self = shift;
132   my $payby   = scalar(@_) ? shift : $self->payby;
133   my $payinfo = scalar(@_) ? shift : $self->payinfo;
134
135   # Check to see if it's encrypted...
136   if ( ref($self) && $self->is_encrypted($payinfo) ) {
137     return 'N/A';
138   } elsif ( $self->tokenized($payinfo) || $payinfo eq 'N/A' ) { #token
139     return 'N/A (tokenized)'; #?
140   } else { # if not, mask it...
141
142     if ($payby eq 'CARD' || $payby eq 'DCRD') {
143                                                 #|| $payby eq 'MCRD') {
144                                                 #MCRD isn't a card in payinfo,
145                                                 #its a record of an _offline_
146                                                 #card
147
148       # Credit Cards
149
150       # special handling for Local Isracards: always show last 4 
151       if ( $payinfo =~ /^(\d{8,9})$/ ) {
152
153         return 'x'x(length($payinfo)-4).
154                substr($payinfo,(length($payinfo)-4));
155
156       }
157
158       my $conf = new FS::Conf;
159       my $mask_method = $conf->config('card_masking_method') || 'first6last4';
160       $mask_method =~ /^first(\d+)last(\d+)$/
161         or die "can't parse card_masking_method $mask_method";
162       my($first, $last) = ($1, $2);
163
164       return substr($payinfo,0,$first).
165              'x'x(length($payinfo)-$first-$last).
166              substr($payinfo,(length($payinfo)-$last));
167
168     } elsif ($payby eq 'CHEK' || $payby eq 'DCHK' ) {
169
170       # Checks (Show last 2 @ bank)
171       my( $account, $aba ) = split('@', $payinfo );
172       return 'x'x(length($account)-2).
173              substr($account,(length($account)-2)).
174              ( length($aba) ? "@".$aba : '');
175
176     } elsif ($payby eq 'EDI') {
177       # EDI.
178       # These numbers have been seen anywhere from 8 to 30 digits, and 
179       # possibly more.  Lacking any better idea I'm going to mask all but
180       # the last 4 digits.
181       return 'x' x (length($payinfo) - 4) . substr($payinfo, -4);
182
183     } else { # Tie up loose ends
184       return $payinfo;
185     }
186   }
187   #die "shouldn't be reached";
188 }
189
190 =item payinfo_check
191
192 Checks payby and payinfo.
193
194 =cut
195
196 sub payinfo_check {
197   my $self = shift;
198
199   FS::payby->can_payby($self->table, $self->payby)
200     or return "Illegal payby: ". $self->payby;
201
202   if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) {
203
204     my $payinfo = $self->payinfo;
205     my $cardtype = cardtype($payinfo);
206     $cardtype = 'Tokenized' if $self->tokenized;
207     $self->set('paycardtype', $cardtype);
208
209     if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
210       # allow it
211     } else {
212       $payinfo =~ s/\D//g;
213       $self->payinfo($payinfo);
214       if ( $self->payinfo ) {
215         $self->payinfo =~ /^(\d{13,16}|\d{8,9})$/
216           or return "Illegal (mistyped?) credit card number (payinfo)";
217         $self->payinfo($1);
218         validate($self->payinfo) or return "Illegal credit card number";
219         return "Unknown card type" if $cardtype eq "Unknown";
220       } else {
221         $self->payinfo('N/A'); #??? re-masks card
222       }
223     }
224   } else {
225     if ( $self->payby eq 'CARD' and $self->paymask ) {
226       # if we can't decrypt the card, at least detect the cardtype
227       $self->set('paycardtype', cardtype($self->paymask));
228     } else {
229       $self->set('paycardtype', '');
230     }
231     if ( $self->is_encrypted($self->payinfo) ) {
232       #something better?  all it would cause is a decryption error anyway?
233       my $error = $self->ut_anything('payinfo');
234       return $error if $error;
235     } else {
236       my $error = $self->ut_textn('payinfo');
237       return $error if $error;
238     }
239   }
240
241   return '';
242 }
243
244 =item payby_payinfo_pretty [ LOCALE ]
245
246 Returns payment method and information (suitably masked, if applicable) as
247 a human-readable string, such as:
248
249   Card #54xxxxxxxxxxxx32
250
251 or
252
253   Check #119006
254
255 =cut
256
257 sub payby_payinfo_pretty {
258   my $self = shift;
259   my $locale = shift;
260   my $lh = FS::L10N->get_handle($locale);
261   if ( $self->payby eq 'CARD' ) {
262     if ($self->paymask =~ /tokenized/) {
263       $lh->maketext('Tokenized Card');
264     } else {
265       $lh->maketext('Card #') . $self->paymask;
266     }
267   } elsif ( $self->payby eq 'CHEK' ) {
268
269     #false laziness w/view/cust_main/payment_history.html::translate_payinfo
270     my( $account, $aba ) = split('@', $self->paymask );
271
272     if ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #blame canada
273       my($branch, $routing) = ($1, $2);
274       $lh->maketext("Routing [_1], Branch [_2], Acct [_3]",
275                      $routing, $branch, $account);
276     } else {
277       $lh->maketext("Routing [_1], Acct [_2]", $aba, $account);
278     }
279
280   } elsif ( $self->payby eq 'BILL' ) {
281     $lh->maketext('Check #') . $self->payinfo;
282   } elsif ( $self->payby eq 'PREP' ) {
283     $lh->maketext('Prepaid card #') . $self->payinfo;
284   } elsif ( $self->payby eq 'CASH' ) {
285     $lh->maketext('Cash') . ' ' . $self->payinfo;
286   } elsif ( $self->payby eq 'WEST' ) {
287     # does Western Union localize their name?
288     $lh->maketext('Western Union');
289   } elsif ( $self->payby eq 'MCRD' ) {
290     $lh->maketext('Manual credit card');
291   } elsif ( $self->payby eq 'MCHK' ) {
292     $lh->maketext('Manual electronic check');
293   } elsif ( $self->payby eq 'EDI' ) {
294     $lh->maketext('EDI') . ' ' . $self->paymask;
295   } elsif ( $self->payby eq 'PPAL' ) {
296     $lh->maketext('PayPal transaction#') . $self->order_number;
297   } else {
298     $self->payby. ' '. $self->payinfo;
299   }
300 }
301
302 =item payinfo_used [ PAYINFO ]
303
304 Returns 1 if there's an existing payment using this payinfo.  This can be 
305 used to set the 'recurring payment' flag required by some processors.
306
307 =cut
308
309 sub payinfo_used {
310   my $self = shift;
311   my $payinfo = shift || $self->payinfo;
312   my %hash = (
313     'custnum' => $self->custnum,
314     'payby'   => 'CARD',
315   );
316
317   return 1
318   if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } )
319   || qsearch('cust_pay', 
320     { %hash, 'paymask' => $self->mask_payinfo('CARD', $payinfo) }  )
321   ;
322
323   return 0;
324 }
325
326 =item upgrade_set_cardtype
327
328 Find all records with a credit card payment type and no paycardtype, and
329 replace them in order to set their paycardtype.
330
331 =cut
332
333 sub upgrade_set_cardtype {
334   my $class = shift;
335   # assign cardtypes to CARD/DCRDs that need them; check_payinfo_cardtype
336   # will do this. ignore any problems with the cards.
337   local $ignore_masked_payinfo = 1;
338   my $search = FS::Cursor->new({
339     table     => $class->table,
340     extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ],
341   });
342   while (my $record = $search->fetch) {
343     my $error = $record->replace;
344     die $error if $error;
345   }
346 }
347
348 =item tokenized [ PAYINFO ]
349
350 Returns true if object payinfo is tokenized
351
352 Optionally, an arbitrary payby and payinfo can be passed.
353
354 =cut
355
356 sub tokenized {
357   my $self = shift;
358   my $payinfo = scalar(@_) ? shift : $self->payinfo;
359   return 0 unless $payinfo; #avoid uninitialized value error
360   $payinfo =~ /^99\d{14}$/;
361 }
362
363 =back
364
365 =head1 BUGS
366
367 =head1 SEE ALSO
368
369 L<FS::payby>, L<FS::Record>
370
371 =cut
372
373 1;
374