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 if $self->payby =~/^(CARD|DCRD)$/;
164 $self->SUPER::insert unless $error;
167 $dbh->rollback if $oldAutoCommit;
171 if ( $self->payby =~ /^(CARD|CHEK)$/ ) {
172 # new auto card/check info, want to retry realtime_ invoice events
173 # (new customer? that's okay, they won't have any)
174 my $error = $self->cust_main->retry_realtime;
176 $dbh->rollback if $oldAutoCommit;
181 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
188 Delete this record from the database.
190 =item replace OLD_RECORD
192 Replaces the OLD_RECORD with this one in the database. If there is an error,
193 returns the error, otherwise returns false.
200 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
202 : $self->replace_old;
204 if ( $self->payby =~ /^(CARD|DCRD)$/
205 && ( $self->payinfo =~ /xx/
206 || $self->payinfo =~ /^\s*N\/A\s+\(tokenized\)\s*$/
211 $self->payinfo($old->payinfo);
213 } elsif ( $self->payby =~ /^(CHEK|DCHK)$/ && $self->payinfo =~ /xx/ ) {
214 #fix for #3085 "edit of customer's routing code only surprisingly causes
215 #nothing to happen...
216 # this probably won't do the right thing when we don't have the
217 # public key (can't actually get the real $old->payinfo)
218 my($new_account, $new_aba) = split('@', $self->payinfo);
219 my($old_account, $old_aba) = split('@', $old->payinfo);
220 $new_account = $old_account if $new_account =~ /xx/;
221 $new_aba = $old_aba if $new_aba =~ /xx/;
222 $self->payinfo($new_account.'@'.$new_aba);
225 # only unmask paycvv if payinfo stayed the same
226 if ( $self->payby =~ /^(CARD|DCRD)$/ and $self->paycvv =~ /^\s*[\*x]+\s*$/ ) {
227 if ( $old->payinfo eq $self->payinfo
228 && $old->paymask eq $self->paymask
230 $self->paycvv($old->paycvv);
236 local($ignore_expired_card) = 1
237 if $old->payby =~ /^(CARD|DCRD)$/
238 && $self->payby =~ /^(CARD|DCRD)$/
239 && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
241 local($ignore_banned_card) = 1
242 if ( $old->payby =~ /^(CARD|DCRD)$/ && $self->payby =~ /^(CARD|DCRD)$/
243 || $old->payby =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
244 && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
246 if ( $self->payby =~ /^(CARD|DCRD)$/
247 && $old->payinfo ne $self->payinfo
248 && $old->paymask ne $self->paymask )
250 my $error = $self->check_payinfo_cardtype;
251 return $error if $error;
253 if ( $conf->exists('business-onlinepayment-verification') ) {
254 $error = $self->verify;
256 $error = $self->tokenize;
258 return $error if $error;
262 local $SIG{HUP} = 'IGNORE';
263 local $SIG{INT} = 'IGNORE';
264 local $SIG{QUIT} = 'IGNORE';
265 local $SIG{TERM} = 'IGNORE';
266 local $SIG{TSTP} = 'IGNORE';
267 local $SIG{PIPE} = 'IGNORE';
269 my $oldAutoCommit = $FS::UID::AutoCommit;
270 local $FS::UID::AutoCommit = 0;
273 my $error = $self->SUPER::replace($old);
275 $dbh->rollback if $oldAutoCommit;
279 if ( $self->payby =~ /^(CARD|CHEK)$/
280 && ( ( $self->get('payinfo') ne $old->get('payinfo')
283 || grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
288 # card/check/lec info has changed, want to retry realtime_ invoice events
289 my $error = $self->cust_main->retry_realtime;
291 $dbh->rollback if $oldAutoCommit;
296 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
303 Checks all fields to make sure this is a valid record. If there is
304 an error, returns the error, otherwise returns false. Called by the insert
313 $self->ut_numbern('custpaybynum')
314 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
315 || $self->ut_numbern('weight')
316 #encrypted #|| $self->ut_textn('payinfo')
317 #encrypted #|| $self->ut_textn('paycvv')
318 # || $self->ut_textn('paymask') #XXX something
319 || $self->ut_numbern('paystart_month')
320 || $self->ut_numbern('paystart_year')
321 || $self->ut_numbern('payissue')
322 # || $self->ut_textn('payname') #XXX something
323 || $self->ut_alphan('paystate')
324 || $self->ut_textn('paytype')
325 || $self->ut_ipn('payip')
327 return $error if $error;
331 FS::payby->can_payby($self->table, $self->payby)
332 or return "Illegal payby: ". $self->payby;
334 # If it is encrypted and the private key is not availaible then we can't
335 # check the credit card.
336 my $check_payinfo = ! $self->is_encrypted($self->payinfo);
338 # Need some kind of global flag to accept invalid cards, for testing
340 #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
342 # In this block: detect card type; reject credit card / account numbers that
343 # are impossible or banned; reject other payment features (date, CVV length)
344 # that are inappropriate for the card type.
345 # However, if the payinfo is encrypted then just detect card type and assume
346 # the other checks were already done.
348 if ( !$ignore_invalid_card &&
349 $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
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;
360 # see parallel checks in check_payinfo_cardtype & payinfo_Mixin::payinfo_check
361 my $cardtype = $self->paycardtype;
362 if ( $self->tokenized ) {
363 $self->set('is_tokenized', 'Y'); #so we don't try to do it again
364 if ( $self->paymask =~ /^\d+x/ ) {
365 $cardtype = cardtype($self->paymask);
367 #return "paycardtype required ".
368 # "(can't derive from a token and no paymask w/prefix provided)"
372 $cardtype = cardtype($self->payinfo);
375 return gettext('unknown_card_type') if $cardtype eq "Unknown";
377 $self->set('paycardtype', $cardtype);
379 unless ( $ignore_banned_card ) {
380 my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
382 if ( $ban->bantype eq 'warn' ) {
383 #or others depending on value of $ban->reason ?
384 return '_duplicate_card'.
385 ': disabled from'. time2str('%a %h %o at %r', $ban->_date).
386 ' until '. time2str('%a %h %o at %r', $ban->_end_date).
387 ' (ban# '. $ban->bannum. ')'
388 unless $self->override_ban_warn;
390 return 'Banned credit card: banned on '.
391 time2str('%a %h %o at %r', $ban->_date).
392 ' by '. $ban->otaker.
393 ' (ban# '. $ban->bannum. ')';
398 if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
399 if ( $cardtype eq 'American Express card' ) {
400 $self->paycvv =~ /^(\d{4})$/
401 or return "CVV2 (CID) for American Express cards is four digits.";
404 $self->paycvv =~ /^(\d{3})$/
405 or return "CVV2 (CVC2/CID) is three digits.";
412 if ( $cardtype =~ /^(Switch|Solo)$/i ) {
414 return "Start date or issue number is required for $cardtype cards"
415 unless $self->paystart_month && $self->paystart_year or $self->payissue;
417 return "Start month must be between 1 and 12"
418 if $self->paystart_month
419 and $self->paystart_month < 1 || $self->paystart_month > 12;
421 return "Start year must be 1990 or later"
422 if $self->paystart_year
423 and $self->paystart_year < 1990;
425 return "Issue number must be beween 1 and 99"
427 and $self->payissue < 1 || $self->payissue > 99;
430 $self->paystart_month('');
431 $self->paystart_year('');
435 } elsif ( !$ignore_invalid_card &&
436 $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
438 my $payinfo = $self->payinfo;
439 $payinfo =~ s/[^\d\@\.]//g;
440 if ( $conf->config('echeck-country') eq 'CA' ) {
441 $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/
442 or return 'invalid echeck account@branch.bank';
443 $payinfo = "$1\@$2.$3";
444 } elsif ( $conf->config('echeck-country') eq 'US' ) {
445 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
448 $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
451 $self->payinfo($payinfo);
454 unless ( $ignore_banned_card ) {
455 my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
457 if ( $ban->bantype eq 'warn' ) {
458 #or others depending on value of $ban->reason ?
459 return '_duplicate_ach' unless $self->override_ban_warn;
461 return 'Banned ACH account: banned on '.
462 time2str('%a %h %o at %r', $ban->_date).
463 ' by '. $ban->otaker.
464 ' (ban# '. $ban->bannum. ')';
469 } elsif ( $self->payby =~ /^CARD|DCRD$/ and $self->paymask ) {
470 # either ignoring invalid cards, or we can't decrypt the payinfo, but
471 # try to detect the card type anyway. this never returns failure, so
472 # the contract of $ignore_invalid_cards is maintained.
473 $self->set('paycardtype', cardtype($self->paymask));
475 $self->set('paycardtype', '');
478 # } elsif ( $self->payby eq 'PREPAY' ) {
480 # my $payinfo = $self->payinfo;
481 # $payinfo =~ s/\W//g; #anything else would just confuse things
482 # $self->payinfo($payinfo);
483 # $error = $self->ut_alpha('payinfo');
484 # return "Illegal prepayment identifier: ". $self->payinfo if $error;
485 # return "Unknown prepayment identifier"
486 # unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
489 if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
493 } elsif ( $self->payby =~ /^(CARD|DCRD)$/ ) {
495 # shouldn't payinfo_check do this?
496 # (except we don't ever call payinfo_check from here)
497 return "Expiration date required"
498 if $self->paydate eq '' || $self->paydate eq '-';
501 if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
502 ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
503 } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
504 ( $m, $y ) = ( $2, "19$1" );
505 } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
506 ( $m, $y ) = ( $3, "20$2" );
508 return "Illegal expiration date: ". $self->paydate;
510 $m = sprintf('%02d',$m);
511 $self->paydate("$y-$m-01");
512 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
513 return gettext('expired_card')
516 !$ignore_expired_card
517 && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
521 if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
522 ( ! $conf->exists('require_cardname')
523 || $self->payby !~ /^(CARD|DCRD)$/ )
525 $self->payname( $self->first. " ". $self->getfield('last') );
528 if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
529 $self->payname =~ /^([\w \,\.\-\']*)$/
530 or return gettext('illegal_name'). " payname: ". $self->payname;
533 $self->payname =~ /^([\w \,\.\-\'\&]*)$/
534 or return gettext('illegal_name'). " payname: ". $self->payname;
540 if ( ! $self->custpaybynum ) {
541 if ($conf->exists('business-onlinepayment-verification')) {
542 $error = $self->verify;
544 $error = $self->tokenize;
546 return $error if $error;
549 $error = $self->ut_daten('paydate');
550 return $error if $error;
555 sub check_payinfo_cardtype {
558 return '' if $ignore_cardtype;
560 return '' unless $self->payby =~ /^(CARD|CHEK)$/;
562 my $payinfo = $self->payinfo;
565 # see parallel checks in cust_payby::check & payinfo_Mixin::payinfo_check
566 if ( $self->tokenized($payinfo) ) {
567 $self->set('is_tokenized', 'Y'); #so we don't try to do it again
568 if ( $self->paymask =~ /^\d+x/ ) {
569 $self->set('paycardtype', cardtype($self->paymask));
571 $self->set('paycardtype', '');
572 #return "paycardtype required ".
573 # "(can't derive from a token and no paymask w/prefix provided)";
578 my %bop_card_types = map { $_=>1 } values %{ card_types() };
579 my $cardtype = cardtype($payinfo);
580 $self->set('paycardtype', $cardtype);
582 return "$cardtype not accepted" unless $bop_card_types{$cardtype};
588 sub _banned_pay_hashref {
599 'payby' => $payby2ban{$self->payby},
600 'payinfo' => $self->payinfo,
601 #don't ever *search* on reason! #'reason' =>
605 sub _new_banned_pay_hashref {
607 my $hr = $self->_banned_pay_hashref;
608 $hr->{payinfo_hash} = 'SHA512';
609 $hr->{payinfo} = sha512_base64($hr->{payinfo});
613 =item paydate_mon_year
615 Returns a two element list consisting of the paydate month and year.
619 sub paydate_mon_year {
622 my $date = $self->paydate; # || '12-2037';
624 #false laziness w/elements/select-month_year.html
625 if ( $date =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #PostgreSQL date format
627 } elsif ( $date =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
630 warn "unrecognized expiration date format: $date";
638 Returns a one line text label for this payment type.
655 my $name = $self->payby =~ /^(CARD|DCRD)$/
656 && $self->paycardtype || FS::payby->shortname($self->payby);
658 ( $self->payby =~ /^(CARD|CHEK)$/ ? $weight{$self->weight}. ' automatic '
661 "$name: ". $self->paymask.
662 ( $self->payby =~ /^(CARD|DCRD)$/
663 ? ' Exp '. join('/', $self->paydate_mon_year)
671 Runs a L<realtime_bop|FS::cust_main::Billing_Realtime::realtime_bop> transaction on this card
676 my( $self, %opt ) = @_;
678 $self->cust_main->realtime_bop({
680 'cust_payby' => $self,
687 Runs a L<realtime_tokenize|FS::cust_main::Billing_Realtime::realtime_tokenize> transaction on this card
693 return '' unless $self->payby =~ /^(CARD|DCRD)$/;
695 $self->cust_main->realtime_tokenize({
696 'cust_payby' => $self,
703 Runs a L<realtime_verify_bop|FS::cust_main::Billing_Realtime/realtime_verify_bop> transaction on this card
709 return '' unless $self->payby =~ /^(CARD|DCRD)$/;
711 $self->cust_main->realtime_verify_bop({
712 'cust_payby' => $self,
719 Returns a list of valid values for the paytype field (bank account type for
720 electronic check payment).
727 ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
730 =item cgi_cust_payby_fields
732 Returns the field names used in the web interface (including some pseudo-fields).
736 sub cgi_cust_payby_fields {
738 [qw( payby payinfo paydate_month paydate_year paycvv payname weight
739 payinfo1 payinfo2 payinfo3 paytype paystate payname_CHEK )];
742 =item cgi_hash_callback HASHREF OLD
744 Subroutine (not a class or object method). Processes a hash reference
745 of web interface contet (transfers the data from pseudo-fields to real fields).
747 If OLD object is passed, also preserves locationnum, paystart_month, paystart_year,
748 payissue and payip. If the new field is blank but the old is not, the old field
753 sub cgi_hash_callback {
761 # the payby selector gives the choice of CARD or CHEK (or others, but
762 # those are the ones with auto and on-demand versions). if the user didn't
763 # choose a weight, then they mean DCRD/DCHK.
764 $hashref->{payby} = $noauto{$hashref->{payby}}
765 if ! $hashref->{weight} && exists $noauto{$hashref->{payby}};
767 if ( $hashref->{payby} =~ /^(CHEK|DCHK)$/ ) {
769 unless ( grep $hashref->{$_}, qw(payinfo1 payinfo2 payinfo3 payname_CHEK)) {
774 $hashref->{payinfo} = $hashref->{payinfo1}. '@';
775 $hashref->{payinfo} .= $hashref->{payinfo3}.'.'
776 if $conf->config('echeck-country') eq 'CA';
777 $hashref->{payinfo} .= $hashref->{'payinfo2'};
779 $hashref->{payname} = $hashref->{'payname_CHEK'};
781 } elsif ( $hashref->{payby} =~ /^(CARD|DCRD)$/ ) {
783 unless ( grep $hashref->{$_}, qw( payinfo paycvv payname ) ) {
790 $hashref->{paydate}= $hashref->{paydate_month}. '-'. $hashref->{paydate_year};
793 foreach my $field ( qw(locationnum paystart_month paystart_year payissue payip) ) {
794 next if $hashref->{$field};
795 next unless $old->get($field);
796 $hashref->{$field} = $old->get($field);
806 Returns a qsearch hash expression to search for parameters specified in HASHREF.
825 my ($class, $params) = @_;
830 # initialize these to prevent warnings
832 'paydate_year' => '',
840 if ( $params->{'payby'} ) {
842 my @payby = ref( $params->{'payby'} )
843 ? @{ $params->{'payby'} }
844 : ( $params->{'payby'} );
846 @payby = grep /^([A-Z]{4})$/, @payby;
847 my $in_payby = 'IN(' . join(',', map {"'$_'"} @payby) . ')';
848 push @where, "cust_payby.payby $in_payby"
853 # paydate_year / paydate_month
856 if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
858 $params->{'paydate_month'} =~ /^(\d\d?)$/
859 or die "paydate_year without paydate_month?";
863 'cust_payby.paydate IS NOT NULL',
864 "cust_payby.paydate != ''",
865 "CAST(cust_payby.paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
869 # setup queries, subs, etc. for the search
872 $orderby ||= 'ORDER BY custnum';
874 # here is the agent virtualization
876 $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
878 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
880 my $addl_from = ' LEFT JOIN cust_main USING ( custnum ) ';
881 # always make address fields available in results
882 for my $pre ('bill_', 'ship_') {
884 ' LEFT JOIN cust_location AS '.$pre.'location '.
885 'ON (cust_main.'.$pre.'locationnum = '.$pre.'location.locationnum) ';
887 # always make referral available in results
888 # (maybe we should be using FS::UI::Web::join_cust_main instead?)
889 $addl_from .= ' LEFT JOIN (select refnum, referral from part_referral) AS part_referral_x ON (cust_main.refnum = part_referral_x.refnum) ';
891 my $count_query = "SELECT COUNT(*) FROM cust_payby $addl_from $extra_sql";
893 my @select = ( 'cust_payby.*',
894 #'cust_main.custnum',
895 # there's a good chance that we'll need these
896 'cust_main.bill_locationnum',
897 'cust_main.ship_locationnum',
898 FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
901 my $select = join(', ', @select);
904 'table' => 'cust_payby',
906 'addl_from' => $addl_from,
908 'extra_sql' => $extra_sql,
909 'order_by' => $orderby,
910 'count_query' => $count_query,
918 =item has_autobill_cards
920 Returns the number of unexpired cards configured for autobill
924 sub has_autobill_cards {
925 scalar FS::Record::qsearch({
926 table => 'cust_payby',
927 addl_from => 'JOIN cust_main USING (custnum)',
928 order_by => 'LIMIT 1',
930 paydate => { op => '>', value => DateTime->now->ymd },
931 weight => { op => '>', value => 0 },
934 "AND cust_payby.payby IN ('CARD', 'DCRD') ".
936 $FS::CurrentUser::CurrentUser->agentnums_sql( table => 'cust_main' ),
940 =item has_autobill_checks
942 Returns the number of check accounts configured for autobill
946 sub has_autobill_checks {
947 scalar FS::Record::qsearch({
948 table => 'cust_payby',
949 addl_from => 'JOIN cust_main USING (custnum)',
950 order_by => 'LIMIT 1',
952 weight => { op => '>', value => 0 },
955 "AND cust_payby.payby IN ('CHEK','DCHEK','DCHK') ".
957 $FS::CurrentUser::CurrentUser->agentnums_sql( table => 'cust_main' ),
961 =item future_autobill_report_title
963 Determine if the future_autobill report should be available.
964 If so, return a dynamic title for it
968 sub future_autobill_report_title {
969 # Perhaps this function belongs somewhere else
971 return $title if defined $title;
973 # Report incompatible with tax engines
974 return $title = '' if FS::TaxEngine->new->info->{batch};
976 my $has_cards = has_autobill_cards();
977 my $has_checks = has_autobill_checks();
978 my $_title = 'Future %s transactions';
980 if ( $has_cards && $has_checks ) {
981 $title = sprintf $_title, 'credit card and electronic check';
982 } elsif ( $has_cards ) {
983 $title = sprintf $_title, 'credit card';
984 } elsif ( $has_checks ) {
985 $title = sprintf $_title, 'electronic check';
996 local $ignore_banned_card = 1;
997 local $ignore_expired_card = 1;
998 local $ignore_invalid_card = 1;
999 $class->upgrade_set_cardtype;
1000 $class->_upgrade_data_paydate_edgebug;
1004 =item _upgrade_data_paydate_edgebug
1006 Correct bad data injected into payment expire date column by Edge browser bug
1008 The month and year values may have an extra character injected into form POST
1009 data by Edge browser. It was possible for some bad month values to slip
1010 past data validation.
1012 If the stored value was out of range, it was causing payments screen to crash.
1013 We can detect and fix this by dropping the second digit.
1015 If the stored value is is 11 or 12, it's possible the user inputted a 1. In
1016 this case, the payment method will fail to authorize, but the record will
1017 not cause crashdumps for being out of range.
1019 In short, check for any expiration month > 12, and drop the extra digit
1023 sub _upgrade_data_paydate_edgebug {
1024 my $journal_label = 'cust_payby_paydate_edgebug';
1025 return if FS::upgrade_journal->is_done( $journal_label );
1027 my $oldAutoCommit = $FS::UID::AutoCommit;
1028 local $FS::UID::AutoCommit = 0;
1031 FS::Record::qsearch(
1032 cust_payby => { paydate => { op => '!=', value => '' }}
1035 next unless $row->ut_daten('paydate');
1037 # paydate column stored in database has failed date validation
1038 my $bad_paydate = $row->paydate;
1040 my @date = split /[\-\/]/, $bad_paydate;
1041 @date = @date[2,0,1] if $date[2] > 1900;
1043 # Only autocorrecting when month > 12 - notify operator
1044 unless ( $date[1] > 12 ) {
1046 'Unable to correct bad paydate stored in cust_payby row '.
1047 'custpaybynum(%s) custnum(%s) paydate(%s)',
1054 $date[1] = substr( $date[1], 0, 1 );
1055 $row->paydate( join('-', @date ));
1057 if ( my $error = $row->replace ) {
1059 'Failed to autocorrect bad paydate stored in cust_payby row '.
1060 'custpaybynum(%s) custnum(%s) paydate(%s) - error: %s',
1069 'Autocorrected bad paydate stored in cust_payby row '.
1070 "custpaybynum(%s) custnum(%s) old-paydate(%s) new-paydate(%s)\n",
1079 FS::upgrade_journal->set_done( $journal_label );
1080 dbh->commit unless $oldAutoCommit;
1087 L<FS::Record>, schema.html from the base documentation.