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