add skip_dcontext_suffix to skip CDRs with dcontext ending in a definable string...
[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 Time::Local qw(timelocal);
9
10 use vars qw($ignore_masked_payinfo);
11
12 =head1 NAME
13
14 FS::payinfo_Mixin - Mixin class for records in tables that contain payinfo.  
15
16 =head1 SYNOPSIS
17
18 package FS::some_table;
19 use vars qw(@ISA);
20 @ISA = qw( FS::payinfo_Mixin FS::Record );
21
22 =head1 DESCRIPTION
23
24 This is a mixin class for records that contain payinfo. 
25
26 =head1 FIELDS
27
28 =over 4
29
30 =item payby
31
32 The following payment types (payby) are supported:
33
34 For Customers (cust_main):
35 'CARD' (credit card - automatic), 'DCRD' (credit card - on-demand),
36 'CHEK' (electronic check - automatic), 'DCHK' (electronic check - on-demand),
37 'LECB' (Phone bill billing), 'BILL' (billing), 'COMP' (free), or
38 'PREPAY' (special billing type: applies a credit and sets billing type to I<BILL> - see L<FS::prepay_credit>)
39
40 For Refunds (cust_refund):
41 'CARD' (credit cards), 'CHEK' (electronic check/ACH),
42 'LECB' (Phone bill billing), 'BILL' (billing), 'CASH' (cash),
43 'WEST' (Western Union), 'MCRD' (Manual credit card), 'MCHK' (Manual electronic
44 check), 'CBAK' Chargeback, or 'COMP' (free)
45
46
47 For Payments (cust_pay):
48 'CARD' (credit cards), 'CHEK' (electronic check/ACH),
49 'LECB' (phone bill billing), 'BILL' (billing), 'PREP' (prepaid card),
50 'CASH' (cash), 'WEST' (Western Union), 'MCRD' (Manual credit card), 'MCHK'
51 (Manual electronic check), 'PPAL' (PayPal)
52 'COMP' (free) is depricated as a payment type in cust_pay
53
54 =cut 
55
56 =item payinfo
57
58 Payment information (payinfo) can be one of the following types:
59
60 Card Number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) 
61 prepayment identifier (see L<FS::prepay_credit>), PayPal transaction ID
62
63 =cut
64
65 sub payinfo {
66   my($self,$payinfo) = @_;
67
68   if ( defined($payinfo) ) {
69     $self->setfield('payinfo', $payinfo);
70     $self->paymask($self->mask_payinfo) unless $payinfo =~ /^99\d{14}$/; #token
71   } else {
72     $self->getfield('payinfo');
73   }
74 }
75
76 =item paycvv
77
78 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
79
80 =cut
81
82 #this prevents encrypting empty values on insert?
83 sub paycvv {
84   my($self,$paycvv) = @_;
85   # This is only allowed in cust_payby (formerly cust_main)
86   #  It shouldn't be stored longer than necessary to run the first transaction
87   if ( defined($paycvv) ) {
88     $self->setfield('paycvv', $paycvv);
89   } else {
90     $self->getfield('paycvv');
91   }
92 }
93
94 =item paymask
95
96 =cut
97
98 sub paymask {
99   my($self, $paymask) = @_;
100
101   if ( defined($paymask) ) {
102     $self->setfield('paymask', $paymask);
103   } else {
104     $self->getfield('paymask') || $self->mask_payinfo;
105   }
106 }
107
108 =back
109
110 =head1 METHODS
111
112 =over 4
113
114 =item mask_payinfo [ PAYBY, PAYINFO ]
115
116 This method converts the payment info (credit card, bank account, etc.) into a
117 masked string.
118
119 Optionally, an arbitrary payby and payinfo can be passed.
120
121 =cut
122
123 sub mask_payinfo {
124   my $self = shift;
125   my $payby   = scalar(@_) ? shift : $self->payby;
126   my $payinfo = scalar(@_) ? shift : $self->payinfo;
127
128   # Check to see if it's encrypted...
129   if ( ref($self) && $self->is_encrypted($payinfo) ) {
130     return 'N/A';
131   } elsif ( $payinfo =~ /^99\d{14}$/ || $payinfo eq 'N/A' ) { #token
132     return 'N/A (tokenized)'; #?
133   } else { # if not, mask it...
134
135     if ($payby eq 'CARD' || $payby eq 'DCRD') {
136                                                 #|| $payby eq 'MCRD') {
137                                                 #MCRD isn't a card in payinfo,
138                                                 #its a record of an _offline_
139                                                 #card
140
141       # Credit Cards
142
143       # special handling for Local Isracards: always show last 4 
144       if ( $payinfo =~ /^(\d{8,9})$/ ) {
145
146         return 'x'x(length($payinfo)-4).
147                substr($payinfo,(length($payinfo)-4));
148
149       }
150
151       my $conf = new FS::Conf;
152       my $mask_method = $conf->config('card_masking_method') || 'first6last4';
153       $mask_method =~ /^first(\d+)last(\d+)$/
154         or die "can't parse card_masking_method $mask_method";
155       my($first, $last) = ($1, $2);
156
157       return substr($payinfo,0,$first).
158              'x'x(length($payinfo)-$first-$last).
159              substr($payinfo,(length($payinfo)-$last));
160
161     } elsif ($payby eq 'CHEK' || $payby eq 'DCHK' ) {
162
163       # Checks (Show last 2 @ bank)
164       my( $account, $aba ) = split('@', $payinfo );
165       return 'x'x(length($account)-2).
166              substr($account,(length($account)-2)).
167              ( length($aba) ? "@".$aba : '');
168
169     } elsif ($payby eq 'EDI') {
170       # EDI.
171       # These numbers have been seen anywhere from 8 to 30 digits, and 
172       # possibly more.  Lacking any better idea I'm going to mask all but
173       # the last 4 digits.
174       return 'x' x (length($payinfo) - 4) . substr($payinfo, -4);
175
176     } else { # Tie up loose ends
177       return $payinfo;
178     }
179   }
180   #die "shouldn't be reached";
181 }
182
183 =item payinfo_check
184
185 Checks payby and payinfo.
186
187 =cut
188
189 sub payinfo_check {
190   my $self = shift;
191
192   FS::payby->can_payby($self->table, $self->payby)
193     or return "Illegal payby: ". $self->payby;
194
195   if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) {
196     my $payinfo = $self->payinfo;
197     if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
198       # allow it
199     } else {
200       $payinfo =~ s/\D//g;
201       $self->payinfo($payinfo);
202       if ( $self->payinfo ) {
203         $self->payinfo =~ /^(\d{13,16}|\d{8,9})$/
204           or return "Illegal (mistyped?) credit card number (payinfo)";
205         $self->payinfo($1);
206         validate($self->payinfo) or return "Illegal credit card number";
207         return "Unknown card type" if $self->payinfo !~ /^99\d{14}$/ #token
208                                    && cardtype($self->payinfo) eq "Unknown";
209       } else {
210         $self->payinfo('N/A'); #???
211       }
212     }
213   } else {
214     if ( $self->is_encrypted($self->payinfo) ) {
215       #something better?  all it would cause is a decryption error anyway?
216       my $error = $self->ut_anything('payinfo');
217       return $error if $error;
218     } else {
219       my $error = $self->ut_textn('payinfo');
220       return $error if $error;
221     }
222   }
223
224 }
225
226 =item payby_payinfo_pretty [ LOCALE ]
227
228 Returns payment method and information (suitably masked, if applicable) as
229 a human-readable string, such as:
230
231   Card #54xxxxxxxxxxxx32
232
233 or
234
235   Check #119006
236
237 =cut
238
239 sub payby_payinfo_pretty {
240   my $self = shift;
241   my $locale = shift;
242   my $lh = FS::L10N->get_handle($locale);
243   if ( $self->payby eq 'CARD' ) {
244     if ($self->paymask =~ /tokenized/) {
245       $lh->maketext('Tokenized Card');
246     } else {
247       $lh->maketext('Card #') . $self->paymask;
248     }
249   } elsif ( $self->payby eq 'CHEK' ) {
250
251     #false laziness w/view/cust_main/payment_history.html::translate_payinfo
252     my( $account, $aba ) = split('@', $self->paymask );
253
254     if ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #blame canada
255       my($branch, $routing) = ($1, $2);
256       $lh->maketext("Routing [_1], Branch [_2], Acct [_3]",
257                      $routing, $branch, $account);
258     } else {
259       $lh->maketext("Routing [_1], Acct [_2]", $aba, $account);
260     }
261
262   } elsif ( $self->payby eq 'BILL' ) {
263     $lh->maketext('Check #') . $self->payinfo;
264   } elsif ( $self->payby eq 'PREP' ) {
265     $lh->maketext('Prepaid card #') . $self->payinfo;
266   } elsif ( $self->payby eq 'CASH' ) {
267     $lh->maketext('Cash') . ' ' . $self->payinfo;
268   } elsif ( $self->payby eq 'WEST' ) {
269     # does Western Union localize their name?
270     $lh->maketext('Western Union');
271   } elsif ( $self->payby eq 'MCRD' ) {
272     $lh->maketext('Manual credit card');
273   } elsif ( $self->payby eq 'MCHK' ) {
274     $lh->maketext('Manual electronic check');
275   } elsif ( $self->payby eq 'EDI' ) {
276     $lh->maketext('EDI') . ' ' . $self->paymask;
277   } elsif ( $self->payby eq 'PPAL' ) {
278     $lh->maketext('PayPal transaction#') . $self->order_number;
279   } else {
280     $self->payby. ' '. $self->payinfo;
281   }
282 }
283
284 =item payinfo_used [ PAYINFO ]
285
286 Returns 1 if there's an existing payment using this payinfo.  This can be 
287 used to set the 'recurring payment' flag required by some processors.
288
289 =cut
290
291 sub payinfo_used {
292   my $self = shift;
293   my $payinfo = shift || $self->payinfo;
294   my %hash = (
295     'custnum' => $self->custnum,
296     'payby'   => 'CARD',
297   );
298
299   return 1
300   if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } )
301   || qsearch('cust_pay', 
302     { %hash, 'paymask' => $self->mask_payinfo('CARD', $payinfo) }  )
303   ;
304
305   return 0;
306 }
307
308 =item display_status
309
310 For transactions that have both 'status' and 'failure_status', shows the
311 status in a single, display-friendly string.
312
313 =cut
314
315 sub display_status {
316   my $self = shift;
317   my %status = (
318     'done'        => 'Approved',
319     'expired'     => 'Card Expired',
320     'stolen'      => 'Lost/Stolen',
321     'pickup'      => 'Pick Up Card',
322     'nsf'         => 'Insufficient Funds',
323     'inactive'    => 'Inactive Account',
324     'blacklisted' => 'Blacklisted',
325     'declined'    => 'Declined',
326     'approved'    => 'Approved',
327   );
328   if ( $self->failure_status ) {
329     return $status{$self->failure_status};
330   } else {
331     return $status{$self->status};
332   }
333 }
334
335 =item paydate_monthyear
336
337 Returns a two-element list consisting of the month and year of this customer's
338 paydate (credit card expiration date for CARD customers)
339
340 =cut
341
342 sub paydate_monthyear {
343   my $self = shift;
344   if ( $self->paydate  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
345     ( $2, $1 );
346   } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
347     ( $1, $3 );
348   } else {
349     ('', '');
350   }
351 }
352
353 =item paydate_epoch
354
355 Returns the exact time in seconds corresponding to the payment method 
356 expiration date.  For CARD/DCRD customers this is the end of the month;
357 for others (COMP is the only other payby that uses paydate) it's the start.
358 Returns 0 if the paydate is empty or set to the far future.
359
360 =cut
361
362 sub paydate_epoch {
363   my $self = shift;
364   my ($month, $year) = $self->paydate_monthyear;
365   return 0 if !$year or $year >= 2037;
366   if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) {
367     $month++;
368     if ( $month == 13 ) {
369       $month = 1;
370       $year++;
371     }
372     return timelocal(0,0,0,1,$month-1,$year) - 1;
373   }
374   else {
375     return timelocal(0,0,0,1,$month-1,$year);
376   }
377 }
378
379 =item paydate_epoch_sql
380
381 Class method.  Returns an SQL expression to obtain the payment expiration date
382 as a number of seconds.
383
384 =cut
385
386 # Special expiration date behavior for non-CARD/DCRD customers has been 
387 # carefully preserved.  Do we really use that?
388 sub paydate_epoch_sql {
389   my $class = shift;
390   my $table = $class->table;
391   my ($case1, $case2);
392   if ( driver_name eq 'Pg' ) {
393     $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1";
394     $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )";
395   }
396   elsif ( lc(driver_name) eq 'mysql' ) {
397     $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1";
398     $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )";
399   }
400   else { return '' }
401   return "CASE WHEN $table.payby IN('CARD','DCRD') 
402   THEN ($case1)
403   ELSE ($case2)
404   END"
405 }
406
407 =back
408
409 =head1 BUGS
410
411 =head1 SEE ALSO
412
413 L<FS::payby>, L<FS::Record>
414
415 =cut
416
417 1;
418