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