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