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