From 2101a32bdf12abdb2afdb654d6da30975ddd4fc9 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Wed, 17 Jul 2013 09:03:56 -0700 Subject: [PATCH] multiple payment options, RT#23741 --- FS/FS.pm | 2 + FS/FS/Schema.pm | 28 ++++ FS/FS/Upgrade.pm | 3 + FS/FS/cust_main.pm | 4 + FS/FS/cust_payby.pm | 414 ++++++++++++++++++++++++++++++++++++++++++++++++++++ FS/MANIFEST | 2 + FS/t/cust_payby.t | 5 + 7 files changed, 458 insertions(+) create mode 100644 FS/FS/cust_payby.pm create mode 100644 FS/t/cust_payby.t diff --git a/FS/FS.pm b/FS/FS.pm index 076f80b34..a318a2018 100644 --- a/FS/FS.pm +++ b/FS/FS.pm @@ -338,6 +338,8 @@ L - Customer real-time billing class L - Customer packages class +L - Customer payment information class + L - Customer location class L - Mixin class for records that contain fields from cust_main diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 6df45e2b1..2b7db26f3 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1084,6 +1084,8 @@ sub tables_hashref { 'ship_fax', 'varchar', 'NULL', 12, '', '', 'ship_mobile', 'varchar', 'NULL', 12, '', '', 'currency', 'char', 'NULL', 3, '', '', + + #deprecated, info moved to cust_payby 'payby', 'char', '', 4, '', '', 'payinfo', 'varchar', 'NULL', 512, '', '', 'paycvv', 'varchar', 'NULL', 512, '', '', @@ -1097,6 +1099,7 @@ sub tables_hashref { 'paystate', 'varchar', 'NULL', $char_d, '', '', 'paytype', 'varchar', 'NULL', $char_d, '', '', 'payip', 'varchar', 'NULL', 15, '', '', + 'geocode', 'varchar', 'NULL', 20, '', '', 'censustract', 'varchar', 'NULL', 20, '', '', # 7 to save space? 'censusyear', 'char', 'NULL', 4, '', '', @@ -1138,6 +1141,31 @@ sub tables_hashref { ], }, + 'cust_payby' => { + 'columns' => [ + 'custpaybynum', 'serial', '', '', '', '', + 'custnum', 'int', '', '', '', '', + 'weight', 'int', '', '', '', '', + 'payby', 'char', '', 4, '', '', + 'payinfo', 'varchar', 'NULL', 512, '', '', + 'paycvv', 'varchar', 'NULL', 512, '', '', + 'paymask', 'varchar', 'NULL', $char_d, '', '', + #'paydate', @date_type, '', '', + 'paydate', 'varchar', 'NULL', 10, '', '', + 'paystart_month', 'int', 'NULL', '', '', '', + 'paystart_year', 'int', 'NULL', '', '', '', + 'payissue', 'varchar', 'NULL', 2, '', '', + 'payname', 'varchar', 'NULL', 2*$char_d, '', '', + 'paystate', 'varchar', 'NULL', $char_d, '', '', + 'paytype', 'varchar', 'NULL', $char_d, '', '', + 'payip', 'varchar', 'NULL', 15, '', '', + 'locationnum', 'int', 'NULL', '', '', '', + ], + 'primary_key' => 'custpaybynum', + 'unique' => [], + 'index' => [ [ 'custnum' ] ], + }, + 'cust_recon' => { # (some sort of not-well understood thing for OnPac) 'columns' => [ 'reconid', 'serial', '', '', '', '', diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index cda3198eb..056b80b4c 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -175,6 +175,9 @@ sub upgrade { local($FS::cust_main::ignore_banned_card) = 1; local($FS::cust_main::skip_fuzzyfiles) = 1; + local($FS::cust_payby::ignore_expired_card) = 1; + local($FS::cust_payby::ignore_banned_card) = 1; + # decrypt inadvertantly-encrypted payinfo where payby != CARD,DCRD,CHEK,DCHK # kind of a weird spot for this, but it's better than duplicating # all this code in each class... diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index 7c7c9e2b5..30d6fa02a 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -1791,6 +1791,8 @@ sub check { } + ### start of stuff moved to cust_payby + #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/ # or return "Illegal payby: ". $self->payby; #$self->payby($1); @@ -2000,6 +2002,8 @@ sub check { $self->payname($1); } + ### end of stuff moved to cust_payby + return "Please select an invoicing locale" if ! $self->locale && ! $self->custnum diff --git a/FS/FS/cust_payby.pm b/FS/FS/cust_payby.pm new file mode 100644 index 000000000..7bf8c76dd --- /dev/null +++ b/FS/FS/cust_payby.pm @@ -0,0 +1,414 @@ +package FS::cust_payby; + +use strict; +use base qw( FS::payinfo_Mixin FS::Record ); +use FS::UID; +use FS::Record qw( qsearchs ); #qsearch; +use FS::payby; +use FS::cust_main; + +use vars qw( $conf $ignore_expired_card $ignore_banned_card ); + +$ignore_expired_card = 0; +$ignore_banned_card = 0; + +install_callback FS::UID sub { + $conf = new FS::Conf; + #yes, need it for stuff below (prolly should be cached) +}; + +=head1 NAME + +FS::cust_payby - Object methods for cust_payby records + +=head1 SYNOPSIS + + use FS::cust_payby; + + $record = new FS::cust_payby \%hash; + $record = new FS::cust_payby { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::cust_payby object represents customer stored payment information. +FS::cust_payby inherits from FS::Record. The following fields are currently +supported: + +=over 4 + +=item custpaybynum + +primary key + +=item custnum + +custnum + +=item weight + +weight + +=item payby + +payby + +=item payinfo + +payinfo + +=item paycvv + +paycvv + +=item paymask + +paymask + +=item paydate + +paydate + +=item paystart_month + +paystart_month + +=item paystart_year + +paystart_year + +=item payissue + +payissue + +=item payname + +payname + +=item paystate + +paystate + +=item paytype + +paytype + +=item payip + +payip + + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new record. To add the record to the database, see L<"insert">. + +Note that this stores the hash reference, not a distinct copy of the hash it +points to. You can ask the object for a copy with the I method. + +=cut + +# the new method can be inherited from FS::Record, if a table method is defined + +sub table { 'cust_payby'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=cut + +# the insert method can be inherited from FS::Record + +=item delete + +Delete this record from the database. + +=cut + +# the delete method can be inherited from FS::Record + +=item replace OLD_RECORD + +Replaces the OLD_RECORD with this one in the database. If there is an error, +returns the error, otherwise returns false. + +=cut + +# the replace method can be inherited from FS::Record + +=item check + +Checks all fields to make sure this is a valid record. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('custpaybynum') + || $self->ut_foreign_key('custnum', 'cust_main', 'custnum') + || $self->ut_number('weight') + || $self->ut_('payby') + || $self->ut_textn('payinfo') + || $self->ut_textn('paycvv') + || $self->ut_textn('paymask') + || $self->ut_textn('paydate') + || $self->ut_numbern('paystart_month') + || $self->ut_numbern('paystart_year') + || $self->ut_textn('payissue') + || $self->ut_textn('payname') + || $self->ut_textn('paystate') + || $self->ut_textn('paytype') + || $self->ut_textn('payip') + ; + return $error if $error; + + + ### from cust_main + + + #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/ + # or return "Illegal payby: ". $self->payby; + #$self->payby($1); + FS::payby->can_payby($self->table, $self->payby) + or return "Illegal payby: ". $self->payby; + + $error = $self->ut_numbern('paystart_month') + || $self->ut_numbern('paystart_year') + || $self->ut_numbern('payissue') + || $self->ut_textn('paytype') + ; + return $error if $error; + + if ( $self->payip eq '' ) { + $self->payip(''); + } else { + $error = $self->ut_ip('payip'); + return $error if $error; + } + + # If it is encrypted and the private key is not availaible then we can't + # check the credit card. + my $check_payinfo = ! $self->is_encrypted($self->payinfo); + + # Need some kind of global flag to accept invalid cards, for testing + # on scrubbed data. + #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) { + if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) { + + my $payinfo = $self->payinfo; + $payinfo =~ s/\D//g; + $payinfo =~ /^(\d{13,16}|\d{8,9})$/ + or return gettext('invalid_card'); # . ": ". $self->payinfo; + $payinfo = $1; + $self->payinfo($payinfo); + validate($payinfo) + or return gettext('invalid_card'); # . ": ". $self->payinfo; + + return gettext('unknown_card_type') + if $self->payinfo !~ /^99\d{14}$/ #token + && cardtype($self->payinfo) eq "Unknown"; + + unless ( $ignore_banned_card ) { + my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } ); + if ( $ban ) { + if ( $ban->bantype eq 'warn' ) { + #or others depending on value of $ban->reason ? + return '_duplicate_card'. + ': disabled from'. time2str('%a %h %o at %r', $ban->_date). + ' until '. time2str('%a %h %o at %r', $ban->_end_date). + ' (ban# '. $ban->bannum. ')' + unless $self->override_ban_warn; + } else { + return 'Banned credit card: banned on '. + time2str('%a %h %o at %r', $ban->_date). + ' by '. $ban->otaker. + ' (ban# '. $ban->bannum. ')'; + } + } + } + + if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) { + if ( cardtype($self->payinfo) eq 'American Express card' ) { + $self->paycvv =~ /^(\d{4})$/ + or return "CVV2 (CID) for American Express cards is four digits."; + $self->paycvv($1); + } else { + $self->paycvv =~ /^(\d{3})$/ + or return "CVV2 (CVC2/CID) is three digits."; + $self->paycvv($1); + } + } else { + $self->paycvv(''); + } + + my $cardtype = cardtype($payinfo); + if ( $cardtype =~ /^(Switch|Solo)$/i ) { + + return "Start date or issue number is required for $cardtype cards" + unless $self->paystart_month && $self->paystart_year or $self->payissue; + + return "Start month must be between 1 and 12" + if $self->paystart_month + and $self->paystart_month < 1 || $self->paystart_month > 12; + + return "Start year must be 1990 or later" + if $self->paystart_year + and $self->paystart_year < 1990; + + return "Issue number must be beween 1 and 99" + if $self->payissue + and $self->payissue < 1 || $self->payissue > 99; + + } else { + $self->paystart_month(''); + $self->paystart_year(''); + $self->payissue(''); + } + + } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) { + + my $payinfo = $self->payinfo; + $payinfo =~ s/[^\d\@\.]//g; + if ( $conf->config('echeck-country') eq 'CA' ) { + $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/ + or return 'invalid echeck account@branch.bank'; + $payinfo = "$1\@$2.$3"; + } elsif ( $conf->config('echeck-country') eq 'US' ) { + $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba'; + $payinfo = "$1\@$2"; + } else { + $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing'; + $payinfo = "$1\@$2"; + } + $self->payinfo($payinfo); + $self->paycvv(''); + + unless ( $ignore_banned_card ) { + my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } ); + if ( $ban ) { + if ( $ban->bantype eq 'warn' ) { + #or others depending on value of $ban->reason ? + return '_duplicate_ach' unless $self->override_ban_warn; + } else { + return 'Banned ACH account: banned on '. + time2str('%a %h %o at %r', $ban->_date). + ' by '. $ban->otaker. + ' (ban# '. $ban->bannum. ')'; + } + } + } + + } elsif ( $self->payby eq 'LECB' ) { + + my $payinfo = $self->payinfo; + $payinfo =~ s/\D//g; + $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number'; + $payinfo = $1; + $self->payinfo($payinfo); + $self->paycvv(''); + + } elsif ( $self->payby eq 'BILL' ) { + + $error = $self->ut_textn('payinfo'); + return "Illegal P.O. number: ". $self->payinfo if $error; + $self->paycvv(''); + + } elsif ( $self->payby eq 'COMP' ) { + + my $curuser = $FS::CurrentUser::CurrentUser; + if ( ! $self->custnum + && ! $curuser->access_right('Complimentary customer') + ) + { + return "You are not permitted to create complimentary accounts." + } + + $error = $self->ut_textn('payinfo'); + return "Illegal comp account issuer: ". $self->payinfo if $error; + $self->paycvv(''); + + } elsif ( $self->payby eq 'PREPAY' ) { + + my $payinfo = $self->payinfo; + $payinfo =~ s/\W//g; #anything else would just confuse things + $self->payinfo($payinfo); + $error = $self->ut_alpha('payinfo'); + return "Illegal prepayment identifier: ". $self->payinfo if $error; + return "Unknown prepayment identifier" + unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } ); + $self->paycvv(''); + + } + + if ( $self->paydate eq '' || $self->paydate eq '-' ) { + return "Expiration date required" + # shouldn't payinfo_check do this? + unless $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD|PPAL)$/; + $self->paydate(''); + } else { + my( $m, $y ); + if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) { + ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" ); + } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) { + ( $m, $y ) = ( $2, "19$1" ); + } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) { + ( $m, $y ) = ( $3, "20$2" ); + } else { + return "Illegal expiration date: ". $self->paydate; + } + $m = sprintf('%02d',$m); + $self->paydate("$y-$m-01"); + my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900; + return gettext('expired_card') + if #XXX !$import + #&& + !$ignore_expired_card + && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) ); + } + + if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ && + ( ! $conf->exists('require_cardname') + || $self->payby !~ /^(CARD|DCRD)$/ ) + ) { + $self->payname( $self->first. " ". $self->getfield('last') ); + } else { + $self->payname =~ /^([\w \,\.\-\'\&]+)$/ + or return gettext('illegal_name'). " payname: ". $self->payname; + $self->payname($1); + } + + ### + + $self->SUPER::check; +} + +=back + +=head1 BUGS + +=head1 SEE ALSO + +L, schema.html from the base documentation. + +=cut + +1; + diff --git a/FS/MANIFEST b/FS/MANIFEST index a86683d6b..7e61868a9 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -705,3 +705,5 @@ FS/currency_exchange.pm t/currency_exchange.t FS/part_pkg_currency.pm t/part_pkg_currency.t +FS/cust_payby.pm +t/cust_payby.t diff --git a/FS/t/cust_payby.t b/FS/t/cust_payby.t new file mode 100644 index 000000000..b5f7f514d --- /dev/null +++ b/FS/t/cust_payby.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::cust_payby; +$loaded=1; +print "ok 1\n"; -- 2.11.0