74355: freeside-upgrade error N/A (tokenized) [payinfo_check return bug fix, already...
[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 $allow_closed_replace );
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->setfield('payinfo', $payinfo);
71     $self->paymask($self->mask_payinfo) unless $payinfo =~ /^99\d{14}$/; #token
72   } else {
73     $self->getfield('payinfo');
74   }
75 }
76
77 =item paycvv
78
79 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
80
81 =cut
82
83 sub paycvv {
84   my($self,$paycvv) = @_;
85   # This is only allowed in cust_main... Even then it really shouldn't be stored...
86   if ($self->table eq 'cust_main') {
87     if ( defined($paycvv) ) {
88       $self->setfield('paycvv', $paycvv); # This is okay since we are the 'setter'
89     } else {
90       $paycvv = $self->getfield('paycvv'); # This is okay since we are the 'getter'
91       return $paycvv;
92     }
93   } else {
94 #    warn "This doesn't work for other tables besides cust_main
95     '';
96   } 
97 }
98
99 =item paymask
100
101 =cut
102
103 sub paymask {
104   my($self, $paymask) = @_;
105
106   if ( defined($paymask) ) {
107     $self->setfield('paymask', $paymask);
108   } else {
109     $self->getfield('paymask') || $self->mask_payinfo;
110   }
111 }
112
113 =back
114
115 =head1 METHODS
116
117 =over 4
118
119 =item mask_payinfo [ PAYBY, PAYINFO ]
120
121 This method converts the payment info (credit card, bank account, etc.) into a
122 masked string.
123
124 Optionally, an arbitrary payby and payinfo can be passed.
125
126 =cut
127
128 sub mask_payinfo {
129   my $self = shift;
130   my $payby   = scalar(@_) ? shift : $self->payby;
131   my $payinfo = scalar(@_) ? shift : $self->payinfo;
132
133   # Check to see if it's encrypted...
134   if ( ref($self) && $self->is_encrypted($payinfo) ) {
135     return 'N/A';
136   } elsif ( $payinfo =~ /^99\d{14}$/ || $payinfo eq 'N/A' ) { #token
137     return 'N/A (tokenized)'; #?
138   } else { # if not, mask it...
139
140     if ($payby eq 'CARD' || $payby eq 'DCRD') {
141                                                 #|| $payby eq 'MCRD') {
142                                                 #MCRD isn't a card in payinfo,
143                                                 #its a record of an _offline_
144                                                 #card
145
146       # Credit Cards
147
148       # special handling for Local Isracards: always show last 4 
149       if ( $payinfo =~ /^(\d{8,9})$/ ) {
150
151         return 'x'x(length($payinfo)-4).
152                substr($payinfo,(length($payinfo)-4));
153
154       }
155
156       my $conf = new FS::Conf;
157       my $mask_method = $conf->config('card_masking_method') || 'first6last4';
158       $mask_method =~ /^first(\d+)last(\d+)$/
159         or die "can't parse card_masking_method $mask_method";
160       my($first, $last) = ($1, $2);
161
162       return substr($payinfo,0,$first).
163              'x'x(length($payinfo)-$first-$last).
164              substr($payinfo,(length($payinfo)-$last));
165
166     } elsif ($payby eq 'CHEK' || $payby eq 'DCHK' ) {
167
168       # Checks (Show last 2 @ bank)
169       my( $account, $aba ) = split('@', $payinfo );
170       return 'x'x(length($account)-2).
171              substr($account,(length($account)-2)).
172              ( length($aba) ? "@".$aba : '');
173
174     } elsif ($payby eq 'EDI') {
175       # EDI.
176       # These numbers have been seen anywhere from 8 to 30 digits, and 
177       # possibly more.  Lacking any better idea I'm going to mask all but
178       # the last 4 digits.
179       return 'x' x (length($payinfo) - 4) . substr($payinfo, -4);
180
181     } else { # Tie up loose ends
182       return $payinfo;
183     }
184   }
185   #die "shouldn't be reached";
186 }
187
188 =item payinfo_check
189
190 Checks payby and payinfo.
191
192 =cut
193
194 sub payinfo_check {
195   my $self = shift;
196
197   FS::payby->can_payby($self->table, $self->payby)
198     or return "Illegal payby: ". $self->payby;
199
200   if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) {
201
202     if ( $self->payinfo =~ /^99\d{14}$/ && ! $self->paycardtype ) {
203       return "paycardtype required (cannot be derived from a token)";
204     } else {
205       $self->set('paycardtype', cardtype($self->payinfo));
206     }
207
208     if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
209       # allow it
210     } else {
211       my $payinfo = $self->payinfo;
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 $self->paycardtype eq "Unknown";
220       } else {
221         $self->payinfo('N/A'); #???
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'   => $self->payby,
315   );
316
317   return 1
318   if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } )
319   || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo } )
320   ;
321
322   return 0;
323 }
324
325 =item upgrade_set_cardtype
326
327 Find all records with a credit card payment type and no paycardtype, and
328 replace them in order to set their paycardtype.
329
330 =cut
331
332 sub upgrade_set_cardtype {
333   my $class = shift;
334   # assign cardtypes to CARD/DCRDs that need them; check_payinfo_cardtype
335   # will do this. ignore any problems with the cards.
336   local $ignore_masked_payinfo = 1;
337   my $search = FS::Cursor->new({
338     table     => $class->table,
339     extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ],
340   });
341   while (my $record = $search->fetch) {
342     my $error = $record->replace;
343     die $error if $error;
344   }
345 }
346
347 =back
348
349 =head1 BUGS
350
351 =head1 SEE ALSO
352
353 L<FS::payby>, L<FS::Record>
354
355 =cut
356
357 1;
358