X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fpayinfo_Mixin.pm;h=01b222634130d6b6ee2fe15a83be00fd07b30f5f;hp=99cca6a8a162e7e6c517cb0b771cff5579594ffb;hb=dc83512c36dc6bea2585abada4f88d714c600e55;hpb=624b2d44625f69d71175c3348cae635d580c890b diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm index 99cca6a8a..01b222634 100644 --- a/FS/FS/payinfo_Mixin.pm +++ b/FS/FS/payinfo_Mixin.pm @@ -3,6 +3,13 @@ package FS::payinfo_Mixin; use strict; use Business::CreditCard; use FS::payby; +use FS::Record qw(qsearch); +use FS::UID qw(driver_name); +use FS::Cursor; +use Time::Local qw(timelocal); + +# allow_closed_replace only relevant to cust_pay/cust_refund, for upgrade tokenizing +use vars qw( $ignore_masked_payinfo $allow_closed_replace ); =head1 NAME @@ -18,12 +25,6 @@ use vars qw(@ISA); This is a mixin class for records that contain payinfo. -This class handles the following functions for payinfo... - -Payment Mask (Generation and Storage) -Data Validation (parent checks need to be sure to call this) -Pretty printing - =head1 FIELDS =over 4 @@ -41,43 +42,37 @@ For Customers (cust_main): For Refunds (cust_refund): 'CARD' (credit cards), 'CHEK' (electronic check/ACH), 'LECB' (Phone bill billing), 'BILL' (billing), 'CASH' (cash), -'WEST' (Western Union), 'MCRD' (Manual credit card), 'CBAK' Chargeback, or 'COMP' (free) +'WEST' (Western Union), 'MCRD' (Manual credit card), 'MCHK' (Manual electronic +check), 'CBAK' Chargeback, or 'COMP' (free) For Payments (cust_pay): 'CARD' (credit cards), 'CHEK' (electronic check/ACH), 'LECB' (phone bill billing), 'BILL' (billing), 'PREP' (prepaid card), -'CASH' (cash), 'WEST' (Western Union), or 'MCRD' (Manual credit card) +'CASH' (cash), 'WEST' (Western Union), 'MCRD' (Manual credit card), 'MCHK' +(Manual electronic check), 'PPAL' (PayPal) 'COMP' (free) is depricated as a payment type in cust_pay =cut -# was this supposed to do something? - -#sub payby { -# my($self,$payby) = @_; -# if ( defined($payby) ) { -# $self->setfield('payby', $payby); -# } -# return $self->getfield('payby') -#} - =item payinfo Payment information (payinfo) can be one of the following types: -Card Number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L) +Card Number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) +prepayment identifier (see L), PayPal transaction ID =cut sub payinfo { my($self,$payinfo) = @_; + if ( defined($payinfo) ) { - $self->setfield('payinfo', $payinfo); # This is okay since we are the 'setter' - $self->paymask($self->mask_payinfo()); + $self->paymask($self->mask_payinfo) unless $self->getfield('paymask') || $self->tokenized; #make sure old mask is set + $self->setfield('payinfo', $payinfo); + $self->paymask($self->mask_payinfo) unless $self->tokenized($payinfo); #remask unless tokenizing } else { - $payinfo = $self->getfield('payinfo'); # This is okay since we are the 'getter' - return $payinfo; + $self->getfield('payinfo'); } } @@ -87,20 +82,16 @@ Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit nu =cut +#this prevents encrypting empty values on insert? sub paycvv { my($self,$paycvv) = @_; - # This is only allowed in cust_main... Even then it really shouldn't be stored... - if ($self->table eq 'cust_main') { - if ( defined($paycvv) ) { - $self->setfield('paycvv', $paycvv); # This is okay since we are the 'setter' - } else { - $paycvv = $self->getfield('paycvv'); # This is okay since we are the 'getter' - return $paycvv; - } + # This is only allowed in cust_payby (formerly cust_main) + # It shouldn't be stored longer than necessary to run the first transaction + if ( defined($paycvv) ) { + $self->setfield('paycvv', $paycvv); } else { -# warn "This doesn't work for other tables besides cust_main - ''; - } + $self->getfield('paycvv'); + } } =item paymask @@ -110,25 +101,11 @@ sub paycvv { sub paymask { my($self, $paymask) = @_; - if ( defined($paymask) && $paymask ne '' ) { - # I hate this little bit of magic... I don't expect it to cause a problem, - # but who knows... If the payinfo is passed in masked then ignore it and - # set it based on the payinfo. The only guy that should call this in this - # way is... $self->payinfo - $self->setfield('paymask', $self->mask_payinfo()); - + if ( defined($paymask) ) { + $self->setfield('paymask', $paymask); } else { - - $paymask=$self->getfield('paymask'); - if (!defined($paymask) || $paymask eq '') { - # Generate it if it's blank - Note that we're not going to set it - just - # generate - $paymask = $self->mask_payinfo(); - } - + $self->getfield('paymask') || $self->mask_payinfo; } - - return $paymask; } =back @@ -152,55 +129,64 @@ sub mask_payinfo { my $payinfo = scalar(@_) ? shift : $self->payinfo; # Check to see if it's encrypted... - my $paymask; - if ( $self->is_encrypted($payinfo) ) { - $paymask = 'N/A'; - } else { - # if not, mask it... - if ($payby eq 'CARD' || $payby eq 'DCRD' || $payby eq 'MCRD') { + if ( ref($self) && $self->is_encrypted($payinfo) ) { + return 'N/A'; + } elsif ( $self->tokenized($payinfo) || $payinfo eq 'N/A' ) { #token + return 'N/A (tokenized)'; #? + } else { # if not, mask it... + + if ($payby eq 'CARD' || $payby eq 'DCRD') { + #|| $payby eq 'MCRD') { + #MCRD isn't a card in payinfo, + #its a record of an _offline_ + #card + # Credit Cards + + # special handling for Local Isracards: always show last 4 + if ( $payinfo =~ /^(\d{8,9})$/ ) { + + return 'x'x(length($payinfo)-4). + substr($payinfo,(length($payinfo)-4)); + + } + my $conf = new FS::Conf; my $mask_method = $conf->config('card_masking_method') || 'first6last4'; $mask_method =~ /^first(\d+)last(\d+)$/ or die "can't parse card_masking_method $mask_method"; my($first, $last) = ($1, $2); - $paymask = substr($payinfo,0,$first). - 'x'x(length($payinfo)-$first-$last). - substr($payinfo,(length($payinfo)-$last)); + return substr($payinfo,0,$first). + 'x'x(length($payinfo)-$first-$last). + substr($payinfo,(length($payinfo)-$last)); + } elsif ($payby eq 'CHEK' || $payby eq 'DCHK' ) { + # Checks (Show last 2 @ bank) my( $account, $aba ) = split('@', $payinfo ); - $paymask = 'x'x(length($account)-2). - substr($account,(length($account)-2))."@".$aba; + return 'x'x(length($account)-2). + substr($account,(length($account)-2)). + ( length($aba) ? "@".$aba : ''); + + } elsif ($payby eq 'EDI') { + # EDI. + # These numbers have been seen anywhere from 8 to 30 digits, and + # possibly more. Lacking any better idea I'm going to mask all but + # the last 4 digits. + return 'x' x (length($payinfo) - 4) . substr($payinfo, -4); + } else { # Tie up loose ends - $paymask = $payinfo; + return $payinfo; } } - return $paymask; + #die "shouldn't be reached"; } =item payinfo_check Checks payby and payinfo. -For Customers (cust_main): -'CARD' (credit card - automatic), 'DCRD' (credit card - on-demand), -'CHEK' (electronic check - automatic), 'DCHK' (electronic check - on-demand), -'LECB' (Phone bill billing), 'BILL' (billing), 'COMP' (free), or -'PREPAY' (special billing type: applies a credit - see L and sets billing type to I) - -For Refunds (cust_refund): -'CARD' (credit cards), 'CHEK' (electronic check/ACH), -'LECB' (Phone bill billing), 'BILL' (billing), 'CASH' (cash), -'WEST' (Western Union), 'MCRD' (Manual credit card), 'CBAK' (Chargeback), or 'COMP' (free) - -For Payments (cust_pay): -'CARD' (credit cards), 'CHEK' (electronic check/ACH), -'LECB' (phone bill billing), 'BILL' (billing), 'PREP' (prepaid card), -'CASH' (cash), 'WEST' (Western Union), or 'MCRD' (Manual credit card) -'COMP' (free) is depricated as a payment type in cust_pay - =cut sub payinfo_check { @@ -210,19 +196,49 @@ sub payinfo_check { or return "Illegal payby: ". $self->payby; if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) { - my $payinfo = $self->payinfo; - $payinfo =~ s/\D//g; - $self->payinfo($payinfo); - if ( $self->payinfo ) { - $self->payinfo =~ /^(\d{13,16})$/ - or return "Illegal (mistyped?) credit card number (payinfo)"; - $self->payinfo($1); - validate($self->payinfo) or return "Illegal credit card number"; - return "Unknown card type" if cardtype($self->payinfo) eq "Unknown"; + + # see parallel checks in cust_payby::check & cust_payby::check_payinfo_cardtype + if ( $self->tokenized ) { + $self->set('is_tokenized', 'Y'); #so we don't try to do it again + if ( $self->paymask =~ /^\d+x/ ) { + $self->set('paycardtype', cardtype($self->paymask)); + } else { + $self->set('paycardtype', '') unless $self->paycardtype; + #return "paycardtype required ". + # "(can't derive from a token and no paymask w/prefix provided)"; + } } else { - $self->payinfo('N/A'); #??? + $self->set('paycardtype', cardtype($self->payinfo)); } + + if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) { + # allow it + } else { + my $payinfo = $self->payinfo; + $payinfo =~ s/\D//g; + $self->payinfo($payinfo); + if ( $self->payinfo ) { + $self->payinfo =~ /^(\d{13,19}|\d{8,9})$/ + or return "Illegal (mistyped?) credit card number (payinfo)"; + $self->payinfo($1); + validate($self->payinfo) or return "Illegal credit card number"; + return "Unknown card type" if $self->paycardtype eq "Unknown"; + } else { + $self->payinfo('N/A'); #??? re-masks card + } + } + } else { + + if ( $self->payby eq 'CARD' && $self->paymask =~ /^\d+x/ ) { + # if we can't decrypt the card, at least detect the cardtype + $self->set('paycardtype', cardtype($self->paymask)); + } else { + $self->set('paycardtype', '') unless $self->paycardtype; + # return "paycardtype required ". + # "(can't derive from a token and no paymask w/prefix provided)"; + } + if ( $self->is_encrypted($self->payinfo) ) { #something better? all it would cause is a decryption error anyway? my $error = $self->ut_anything('payinfo'); @@ -233,11 +249,10 @@ sub payinfo_check { } } - ''; - + return ''; } -=item payby_payinfo_pretty +=item payby_payinfo_pretty [ LOCALE ] Returns payment method and information (suitably masked, if applicable) as a human-readable string, such as: @@ -252,34 +267,227 @@ or sub payby_payinfo_pretty { my $self = shift; + my $locale = shift; + my $lh = FS::L10N->get_handle($locale); if ( $self->payby eq 'CARD' ) { - 'Card #'. $self->paymask; + if ($self->paymask =~ /tokenized/) { + $lh->maketext('Tokenized Card'); + } else { + $lh->maketext('Card #') . $self->paymask; + } } elsif ( $self->payby eq 'CHEK' ) { - 'E-check acct#'. $self->payinfo; + + #false laziness w/view/cust_main/payment_history.html::translate_payinfo + my( $account, $aba ) = split('@', $self->paymask ); + + if ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #blame canada + my($branch, $routing) = ($1, $2); + $lh->maketext("Routing [_1], Branch [_2], Acct [_3]", + $routing, $branch, $account); + } else { + $lh->maketext("Routing [_1], Acct [_2]", $aba, $account); + } + } elsif ( $self->payby eq 'BILL' ) { - 'Check #'. $self->payinfo; + $lh->maketext('Check #') . $self->payinfo; } elsif ( $self->payby eq 'PREP' ) { - 'Prepaid card #'. $self->payinfo; + $lh->maketext('Prepaid card #') . $self->payinfo; } elsif ( $self->payby eq 'CASH' ) { - 'Cash '. $self->payinfo; + $lh->maketext('Cash') . ' ' . $self->payinfo; } elsif ( $self->payby eq 'WEST' ) { - 'Western Union'; #. $self->payinfo; + # does Western Union localize their name? + $lh->maketext('Western Union'); } elsif ( $self->payby eq 'MCRD' ) { - 'Manual credit card'; #. $self->payinfo; + $lh->maketext('Manual credit card'); + } elsif ( $self->payby eq 'MCHK' ) { + $lh->maketext('Manual electronic check'); + } elsif ( $self->payby eq 'EDI' ) { + $lh->maketext('EDI') . ' ' . $self->paymask; + } elsif ( $self->payby eq 'PPAL' ) { + $lh->maketext('PayPal transaction#') . $self->order_number; } else { $self->payby. ' '. $self->payinfo; } } +=item payinfo_used [ PAYINFO ] + +Returns 1 if there's an existing payment using this payinfo. This can be +used to set the 'recurring payment' flag required by some processors. + +=cut + +sub payinfo_used { + my $self = shift; + my $payinfo = shift || $self->payinfo; + my %hash = ( + 'custnum' => $self->custnum, + 'payby' => $self->payby, + ); + + return 1 + if qsearch('cust_pay', { %hash, 'payinfo' => $payinfo } ) + || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo } ) + ; + + return 0; +} + +=item display_status + +For transactions that have both 'status' and 'failure_status', shows the +status in a single, display-friendly string. + +=cut + +sub display_status { + my $self = shift; + my %status = ( + 'done' => 'Approved', + 'expired' => 'Card Expired', + 'stolen' => 'Lost/Stolen', + 'pickup' => 'Pick Up Card', + 'nsf' => 'Insufficient Funds', + 'inactive' => 'Inactive Account', + 'blacklisted' => 'Blacklisted', + 'declined' => 'Declined', + 'approved' => 'Approved', + ); + if ( $self->failure_status ) { + return $status{$self->failure_status}; + } else { + return $status{$self->status}; + } +} + +=item paydate_monthyear + +Returns a two-element list consisting of the month and year of this customer's +paydate (credit card expiration date for CARD customers) + +=cut + +sub paydate_monthyear { + my $self = shift; + if ( $self->paydate =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format + ( $2, $1 ); + } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) { + ( $1, $3 ); + } else { + ('', ''); + } +} + +=item paydate_epoch + +Returns the exact time in seconds corresponding to the payment method +expiration date. For CARD/DCRD customers this is the end of the month; +for others (COMP is the only other payby that uses paydate) it's the start. +Returns 0 if the paydate is empty or set to the far future. + +=cut + +sub paydate_epoch { + my $self = shift; + my ($month, $year) = $self->paydate_monthyear; + return 0 if !$year or $year >= 2037; + if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) { + $month++; + if ( $month == 13 ) { + $month = 1; + $year++; + } + return timelocal(0,0,0,1,$month-1,$year) - 1; + } + else { + return timelocal(0,0,0,1,$month-1,$year); + } +} + +=item paydate_epoch_sql + +Class method. Returns an SQL expression to obtain the payment expiration date +as a number of seconds. + +=cut + +# Special expiration date behavior for non-CARD/DCRD customers has been +# carefully preserved. Do we really use that? +sub paydate_epoch_sql { + my $class = shift; + my $table = $class->table; + my ($case1, $case2); + if ( driver_name eq 'Pg' ) { + $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1"; + $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )"; + } + elsif ( lc(driver_name) eq 'mysql' ) { + $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1"; + $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )"; + } + else { return '' } + return "CASE WHEN $table.payby IN('CARD','DCRD') + THEN ($case1) + ELSE ($case2) + END" +} + +=item upgrade_set_cardtype + +Find all records with a credit card payment type and no paycardtype, and +replace them in order to set their paycardtype. + +This method actually just starts a queue job. + +=cut + +sub upgrade_set_cardtype { + my $class = shift; + my $table = $class->table or die "upgrade_set_cardtype needs a table"; + + if ( ! FS::upgrade_journal->is_done("${table}__set_cardtype") ) { + my $job = FS::queue->new({ job => 'FS::payinfo_Mixin::process_set_cardtype' }); + my $error = $job->insert($table); + die $error if $error; + FS::upgrade_journal->set_done("${table}__set_cardtype"); + } +} + +sub process_set_cardtype { + my $table = shift; + + # assign cardtypes to CARD/DCRDs that need them; check_payinfo_cardtype + # will do this. ignore any problems with the cards. + local $ignore_masked_payinfo = 1; + my $search = FS::Cursor->new({ + table => $table, + extra_sql => q[ WHERE payby IN('CARD','DCRD') AND paycardtype IS NULL ], + }); + while (my $record = $search->fetch) { + my $error = $record->replace; + die $error if $error; + } +} + +=item tokenized [ PAYINFO ] + +Returns true if object payinfo is tokenized + +Optionally, an arbitrary payby and payinfo can be passed. + +=cut + +sub tokenized { + my $self = shift; + my $payinfo = scalar(@_) ? shift : $self->payinfo; + return 0 unless $payinfo; #avoid uninitialized value error + $payinfo =~ /^99\d{14}$/; +} + =back =head1 BUGS -Future items? - Encryption - In the Future (Pull from Record.pm) - Bad Card Stuff - In the Future (Integrate Banned Pay) - Currency - In the Future - =head1 SEE ALSO L, L