1 package FS::cust_payby;
2 use base qw( FS::payinfo_Mixin FS::cust_main_Mixin FS::Record );
6 use Scalar::Util qw( blessed );
7 use Digest::SHA qw( sha512_base64 );
8 use Business::CreditCard qw( validate cardtype );
10 use FS::Msgcat qw( gettext );
11 use FS::Misc qw( card_types );
12 use FS::Record; #qw( qsearch qsearchs );
17 our @encrypted_fields = ('payinfo', 'paycvv');
18 sub nohistory_fields { ('payinfo', 'paycvv'); }
20 our $ignore_expired_card = 0;
21 our $ignore_banned_card = 0;
22 our $ignore_invalid_card = 0;
23 our $ignore_cardtype = 0;
26 install_callback FS::UID sub {
28 #yes, need it for stuff below (prolly should be cached)
29 $ignore_invalid_card = $conf->exists('allow_invalid_cards');
34 FS::cust_payby - Object methods for cust_payby records
40 $record = new FS::cust_payby \%hash;
41 $record = new FS::cust_payby { 'column' => 'value' };
43 $error = $record->insert;
45 $error = $new_record->replace($old_record);
47 $error = $record->delete;
49 $error = $record->check;
53 An FS::cust_payby object represents customer stored payment information.
54 FS::cust_payby inherits from FS::Record. The following fields are currently
121 The credit card type (deduced from the card number).
131 Creates a new record. To add the record to the database, see L<"insert">.
133 Note that this stores the hash reference, not a distinct copy of the hash it
134 points to. You can ask the object for a copy with the I<hash> method.
138 # the new method can be inherited from FS::Record, if a table method is defined
140 sub table { 'cust_payby'; }
144 Adds this record to the database. If there is an error, returns the error,
145 otherwise returns false.
152 local $SIG{HUP} = 'IGNORE';
153 local $SIG{INT} = 'IGNORE';
154 local $SIG{QUIT} = 'IGNORE';
155 local $SIG{TERM} = 'IGNORE';
156 local $SIG{TSTP} = 'IGNORE';
157 local $SIG{PIPE} = 'IGNORE';
159 my $oldAutoCommit = $FS::UID::AutoCommit;
160 local $FS::UID::AutoCommit = 0;
163 my $error = $self->check_payinfo_cardtype
164 || $self->SUPER::insert;
166 $dbh->rollback if $oldAutoCommit;
170 if ( $self->payby =~ /^(CARD|CHEK)$/ ) {
171 # new auto card/check info, want to retry realtime_ invoice events
172 # (new customer? that's okay, they won't have any)
173 my $error = $self->cust_main->retry_realtime;
175 $dbh->rollback if $oldAutoCommit;
180 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
187 Delete this record from the database.
189 =item replace OLD_RECORD
191 Replaces the OLD_RECORD with this one in the database. If there is an error,
192 returns the error, otherwise returns false.
199 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
201 : $self->replace_old;
203 if ( $self->payby =~ /^(CARD|DCRD)$/
204 && ( $self->payinfo =~ /xx/
205 || $self->payinfo =~ /^\s*N\/A\s+\(tokenized\)\s*$/
210 $self->payinfo($old->payinfo);
212 } elsif ( $self->payby =~ /^(CHEK|DCHK)$/ && $self->payinfo =~ /xx/ ) {
213 #fix for #3085 "edit of customer's routing code only surprisingly causes
214 #nothing to happen...
215 # this probably won't do the right thing when we don't have the
216 # public key (can't actually get the real $old->payinfo)
217 my($new_account, $new_aba) = split('@', $self->payinfo);
218 my($old_account, $old_aba) = split('@', $old->payinfo);
219 $new_account = $old_account if $new_account =~ /xx/;
220 $new_aba = $old_aba if $new_aba =~ /xx/;
221 $self->payinfo($new_account.'@'.$new_aba);
224 # only unmask paycvv if payinfo stayed the same
225 if ( $self->payby =~ /^(CARD|DCRD)$/ and $self->paycvv =~ /^\s*[\*x]+\s*$/ ) {
226 if ( $old->payinfo eq $self->payinfo
227 && $old->paymask eq $self->paymask
229 $self->paycvv($old->paycvv);
235 local($ignore_expired_card) = 1
236 if $old->payby =~ /^(CARD|DCRD)$/
237 && $self->payby =~ /^(CARD|DCRD)$/
238 && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
240 local($ignore_banned_card) = 1
241 if ( $old->payby =~ /^(CARD|DCRD)$/ && $self->payby =~ /^(CARD|DCRD)$/
242 || $old->payby =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
243 && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
245 if ( $self->payby =~ /^(CARD|DCRD)$/
246 && $old->payinfo ne $self->payinfo
247 && $old->paymask ne $self->paymask )
249 my $error = $self->check_payinfo_cardtype;
250 return $error if $error;
252 if ( $conf->exists('business-onlinepayment-verification') ) {
253 $error = $self->verify;
255 $error = $self->tokenize;
257 return $error if $error;
261 local $SIG{HUP} = 'IGNORE';
262 local $SIG{INT} = 'IGNORE';
263 local $SIG{QUIT} = 'IGNORE';
264 local $SIG{TERM} = 'IGNORE';
265 local $SIG{TSTP} = 'IGNORE';
266 local $SIG{PIPE} = 'IGNORE';
268 my $oldAutoCommit = $FS::UID::AutoCommit;
269 local $FS::UID::AutoCommit = 0;
272 my $error = $self->SUPER::replace($old);
274 $dbh->rollback if $oldAutoCommit;
278 if ( $self->payby =~ /^(CARD|CHEK)$/
279 && ( ( $self->get('payinfo') ne $old->get('payinfo')
282 || grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
287 # card/check/lec info has changed, want to retry realtime_ invoice events
288 my $error = $self->cust_main->retry_realtime;
290 $dbh->rollback if $oldAutoCommit;
295 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
302 Checks all fields to make sure this is a valid record. If there is
303 an error, returns the error, otherwise returns false. Called by the insert
312 $self->ut_numbern('custpaybynum')
313 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
314 || $self->ut_numbern('weight')
315 #encrypted #|| $self->ut_textn('payinfo')
316 #encrypted #|| $self->ut_textn('paycvv')
317 # || $self->ut_textn('paymask') #XXX something
318 || $self->ut_numbern('paystart_month')
319 || $self->ut_numbern('paystart_year')
320 || $self->ut_numbern('payissue')
321 # || $self->ut_textn('payname') #XXX something
322 || $self->ut_alphan('paystate')
323 || $self->ut_textn('paytype')
324 || $self->ut_ipn('payip')
326 return $error if $error;
330 FS::payby->can_payby($self->table, $self->payby)
331 or return "Illegal payby: ". $self->payby;
333 # If it is encrypted and the private key is not availaible then we can't
334 # check the credit card.
335 my $check_payinfo = ! $self->is_encrypted($self->payinfo);
337 # Need some kind of global flag to accept invalid cards, for testing
339 #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
341 # In this block: detect card type; reject credit card / account numbers that
342 # are impossible or banned; reject other payment features (date, CVV length)
343 # that are inappropriate for the card type.
344 # However, if the payinfo is encrypted then just detect card type and assume
345 # the other checks were already done.
347 if ( !$ignore_invalid_card &&
348 $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
350 unless ( $self->tokenized ) {
351 my $payinfo = $self->payinfo;
353 $payinfo =~ /^(\d{13,19}|\d{8,9})$/
354 or return gettext('invalid_card'); #. ": ". $self->payinfo;
356 $self->payinfo($payinfo);
358 or return gettext('invalid_card'); # . ": ". $self->payinfo;
361 # see parallel checks in check_payinfo_cardtype & payinfo_Mixin::payinfo_check
362 my $cardtype = $self->paycardtype;
363 if ( $self->tokenized ) {
364 $self->set('is_tokenized', 'Y'); #so we don't try to do it again
365 if ( $self->paymask =~ /^\d+x/ ) {
366 $cardtype = cardtype($self->paymask);
368 #return "paycardtype required ".
369 # "(can't derive from a token and no paymask w/prefix provided)"
373 $cardtype = cardtype($self->payinfo);
376 return gettext('unknown_card_type') if $cardtype eq "Unknown";
378 $self->set('paycardtype', $cardtype);
380 unless ( $ignore_banned_card ) {
381 my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
383 if ( $ban->bantype eq 'warn' ) {
384 #or others depending on value of $ban->reason ?
385 return '_duplicate_card'.
386 ': disabled from'. time2str('%a %h %o at %r', $ban->_date).
387 ' until '. time2str('%a %h %o at %r', $ban->_end_date).
388 ' (ban# '. $ban->bannum. ')'
389 unless $self->override_ban_warn;
391 return 'Banned credit card: banned on '.
392 time2str('%a %h %o at %r', $ban->_date).
393 ' by '. $ban->otaker.
394 ' (ban# '. $ban->bannum. ')';
399 if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
400 if ( $cardtype eq 'American Express card' ) {
401 $self->paycvv =~ /^(\d{4})$/
402 or return "CVV2 (CID) for American Express cards is four digits.";
405 $self->paycvv =~ /^(\d{3})$/
406 or return "CVV2 (CVC2/CID) is three digits.";
413 if ( $cardtype =~ /^(Switch|Solo)$/i ) {
415 return "Start date or issue number is required for $cardtype cards"
416 unless $self->paystart_month && $self->paystart_year or $self->payissue;
418 return "Start month must be between 1 and 12"
419 if $self->paystart_month
420 and $self->paystart_month < 1 || $self->paystart_month > 12;
422 return "Start year must be 1990 or later"
423 if $self->paystart_year
424 and $self->paystart_year < 1990;
426 return "Issue number must be beween 1 and 99"
428 and $self->payissue < 1 || $self->payissue > 99;
431 $self->paystart_month('');
432 $self->paystart_year('');
436 } elsif ( !$ignore_invalid_card &&
437 $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
439 my $payinfo = $self->payinfo;
440 $payinfo =~ s/[^\d\@\.]//g;
441 if ( $conf->config('echeck-country') eq 'CA' ) {
442 $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/
443 or return 'invalid echeck account@branch.bank';
444 $payinfo = "$1\@$2.$3";
445 } elsif ( $conf->config('echeck-country') eq 'US' ) {
446 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
449 $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
452 $self->payinfo($payinfo);
455 unless ( $ignore_banned_card ) {
456 my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
458 if ( $ban->bantype eq 'warn' ) {
459 #or others depending on value of $ban->reason ?
460 return '_duplicate_ach' unless $self->override_ban_warn;
462 return 'Banned ACH account: banned on '.
463 time2str('%a %h %o at %r', $ban->_date).
464 ' by '. $ban->otaker.
465 ' (ban# '. $ban->bannum. ')';
470 } elsif ( $self->payby =~ /^CARD|DCRD$/ and $self->paymask ) {
471 # either ignoring invalid cards, or we can't decrypt the payinfo, but
472 # try to detect the card type anyway. this never returns failure, so
473 # the contract of $ignore_invalid_cards is maintained.
474 $self->set('paycardtype', cardtype($self->paymask));
476 $self->set('paycardtype', '');
479 # } elsif ( $self->payby eq 'PREPAY' ) {
481 # my $payinfo = $self->payinfo;
482 # $payinfo =~ s/\W//g; #anything else would just confuse things
483 # $self->payinfo($payinfo);
484 # $error = $self->ut_alpha('payinfo');
485 # return "Illegal prepayment identifier: ". $self->payinfo if $error;
486 # return "Unknown prepayment identifier"
487 # unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
490 if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
494 } elsif ( $self->payby =~ /^(CARD|DCRD)$/ ) {
496 # shouldn't payinfo_check do this?
497 # (except we don't ever call payinfo_check from here)
498 return "Expiration date required"
499 if $self->paydate eq '' || $self->paydate eq '-';
502 if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
503 ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
504 } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
505 ( $m, $y ) = ( $2, "19$1" );
506 } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
507 ( $m, $y ) = ( $3, "20$2" );
509 return "Illegal expiration date: ". $self->paydate;
511 $m = sprintf('%02d',$m);
512 $self->paydate("$y-$m-01");
513 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
514 return gettext('expired_card')
517 !$ignore_expired_card
518 && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
522 if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
523 ( ! $conf->exists('require_cardname')
524 || $self->payby !~ /^(CARD|DCRD)$/ )
526 $self->payname( $self->first. " ". $self->getfield('last') );
529 if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
530 $self->payname =~ /^([\w \,\.\-\']*)$/
531 or return gettext('illegal_name'). " payname: ". $self->payname;
534 $self->payname =~ /^([\w \,\.\-\'\&]*)$/
535 or return gettext('illegal_name'). " payname: ". $self->payname;
541 if ( ! $self->custpaybynum ) {
542 if ($conf->exists('business-onlinepayment-verification')) {
543 $error = $self->verify;
545 $error = $self->tokenize;
547 return $error if $error;
550 $error = $self->ut_daten('paydate');
551 return $error if $error;
556 sub check_payinfo_cardtype {
559 return '' if $ignore_cardtype;
561 return '' unless $self->payby =~ /^(CARD|DCRD)$/;
563 my $payinfo = $self->payinfo;
566 # see parallel checks in cust_payby::check & payinfo_Mixin::payinfo_check
567 if ( $self->tokenized($payinfo) ) {
568 $self->set('is_tokenized', 'Y'); #so we don't try to do it again
569 if ( $self->paymask =~ /^\d+x/ ) {
570 $self->set('paycardtype', cardtype($self->paymask));
572 $self->set('paycardtype', '');
573 #return "paycardtype required ".
574 # "(can't derive from a token and no paymask w/prefix provided)";
579 my %bop_card_types = map { $_=>1 } values %{ card_types() };
580 my $cardtype = cardtype($payinfo);
581 $self->set('paycardtype', $cardtype);
583 return "$cardtype not accepted" unless $bop_card_types{$cardtype};
589 sub _banned_pay_hashref {
600 'payby' => $payby2ban{$self->payby},
601 'payinfo' => $self->payinfo,
602 #don't ever *search* on reason! #'reason' =>
606 sub _new_banned_pay_hashref {
608 my $hr = $self->_banned_pay_hashref;
609 $hr->{payinfo_hash} = 'SHA512';
610 $hr->{payinfo} = sha512_base64($hr->{payinfo});
614 =item paydate_mon_year
616 Returns a two element list consisting of the paydate month and year.
620 sub paydate_mon_year {
623 my $date = $self->paydate; # || '12-2037';
625 #false laziness w/elements/select-month_year.html
626 if ( $date =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #PostgreSQL date format
628 } elsif ( $date =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
631 warn "unrecognized expiration date format: $date";
639 Returns a one line text label for this payment type.
656 my $name = $self->payby =~ /^(CARD|DCRD)$/
657 && $self->paycardtype || FS::payby->shortname($self->payby);
659 ( $self->payby =~ /^(CARD|CHEK)$/ ? $weight{$self->weight}. ' automatic '
662 "$name: ". $self->paymask.
663 ( $self->payby =~ /^(CARD|DCRD)$/
664 ? ' Exp '. join('/', $self->paydate_mon_year)
672 Runs a L<FS::cust_main::Billing_Realtime/realtime_bop> transaction on this card
677 my( $self, %opt ) = @_;
679 $self->cust_main->realtime_bop({
681 'cust_payby' => $self,
688 Runs a L<FS::cust_main::Billing_Realtime/realtime_tokenize> transaction on this card
694 return '' unless $self->payby =~ /^(CARD|DCRD)$/;
696 $self->cust_main->realtime_tokenize({
697 'cust_payby' => $self,
704 Runs a L<realtime_verify_bop|FS::cust_main::Billing_Realtime/realtime_verify_bop> transaction on this card
710 return '' unless $self->payby =~ /^(CARD|DCRD)$/;
712 $self->cust_main->realtime_verify_bop({
713 'cust_payby' => $self,
720 Returns a list of valid values for the paytype field (bank account type for
721 electronic check payment).
728 ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
731 =item cgi_cust_payby_fields
733 Returns the field names used in the web interface (including some pseudo-fields).
737 sub cgi_cust_payby_fields {
739 [qw( payby payinfo paydate_month paydate_year paycvv payname weight
740 payinfo1 payinfo2 payinfo3 paytype paystate payname_CHEK )];
743 =item cgi_hash_callback HASHREF OLD
745 Subroutine (not a class or object method). Processes a hash reference
746 of web interface contet (transfers the data from pseudo-fields to real fields).
748 If OLD object is passed, also preserves locationnum, paystart_month, paystart_year,
749 payissue and payip. If the new field is blank but the old is not, the old field
754 sub cgi_hash_callback {
762 # the payby selector gives the choice of CARD or CHEK (or others, but
763 # those are the ones with auto and on-demand versions). if the user didn't
764 # choose a weight, then they mean DCRD/DCHK.
765 $hashref->{payby} = $noauto{$hashref->{payby}}
766 if ! $hashref->{weight} && exists $noauto{$hashref->{payby}};
768 if ( $hashref->{payby} =~ /^(CHEK|DCHK)$/ ) {
770 unless ( grep $hashref->{$_}, qw(payinfo1 payinfo2 payinfo3 payname_CHEK)) {
775 $hashref->{payinfo} = $hashref->{payinfo1}. '@';
776 $hashref->{payinfo} .= $hashref->{payinfo3}.'.'
777 if $conf->config('echeck-country') eq 'CA';
778 $hashref->{payinfo} .= $hashref->{'payinfo2'};
780 $hashref->{payname} = $hashref->{'payname_CHEK'};
782 } elsif ( $hashref->{payby} =~ /^(CARD|DCRD)$/ ) {
784 unless ( grep $hashref->{$_}, qw( payinfo paycvv payname ) ) {
791 $hashref->{paydate}= $hashref->{paydate_month}. '-'. $hashref->{paydate_year};
794 foreach my $field ( qw(locationnum paystart_month paystart_year payissue payip) ) {
795 next if $hashref->{$field};
796 next unless $old->get($field);
797 $hashref->{$field} = $old->get($field);
807 Returns a qsearch hash expression to search for parameters specified in HASHREF.
826 my ($class, $params) = @_;
831 # initialize these to prevent warnings
833 'paydate_year' => '',
841 if ( $params->{'payby'} ) {
843 my @payby = ref( $params->{'payby'} )
844 ? @{ $params->{'payby'} }
845 : ( $params->{'payby'} );
847 @payby = grep /^([A-Z]{4})$/, @payby;
848 my $in_payby = 'IN(' . join(',', map {"'$_'"} @payby) . ')';
849 push @where, "cust_payby.payby $in_payby"
854 # paydate_year / paydate_month
857 if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
859 $params->{'paydate_month'} =~ /^(\d\d?)$/
860 or die "paydate_year without paydate_month?";
864 'cust_payby.paydate IS NOT NULL',
865 "cust_payby.paydate != ''",
866 "CAST(cust_payby.paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
870 # setup queries, subs, etc. for the search
873 $orderby ||= 'ORDER BY custnum';
875 # here is the agent virtualization
877 $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
879 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
881 my $addl_from = ' LEFT JOIN cust_main USING ( custnum ) ';
882 # always make address fields available in results
883 for my $pre ('bill_', 'ship_') {
885 ' LEFT JOIN cust_location AS '.$pre.'location '.
886 'ON (cust_main.'.$pre.'locationnum = '.$pre.'location.locationnum) ';
888 # always make referral available in results
889 # (maybe we should be using FS::UI::Web::join_cust_main instead?)
890 $addl_from .= ' LEFT JOIN (select refnum, referral from part_referral) AS part_referral_x ON (cust_main.refnum = part_referral_x.refnum) ';
892 my $count_query = "SELECT COUNT(*) FROM cust_payby $addl_from $extra_sql";
894 my @select = ( 'cust_payby.*',
895 #'cust_main.custnum',
896 # there's a good chance that we'll need these
897 'cust_main.bill_locationnum',
898 'cust_main.ship_locationnum',
899 FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
902 my $select = join(', ', @select);
905 'table' => 'cust_payby',
907 'addl_from' => $addl_from,
909 'extra_sql' => $extra_sql,
910 'order_by' => $orderby,
911 'count_query' => $count_query,
919 =item has_autobill_cards
921 Returns the number of unexpired cards configured for autobill
925 sub has_autobill_cards {
926 scalar FS::Record::qsearch({
927 table => 'cust_payby',
928 addl_from => 'JOIN cust_main USING (custnum)',
929 order_by => 'LIMIT 1',
931 paydate => { op => '>', value => DateTime->now->ymd },
932 weight => { op => '>', value => 0 },
935 "AND cust_payby.payby IN ('CARD', 'DCRD') ".
937 $FS::CurrentUser::CurrentUser->agentnums_sql( table => 'cust_main' ),
941 =item has_autobill_checks
943 Returns the number of check accounts configured for autobill
947 sub has_autobill_checks {
948 scalar FS::Record::qsearch({
949 table => 'cust_payby',
950 addl_from => 'JOIN cust_main USING (custnum)',
951 order_by => 'LIMIT 1',
953 weight => { op => '>', value => 0 },
956 "AND cust_payby.payby IN ('CHEK','DCHEK','DCHK') ".
958 $FS::CurrentUser::CurrentUser->agentnums_sql( table => 'cust_main' ),
962 =item future_autobill_report_title
964 Determine if the future_autobill report should be available.
965 If so, return a dynamic title for it
969 sub future_autobill_report_title {
970 # Perhaps this function belongs somewhere else
972 return $title if defined $title;
974 # Report incompatible with tax engines
975 return $title = '' if FS::TaxEngine->new->info->{batch};
977 my $has_cards = has_autobill_cards();
978 my $has_checks = has_autobill_checks();
979 my $_title = 'Future %s transactions';
981 if ( $has_cards && $has_checks ) {
982 $title = sprintf $_title, 'credit card and electronic check';
983 } elsif ( $has_cards ) {
984 $title = sprintf $_title, 'credit card';
985 } elsif ( $has_checks ) {
986 $title = sprintf $_title, 'electronic check';
997 local $ignore_banned_card = 1;
998 local $ignore_expired_card = 1;
999 local $ignore_invalid_card = 1;
1000 $class->upgrade_set_cardtype;
1001 $class->_upgrade_data_paydate_edgebug;
1005 =item _upgrade_data_paydate_edgebug
1007 Correct bad data injected into payment expire date column by Edge browser bug
1009 The month and year values may have an extra character injected into form POST
1010 data by Edge browser. It was possible for some bad month values to slip
1011 past data validation.
1013 If the stored value was out of range, it was causing payments screen to crash.
1014 We can detect and fix this by dropping the second digit.
1016 If the stored value is is 11 or 12, it's possible the user inputted a 1. In
1017 this case, the payment method will fail to authorize, but the record will
1018 not cause crashdumps for being out of range.
1020 In short, check for any expiration month > 12, and drop the extra digit
1024 sub _upgrade_data_paydate_edgebug {
1025 my $journal_label = 'cust_payby_paydate_edgebug';
1026 return if FS::upgrade_journal->is_done( $journal_label );
1028 my $oldAutoCommit = $FS::UID::AutoCommit;
1029 local $FS::UID::AutoCommit = 0;
1032 FS::Record::qsearch(
1033 cust_payby => { paydate => { op => '!=', value => '' }}
1036 next unless $row->ut_daten('paydate');
1038 # paydate column stored in database has failed date validation
1039 my $bad_paydate = $row->paydate;
1041 my @date = split /[\-\/]/, $bad_paydate;
1042 @date = @date[2,0,1] if $date[2] > 1900;
1044 # Only autocorrecting when month > 12 - notify operator
1045 unless ( $date[1] > 12 ) {
1047 'Unable to correct bad paydate stored in cust_payby row '.
1048 'custpaybynum(%s) custnum(%s) paydate(%s)',
1055 $date[1] = substr( $date[1], 0, 1 );
1056 $row->paydate( join('-', @date ));
1058 if ( my $error = $row->replace ) {
1060 'Failed to autocorrect bad paydate stored in cust_payby row '.
1061 'custpaybynum(%s) custnum(%s) paydate(%s) - error: %s',
1070 'Autocorrected bad paydate stored in cust_payby row '.
1071 "custpaybynum(%s) custnum(%s) old-paydate(%s) new-paydate(%s)\n",
1080 FS::upgrade_journal->set_done( $journal_label );
1081 dbh->commit unless $oldAutoCommit;
1088 L<FS::Record>, schema.html from the base documentation.