use Business::CreditCard in cust_payby, #23741
[freeside.git] / FS / FS / cust_payby.pm
1 package FS::cust_payby;
2
3 use strict;
4 use base qw( FS::payinfo_Mixin FS::Record );
5 use FS::UID;
6 use FS::Record qw( qsearchs ); #qsearch;
7 use FS::payby;
8 use FS::cust_main;
9 use Business::CreditCard qw( validate cardtype );
10
11 use vars qw( $conf @encrypted_fields
12              $ignore_expired_card $ignore_banned_card
13            );
14
15 @encrypted_fields = ('payinfo', 'paycvv');
16 sub nohistory_fields { ('payinfo', 'paycvv'); }
17
18 $ignore_expired_card = 0;
19 $ignore_banned_card = 0;
20
21 install_callback FS::UID sub { 
22   $conf = new FS::Conf;
23   #yes, need it for stuff below (prolly should be cached)
24 };
25
26 =head1 NAME
27
28 FS::cust_payby - Object methods for cust_payby records
29
30 =head1 SYNOPSIS
31
32   use FS::cust_payby;
33
34   $record = new FS::cust_payby \%hash;
35   $record = new FS::cust_payby { 'column' => 'value' };
36
37   $error = $record->insert;
38
39   $error = $new_record->replace($old_record);
40
41   $error = $record->delete;
42
43   $error = $record->check;
44
45 =head1 DESCRIPTION
46
47 An FS::cust_payby object represents customer stored payment information.
48 FS::cust_payby inherits from FS::Record.  The following fields are currently
49 supported:
50
51 =over 4
52
53 =item custpaybynum
54
55 primary key
56
57 =item custnum
58
59 custnum
60
61 =item weight
62
63 weight
64
65 =item payby
66
67 payby
68
69 =item payinfo
70
71 payinfo
72
73 =item paycvv
74
75 paycvv
76
77 =item paymask
78
79 paymask
80
81 =item paydate
82
83 paydate
84
85 =item paystart_month
86
87 paystart_month
88
89 =item paystart_year
90
91 paystart_year
92
93 =item payissue
94
95 payissue
96
97 =item payname
98
99 payname
100
101 =item paystate
102
103 paystate
104
105 =item paytype
106
107 paytype
108
109 =item payip
110
111 payip
112
113
114 =back
115
116 =head1 METHODS
117
118 =over 4
119
120 =item new HASHREF
121
122 Creates a new record.  To add the record to the database, see L<"insert">.
123
124 Note that this stores the hash reference, not a distinct copy of the hash it
125 points to.  You can ask the object for a copy with the I<hash> method.
126
127 =cut
128
129 # the new method can be inherited from FS::Record, if a table method is defined
130
131 sub table { 'cust_payby'; }
132
133 =item insert
134
135 Adds this record to the database.  If there is an error, returns the error,
136 otherwise returns false.
137
138 =cut
139
140 # the insert method can be inherited from FS::Record
141
142 =item delete
143
144 Delete this record from the database.
145
146 =cut
147
148 # the delete method can be inherited from FS::Record
149
150 =item replace OLD_RECORD
151
152 Replaces the OLD_RECORD with this one in the database.  If there is an error,
153 returns the error, otherwise returns false.
154
155 =cut
156
157 # the replace method can be inherited from FS::Record
158
159 =item check
160
161 Checks all fields to make sure this is a valid record.  If there is
162 an error, returns the error, otherwise returns false.  Called by the insert
163 and replace methods.
164
165 =cut
166
167 sub check {
168   my $self = shift;
169
170   my $error = 
171     $self->ut_numbern('custpaybynum')
172     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
173     || $self->ut_number('weight')
174     #encrypted #|| $self->ut_textn('payinfo')
175     #encrypted #|| $self->ut_textn('paycvv')
176 #    || $self->ut_textn('paymask') #XXX something
177     #later #|| $self->ut_textn('paydate')
178     || $self->ut_numbern('paystart_month')
179     || $self->ut_numbern('paystart_year')
180     || $self->ut_numbern('payissue')
181 #    || $self->ut_textn('payname') #XXX something
182     || $self->ut_alphan('paystate')
183     || $self->ut_textn('paytype')
184     || $self->ut_ipn('payip')
185   ;
186   return $error if $error;
187
188   ### from cust_main
189
190   FS::payby->can_payby($self->table, $self->payby)
191     or return "Illegal payby: ". $self->payby;
192
193   # If it is encrypted and the private key is not availaible then we can't
194   # check the credit card.
195   my $check_payinfo = ! $self->is_encrypted($self->payinfo);
196
197   # Need some kind of global flag to accept invalid cards, for testing
198   # on scrubbed data.
199   #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
200   if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
201
202     my $payinfo = $self->payinfo;
203     $payinfo =~ s/\D//g;
204     $payinfo =~ /^(\d{13,16}|\d{8,9})$/
205       or return gettext('invalid_card'); # . ": ". $self->payinfo;
206     $payinfo = $1;
207     $self->payinfo($payinfo);
208     validate($payinfo)
209       or return gettext('invalid_card'); # . ": ". $self->payinfo;
210
211     return gettext('unknown_card_type')
212       if $self->payinfo !~ /^99\d{14}$/ #token
213       && cardtype($self->payinfo) eq "Unknown";
214
215     unless ( $ignore_banned_card ) {
216       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
217       if ( $ban ) {
218         if ( $ban->bantype eq 'warn' ) {
219           #or others depending on value of $ban->reason ?
220           return '_duplicate_card'.
221                  ': disabled from'. time2str('%a %h %o at %r', $ban->_date).
222                  ' until '.         time2str('%a %h %o at %r', $ban->_end_date).
223                  ' (ban# '. $ban->bannum. ')'
224             unless $self->override_ban_warn;
225         } else {
226           return 'Banned credit card: banned on '.
227                  time2str('%a %h %o at %r', $ban->_date).
228                  ' by '. $ban->otaker.
229                  ' (ban# '. $ban->bannum. ')';
230         }
231       }
232     }
233
234     if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
235       if ( cardtype($self->payinfo) eq 'American Express card' ) {
236         $self->paycvv =~ /^(\d{4})$/
237           or return "CVV2 (CID) for American Express cards is four digits.";
238         $self->paycvv($1);
239       } else {
240         $self->paycvv =~ /^(\d{3})$/
241           or return "CVV2 (CVC2/CID) is three digits.";
242         $self->paycvv($1);
243       }
244     } else {
245       $self->paycvv('');
246     }
247
248     my $cardtype = cardtype($payinfo);
249     if ( $cardtype =~ /^(Switch|Solo)$/i ) {
250
251       return "Start date or issue number is required for $cardtype cards"
252         unless $self->paystart_month && $self->paystart_year or $self->payissue;
253
254       return "Start month must be between 1 and 12"
255         if $self->paystart_month
256            and $self->paystart_month < 1 || $self->paystart_month > 12;
257
258       return "Start year must be 1990 or later"
259         if $self->paystart_year
260            and $self->paystart_year < 1990;
261
262       return "Issue number must be beween 1 and 99"
263         if $self->payissue
264           and $self->payissue < 1 || $self->payissue > 99;
265
266     } else {
267       $self->paystart_month('');
268       $self->paystart_year('');
269       $self->payissue('');
270     }
271
272   } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
273
274     my $payinfo = $self->payinfo;
275     $payinfo =~ s/[^\d\@\.]//g;
276     if ( $conf->config('echeck-country') eq 'CA' ) {
277       $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/
278         or return 'invalid echeck account@branch.bank';
279       $payinfo = "$1\@$2.$3";
280     } elsif ( $conf->config('echeck-country') eq 'US' ) {
281       $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
282       $payinfo = "$1\@$2";
283     } else {
284       $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
285       $payinfo = "$1\@$2";
286     }
287     $self->payinfo($payinfo);
288     $self->paycvv('');
289
290     unless ( $ignore_banned_card ) {
291       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
292       if ( $ban ) {
293         if ( $ban->bantype eq 'warn' ) {
294           #or others depending on value of $ban->reason ?
295           return '_duplicate_ach' unless $self->override_ban_warn;
296         } else {
297           return 'Banned ACH account: banned on '.
298                  time2str('%a %h %o at %r', $ban->_date).
299                  ' by '. $ban->otaker.
300                  ' (ban# '. $ban->bannum. ')';
301         }
302       }
303     }
304
305   } elsif ( $self->payby eq 'LECB' ) {
306
307     my $payinfo = $self->payinfo;
308     $payinfo =~ s/\D//g;
309     $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
310     $payinfo = $1;
311     $self->payinfo($payinfo);
312     $self->paycvv('');
313
314   } elsif ( $self->payby eq 'BILL' ) {
315
316     $error = $self->ut_textn('payinfo');
317     return "Illegal P.O. number: ". $self->payinfo if $error;
318     $self->paycvv('');
319
320   } elsif ( $self->payby eq 'COMP' ) {
321
322     my $curuser = $FS::CurrentUser::CurrentUser;
323     if (    ! $self->custnum
324          && ! $curuser->access_right('Complimentary customer')
325        )
326     {
327       return "You are not permitted to create complimentary accounts."
328     }
329
330     $error = $self->ut_textn('payinfo');
331     return "Illegal comp account issuer: ". $self->payinfo if $error;
332     $self->paycvv('');
333
334   } elsif ( $self->payby eq 'PREPAY' ) {
335
336     my $payinfo = $self->payinfo;
337     $payinfo =~ s/\W//g; #anything else would just confuse things
338     $self->payinfo($payinfo);
339     $error = $self->ut_alpha('payinfo');
340     return "Illegal prepayment identifier: ". $self->payinfo if $error;
341     return "Unknown prepayment identifier"
342       unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
343     $self->paycvv('');
344
345   }
346
347   if ( $self->paydate eq '' || $self->paydate eq '-' ) {
348     return "Expiration date required"
349       # shouldn't payinfo_check do this?
350       unless $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD|PPAL)$/;
351     $self->paydate('');
352   } else {
353     my( $m, $y );
354     if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
355       ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
356     } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
357       ( $m, $y ) = ( $2, "19$1" );
358     } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
359       ( $m, $y ) = ( $3, "20$2" );
360     } else {
361       return "Illegal expiration date: ". $self->paydate;
362     }
363     $m = sprintf('%02d',$m);
364     $self->paydate("$y-$m-01");
365     my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
366     return gettext('expired_card')
367       if #XXX !$import
368       #&&
369          !$ignore_expired_card 
370       && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
371   }
372
373   if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
374        ( ! $conf->exists('require_cardname')
375          || $self->payby !~ /^(CARD|DCRD)$/  ) 
376   ) {
377     $self->payname( $self->first. " ". $self->getfield('last') );
378   } else {
379     $self->payname =~ /^([\w \,\.\-\'\&]+)$/
380       or return gettext('illegal_name'). " payname: ". $self->payname;
381     $self->payname($1);
382   }
383
384   ###
385
386   $self->SUPER::check;
387 }
388
389 =back
390
391 =head1 BUGS
392
393 =head1 SEE ALSO
394
395 L<FS::Record>, schema.html from the base documentation.
396
397 =cut
398
399 1;
400