fix payinfo_used on batch payments with encrypted payinfo, related to #19571
[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     my $payinfo = $self->payinfo;
201     my $cardtype = cardtype($payinfo);
202     $cardtype = 'Tokenized' if $self->tokenized;
203     $self->set('paycardtype', $cardtype);
204
205     if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
206       # allow it
207     } else {
208       $payinfo =~ s/\D//g;
209       $self->payinfo($payinfo);
210       if ( $self->payinfo ) {
211         $self->payinfo =~ /^(\d{13,16}|\d{8,9})$/
212           or return "Illegal (mistyped?) credit card number (payinfo)";
213         $self->payinfo($1);
214         validate($self->payinfo) or return "Illegal credit card number";
215         return "Unknown card type" if $cardtype eq "Unknown";
216       } else {
217         $self->payinfo('N/A'); #??? re-masks card
218       }
219     }
220   } else {
221     if ( $self->payby eq 'CARD' and $self->paymask ) {
222       # if we can't decrypt the card, at least detect the cardtype
223       $self->set('paycardtype', cardtype($self->paymask));
224     } else {
225       $self->set('paycardtype', '');
226     }
227     if ( $self->is_encrypted($self->payinfo) ) {
228       #something better?  all it would cause is a decryption error anyway?
229       my $error = $self->ut_anything('payinfo');
230       return $error if $error;
231     } else {
232       my $error = $self->ut_textn('payinfo');
233       return $error if $error;
234     }
235   }
236
237   return '';
238 }
239
240 =item payby_payinfo_pretty [ LOCALE ]
241
242 Returns payment method and information (suitably masked, if applicable) as
243 a human-readable string, such as:
244
245   Card #54xxxxxxxxxxxx32
246
247 or
248
249   Check #119006
250
251 =cut
252
253 sub payby_payinfo_pretty {
254   my $self = shift;
255   my $locale = shift;
256   my $lh = FS::L10N->get_handle($locale);
257   if ( $self->payby eq 'CARD' ) {
258     if ($self->paymask =~ /tokenized/) {
259       $lh->maketext('Tokenized Card');
260     } else {
261       $lh->maketext('Card #') . $self->paymask;
262     }
263   } elsif ( $self->payby eq 'CHEK' ) {
264
265     #false laziness w/view/cust_main/payment_history.html::translate_payinfo
266     my( $account, $aba ) = split('@', $self->paymask );
267
268     if ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #blame canada
269       my($branch, $routing) = ($1, $2);
270       $lh->maketext("Routing [_1], Branch [_2], Acct [_3]",
271                      $routing, $branch, $account);
272     } else {
273       $lh->maketext("Routing [_1], Acct [_2]", $aba, $account);
274     }
275
276   } elsif ( $self->payby eq 'BILL' ) {
277     $lh->maketext('Check #') . $self->payinfo;
278   } elsif ( $self->payby eq 'PREP' ) {
279     $lh->maketext('Prepaid card #') . $self->payinfo;
280   } elsif ( $self->payby eq 'CASH' ) {
281     $lh->maketext('Cash') . ' ' . $self->payinfo;
282   } elsif ( $self->payby eq 'WEST' ) {
283     # does Western Union localize their name?
284     $lh->maketext('Western Union');
285   } elsif ( $self->payby eq 'MCRD' ) {
286     $lh->maketext('Manual credit card');
287   } elsif ( $self->payby eq 'MCHK' ) {
288     $lh->maketext('Manual electronic check');
289   } elsif ( $self->payby eq 'EDI' ) {
290     $lh->maketext('EDI') . ' ' . $self->paymask;
291   } elsif ( $self->payby eq 'PPAL' ) {
292     $lh->maketext('PayPal transaction#') . $self->order_number;
293   } else {
294     $self->payby. ' '. $self->payinfo;
295   }
296 }
297
298 =item payinfo_used [ PAYINFO ]
299
300 Returns 1 if there's an existing payment using this payinfo.  This can be 
301 used to set the 'recurring payment' flag required by some processors.
302
303 =cut
304
305 sub payinfo_used {
306   my $self = shift;
307   my $payinfo = shift || $self->payinfo;
308   my %hash = (
309     'custnum' => $self->custnum,
310     'payby'   => $self->payby,
311   );
312
313   return 1
314   if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } )
315   || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_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   return 0 unless $payinfo; #avoid uninitialized value error
469   $payinfo =~ /^99\d{14}$/;
470 }
471
472 =back
473
474 =head1 BUGS
475
476 =head1 SEE ALSO
477
478 L<FS::payby>, L<FS::Record>
479
480 =cut
481
482 1;
483