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