add customer fields option with agent, display_custnum, status and name, RT#73721
[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'   => 'CARD',
311   );
312
313   return 1
314   if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } )
315   || qsearch('cust_pay', 
316     { %hash, 'paymask' => $self->mask_payinfo('CARD', $payinfo) }  )
317   ;
318
319   return 0;
320 }
321
322 =item display_status
323
324 For transactions that have both 'status' and 'failure_status', shows the
325 status in a single, display-friendly string.
326
327 =cut
328
329 sub display_status {
330   my $self = shift;
331   my %status = (
332     'done'        => 'Approved',
333     'expired'     => 'Card Expired',
334     'stolen'      => 'Lost/Stolen',
335     'pickup'      => 'Pick Up Card',
336     'nsf'         => 'Insufficient Funds',
337     'inactive'    => 'Inactive Account',
338     'blacklisted' => 'Blacklisted',
339     'declined'    => 'Declined',
340     'approved'    => 'Approved',
341   );
342   if ( $self->failure_status ) {
343     return $status{$self->failure_status};
344   } else {
345     return $status{$self->status};
346   }
347 }
348
349 =item paydate_monthyear
350
351 Returns a two-element list consisting of the month and year of this customer's
352 paydate (credit card expiration date for CARD customers)
353
354 =cut
355
356 sub paydate_monthyear {
357   my $self = shift;
358   if ( $self->paydate  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
359     ( $2, $1 );
360   } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
361     ( $1, $3 );
362   } else {
363     ('', '');
364   }
365 }
366
367 =item paydate_epoch
368
369 Returns the exact time in seconds corresponding to the payment method 
370 expiration date.  For CARD/DCRD customers this is the end of the month;
371 for others (COMP is the only other payby that uses paydate) it's the start.
372 Returns 0 if the paydate is empty or set to the far future.
373
374 =cut
375
376 sub paydate_epoch {
377   my $self = shift;
378   my ($month, $year) = $self->paydate_monthyear;
379   return 0 if !$year or $year >= 2037;
380   if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) {
381     $month++;
382     if ( $month == 13 ) {
383       $month = 1;
384       $year++;
385     }
386     return timelocal(0,0,0,1,$month-1,$year) - 1;
387   }
388   else {
389     return timelocal(0,0,0,1,$month-1,$year);
390   }
391 }
392
393 =item paydate_epoch_sql
394
395 Class method.  Returns an SQL expression to obtain the payment expiration date
396 as a number of seconds.
397
398 =cut
399
400 # Special expiration date behavior for non-CARD/DCRD customers has been 
401 # carefully preserved.  Do we really use that?
402 sub paydate_epoch_sql {
403   my $class = shift;
404   my $table = $class->table;
405   my ($case1, $case2);
406   if ( driver_name eq 'Pg' ) {
407     $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1";
408     $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )";
409   }
410   elsif ( lc(driver_name) eq 'mysql' ) {
411     $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1";
412     $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )";
413   }
414   else { return '' }
415   return "CASE WHEN $table.payby IN('CARD','DCRD') 
416   THEN ($case1)
417   ELSE ($case2)
418   END"
419 }
420
421 =item upgrade_set_cardtype
422
423 Find all records with a credit card payment type and no paycardtype, and
424 replace them in order to set their paycardtype.
425
426 This method actually just starts a queue job.
427
428 =cut
429
430 sub upgrade_set_cardtype {
431   my $class = shift;
432   my $table = $class->table or die "upgrade_set_cardtype needs a table";
433
434   if ( ! FS::upgrade_journal->is_done("${table}__set_cardtype") ) {
435     my $job = FS::queue->new({ job => 'FS::payinfo_Mixin::process_set_cardtype' });
436     my $error = $job->insert($table);
437     die $error if $error;
438     FS::upgrade_journal->set_done("${table}__set_cardtype");
439   }
440 }
441
442 sub process_set_cardtype {
443   my $table = shift;
444
445   # assign cardtypes to CARD/DCRDs that need them; check_payinfo_cardtype
446   # will do this. ignore any problems with the cards.
447   local $ignore_masked_payinfo = 1;
448   my $search = FS::Cursor->new({
449     table     => $table,
450     extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ],
451   });
452   while (my $record = $search->fetch) {
453     my $error = $record->replace;
454     die $error if $error;
455   }
456 }
457
458 =item tokenized [ PAYINFO ]
459
460 Returns true if object payinfo is tokenized
461
462 Optionally, an arbitrary payby and payinfo can be passed.
463
464 =cut
465
466 sub tokenized {
467   my $self = shift;
468   my $payinfo = scalar(@_) ? shift : $self->payinfo;
469   return 0 unless $payinfo; #avoid uninitialized value error
470   $payinfo =~ /^99\d{14}$/;
471 }
472
473 =back
474
475 =head1 BUGS
476
477 =head1 SEE ALSO
478
479 L<FS::payby>, L<FS::Record>
480
481 =cut
482
483 1;
484