From 5d089cbe4980f7c9c25b83e164099b22bc59eead Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Fri, 29 Jan 2016 18:26:43 -0800 Subject: [PATCH] Use any card on file when making a payment, RT#23741 --- FS/FS/cust_main.pm | 82 +++++++--- FS/FS/cust_payby.pm | 33 ++++ httemplate/elements/select-cust_payby.html | 23 +++ httemplate/elements/select-table.html | 6 +- httemplate/elements/tr-select-cust_payby.html | 31 ++++ httemplate/misc/payment.cgi | 100 ++++++++---- httemplate/misc/process/payment.cgi | 212 +++++++++++++------------- 7 files changed, 327 insertions(+), 160 deletions(-) create mode 100644 httemplate/elements/select-cust_payby.html create mode 100644 httemplate/elements/tr-select-cust_payby.html diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index 9e0db2903..8e0984882 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -4435,8 +4435,10 @@ sub payment_history { Saves a new cust_payby for this customer, replacing an existing entry only in select circumstances. Does not validate input. -If auto is specified, marks this as the customer's primary method (weight 1) -and changes existing primary methods for that payby to secondary methods (weight 2.) +If auto is specified, marks this as the customer's primary method, or the +specified weight. Existing payment methods have their weight incremented as +appropriate. + If bill_location is specified with auto, also sets location in cust_main. Will not insert complete duplicates of existing records, or records in which the @@ -4448,39 +4450,77 @@ blanks when replacing. Accepts the following named parameters: -payment_payby - either CARD or CHEK +=over 4 + +=item payment_payby + +either CARD or CHEK + +=item auto + +save as an automatic payment type (CARD/CHEK if true, DCRD/DCHK if false) + +=item weight + +optional, set higher than 1 for secondary, etc. + +=item payinfo + +required + +=item paymask + +optional, but should be specified for anything that might be tokenized, will be preserved when replacing + +=item payname + +required + +=item payip + +optional, will be preserved when replacing + +=item paydate + +CARD only, required + +=item bill_location -auto - save as an automatic payment type (CARD/CHEK if true, DCRD/DCHK if false) +CARD only, required, FS::cust_location object -payinfo - required +=item paystart_month + +CARD only, optional, will be preserved when replacing -paymask - optional, but should be specified for anything that might be tokenized, will be preserved when replacing +=item paystart_year -payname - required +CARD only, optional, will be preserved when replacing -payip - optional, will be preserved when replacing +=item payissue -paydate - CARD only, required +CARD only, optional, will be preserved when replacing -bill_location - CARD only, required, FS::cust_location object +=item paycvv -paystart_month - CARD only, optional, will be preserved when replacing +CARD only, only used if conf cvv-save is set appropriately -paystart_year - CARD only, optional, will be preserved when replacing +=item paytype -payissue - CARD only, optional, will be preserved when replacing +CHEK only -paycvv - CARD only, only used if conf cvv-save is set appropriately +=item paystate -paytype - CHEK only +CHEK only -paystate - CHEK only +=back =cut #The code for this option is in place, but it's not currently used # -# replace - existing cust_payby object to be replaced (must match custnum) +# =item replace +# +# existing cust_payby object to be replaced (must match custnum) # stateid/stateid_state/ss are not currently supported in cust_payby, # might not even work properly in 4.x, but will need to work here if ever added @@ -4511,8 +4551,7 @@ sub save_cust_payby { @check_existing = qw( CHEK DCHK ); } - # every automatic payment type added here will be marked primary - $new->set( 'weight' => $opt{'auto'} ? 1 : '' ); + $new->set( 'weight' => $opt{'auto'} ? $opt{'weight'} : '' ); # basic fields $new->payinfo($opt{'payinfo'}); # sets default paymask, but not if it's already tokenized @@ -4606,7 +4645,7 @@ PAYBYLOOP: # if we got this far, we're definitely replacing $old = $cust_payby; last PAYBYLOOP; - } + } #PAYBYLOOP } if ($old) { @@ -4649,7 +4688,8 @@ PAYBYLOOP: last unless $cust_payby->payby !~ /^D/; last if $cust_payby->weight > 1; next if $new->custpaybynum eq $cust_payby->custpaybynum; - $cust_payby->set( 'weight' => 2 ); + next if $cust_payby->weight < ($opt{'weight'} || 1); + $cust_payby->weight( $cust_payby->weight + 1 ); my $error = $cust_payby->replace; if ( $error ) { $dbh->rollback if $oldAutoCommit; diff --git a/FS/FS/cust_payby.pm b/FS/FS/cust_payby.pm index 9111fdff1..a68624229 100644 --- a/FS/FS/cust_payby.pm +++ b/FS/FS/cust_payby.pm @@ -560,6 +560,39 @@ sub paydate_mon_year { } +=item label + +Returns a one line text label for this payment type. + +=cut + +my %weight = ( + 1 => 'Primary', + 2 => 'Secondary', + 3 => 'Tertiary', + 4 => 'Fourth', + 5 => 'Fifth', + 6 => 'Sixth', + 7 => 'Seventh', +); + +sub label { + my $self = shift; + + my $name = $self->payby =~ /^(CARD|DCRD)$/ + && cardtype($self->paymask) || FS::payby->shortname($self->payby); + + ( $self->payby =~ /^(CARD|CHEK)$/ ? $weight{$self->weight}. ' automatic ' + : 'Manual ' + ). + "$name: ". $self->paymask. + ( $self->payby =~ /^(CARD|DCRD)$/ + ? ' Exp '. join('/', $self->paydate_mon_year) + : '' + ); + +} + =item realtime_bop =cut diff --git a/httemplate/elements/select-cust_payby.html b/httemplate/elements/select-cust_payby.html new file mode 100644 index 000000000..a726cb398 --- /dev/null +++ b/httemplate/elements/select-cust_payby.html @@ -0,0 +1,23 @@ +<% include( '/elements/select-table.html', + 'table' => 'cust_payby', + 'name_col' => 'label', + 'value' => $custpaybynum, + 'disable_empty' => 1, + 'post_options' => [ '0' => 'Enter new payment information' ], + 'hashref' => { 'custnum' => $opt{'custnum'}, + 'disabled' => '', + }, + %opt, + ) +%> +<%init> + +my %opt = @_; +my $custpaybynum = $opt{'curr_value'} || $opt{'value'}; + +if ( $opt{'cust_payby'} ) { + $opt{'records'} = delete $opt{'cust_payby'}; + $opt{'presorted'} = 1 if ! exists($opt{'presorted'}); +} + + diff --git a/httemplate/elements/select-table.html b/httemplate/elements/select-table.html index 0b04fee6e..4b6ddb40e 100644 --- a/httemplate/elements/select-table.html +++ b/httemplate/elements/select-table.html @@ -28,6 +28,7 @@ Example: 'agent_null_right' => '', #right to see un-agented entries #or 'records' => \@records, #instead of search params + 'presorted' => 0, #set true to disable sorting the records on name_col #instead of the primary key... only for special cases 'value_col' => 'columnname', @@ -176,8 +177,9 @@ if ( $opt{'agent_virt'} ) { my @records = (); if ( $opt{'records'} ) { - @records = sort { $a->get($name_col) cmp $b->get($name_col) } - @{ $opt{'records'} }; + @records = @{ $opt{'records'} }; + @records = sort { $a->get($name_col) cmp $b->get($name_col) } @records + unless $opt{'presorted'}; } else { @records = qsearch( { 'table' => $opt{'table'}, diff --git a/httemplate/elements/tr-select-cust_payby.html b/httemplate/elements/tr-select-cust_payby.html new file mode 100644 index 000000000..e2b2e09d1 --- /dev/null +++ b/httemplate/elements/tr-select-cust_payby.html @@ -0,0 +1,31 @@ +% if ( scalar(@{ $opt{'cust_payby'} }) == 0 ) { + + + +% } else { + + + <% $opt{'label'} || 'Payment via' %> + + <% include( '/elements/select-cust_payby.html', + 'curr_value' => $custpaybynum, + %opt + ) + %> + + + +% } + +<%init> + +my %opt = @_; +my $custpaybynum = $opt{'curr_value'} || $opt{'value'}; + +$opt{'cust_payby'} ||= [ qsearch( 'cust_payby', { custnum => $opt{custnum}, + disabled => '', + } + ) + ]; + + diff --git a/httemplate/misc/payment.cgi b/httemplate/misc/payment.cgi index 7afdfd159..02648a821 100644 --- a/httemplate/misc/payment.cgi +++ b/httemplate/misc/payment.cgi @@ -33,23 +33,57 @@ &> % } + + +% #can't quite handle CARD/CHEK on the same page yet, but very close +% #does it make sense from a UI/usability perspective? +% +% my @cust_payby = (); +% if ( $payby eq 'CARD' ) { +% @cust_payby = $cust_main->cust_payby('CARD','DCRD'); +% } elsif ( $payby eq 'CHEK' ) { +% @cust_payby = $cust_main->cust_payby('CHEK','DCHK'); +% } else { +% die "unknown payby $payby"; +% } +% +% my $custpaybynum = length(scalar($cgi->param('custpaybynum'))) +% ? scalar($cgi->param('custpaybynum')) +% : scalar(@cust_payby) && $cust_payby[0]->custpaybynum; + +<& /elements/tr-select-cust_payby.html, + 'cust_payby' => \@cust_payby, + 'curr_value' => $custpaybynum, + 'onchange' => 'cust_payby_changed(this)', +&> + + +
+
+> + + % my $auto = 0; % if ( $payby eq 'CARD' ) { % % my( $payinfo, $paycvv, $month, $year ) = ( '', '', '', '' ); % my $payname = $cust_main->first. ' '. $cust_main->getfield('last'); % my $location = $cust_main->bill_location; -% -% #auto-fill with the highest weighted match -% my ($cust_payby) = $cust_main->cust_payby('CARD','DCRD'); -% if ($cust_payby) { -% $payinfo = $cust_payby->paymask; -% $paycvv = $cust_payby->paycvv; -% ( $month, $year ) = $cust_payby->paydate_monthyear; -% $payname = $cust_payby->payname if $cust_payby->payname; -% $location = $cust_payby->cust_location || $location; -% $auto = 1 if $cust_payby->payby eq 'CARD'; -% } @@ -104,22 +138,6 @@ % my( $account, $aba, $branch, $payname, $ss, $paytype, $paystate, % $stateid, $stateid_state ) % = ( '', '', '', '', '', '', '', '', '' ); -% my ($cust_payby) = $cust_main->cust_payby('CHEK','DCHK'); -% if ($cust_payby) { -% $cust_payby->paymask =~ /^([\dx]+)\@([\d\.x]*)$/i -% or die "unparsable paymask ". $cust_payby->paymask; -% ($account, $aba) = ($1, $2); -% ($branch,$aba) = split('\.',$aba) -% if $conf->config('echeck-country') eq 'CA'; -% $payname = $cust_payby->payname; -% $paytype = $cust_payby->getfield('paytype'); -% $paystate = $cust_payby->getfield('paystate'); -% $auto = 1 if $cust_payby->payby eq 'CHEK'; -% # these values aren't in cust_payby, but maybe should be... -% $ss = $cust_main->ss; -% $stateid = $cust_main->getfield('stateid'); -% $stateid_state = $cust_main->getfield('stateid_state'); -% } % % #false laziness w/{edit,view}/cust_main/billing.html % my $routing_label = $conf->config('echeck-country') eq 'US' @@ -210,7 +228,7 @@ - @@ -237,19 +255,43 @@ % } -
<% mt('Card number') |h %>
+ <% mt('Remember this information') |h %>
+ NAME="auto" VALUE="1" onClick="if (this.checked) { document.OneTrueForm.save.checked=true; }"> <% mt("Charge future payments to this [_1] automatically",$type{$payby}) |h %> +% if ( @cust_payby ) { + <% mt('as') |h %> + +% } else { + +% }
+

<& /elements/footer.html &> +<%once> + +my %weight = ( + 1 => 'Primary', + 2 => 'Secondary', + 3 => 'Tertiary', + 4 => 'Fourth', + 5 => 'Fifth', + 6 => 'Sixth', + 7 => 'Seventh', +); + + <%init> die "access denied" diff --git a/httemplate/misc/process/payment.cgi b/httemplate/misc/process/payment.cgi index a6046a0dc..79b43b715 100644 --- a/httemplate/misc/process/payment.cgi +++ b/httemplate/misc/process/payment.cgi @@ -23,14 +23,21 @@ die "access denied" unless $curuser->access_right('Process payment'); my $conf = new FS::Conf; +## +# info for all payments, stored or unstored +## + #some false laziness w/MyAccount::process_payment $cgi->param('custnum') =~ /^(\d+)$/ or die "illegal custnum ". $cgi->param('custnum'); my $custnum = $1; -my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ); -die "unknown custnum $custnum" unless $cust_main; +my $cust_main = qsearchs({ + 'table' => 'cust_main', + 'hashref' => { 'custnum' => $custnum }, + 'extra_sql' => ' AND '. $curuser->agentnums_sql, +}) or die "unknown custnum $custnum"; $cgi->param('amount') =~ /^\s*(\d*(\.\d\d)?)\s*$/ or errorpage("illegal amount ". $cgi->param('amount')); @@ -42,14 +49,6 @@ if ( $cgi->param('fee') =~ /^\s*(\d*(\.\d\d)?)\s*$/ ) { $amount = sprintf('%.2f', $amount + $fee); } -$cgi->param('year') =~ /^(\d+)$/ - or errorpage("illegal year ". $cgi->param('year')); -my $year = $1; - -$cgi->param('month') =~ /^(\d+)$/ - or errorpage("illegal month ". $cgi->param('month')); -my $month = $1; - $cgi->param('payby') =~ /^(CARD|CHEK)$/ or errorpage("illegal payby ". $cgi->param('payby')); my $payby = $1; @@ -61,10 +60,6 @@ my %type = ( 'CARD' => 'credit card', 'CHEK' => 'electronic check (ACH)', ); -$cgi->param('payname') =~ /^([\w \,\.\-\']+)$/ - or errorpage(gettext('illegal_name'). " payname: ". $cgi->param('payname')); -my $payname = $1; - $cgi->param('payunique') =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=]*)$/ or errorpage(gettext('illegal_text'). " payunique: ". $cgi->param('payunique')); my $payunique = $1; @@ -73,33 +68,48 @@ $cgi->param('balance') =~ /^\s*(\-?\s*\d*(\.\d\d)?)\s*$/ or errorpage("illegal balance"); my $balance = $1; -my $payinfo; -my $paymask; # override only used by loaded cust payinfo, only implemented for realtime processing -my $paycvv = ''; -my $loaded_cust_payby; -if ( $payby eq 'CHEK' ) { - - if ($cgi->param('payinfo1') =~ /xx/i || $cgi->param('payinfo2') =~ /xx/i ) { - - my $search_paymask = $cgi->param('payinfo1') . '@' . $cgi->param('payinfo2'); - $search_paymask .= '.' . $cgi->param('payinfo3') - if $conf->config('echeck-country') eq 'CA'; - - #paymask might not be saved in database, need to run paymask method for any potential match - foreach my $search_cust_payby ($cust_main->cust_payby('CHEK','DCHK')) { - if ($search_paymask eq $search_cust_payby->paymask) { - # if there are multiple matches, assume for now that it's the first one returned, - # since that's what auto-fills; it's unlikely a masked number would be entered by hand, - # but it's very likely users will just click-through what's been auto-filled - $loaded_cust_payby = $search_cust_payby; - last; - } - } - errorpage("Masked payinfo not found") unless $loaded_cust_payby; - $payinfo = $loaded_cust_payby->payinfo; - $paymask = $loaded_cust_payby->paymask; +$cgi->param('discount_term') =~ /^(\d*)$/ + or errorpage("illegal discount_term"); +my $discount_term = $1; + +my( $payinfo, $paycvv, $month, $year, $payname ); +my $paymask = ''; +if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) { + + ## + # use stored cust_payby info + ## + + my $cust_payby = qsearchs('cust_payby', { custnum => $custnum, + custpaybynum => $custpaybynum, } ) + or die "unknown custpaybynum $custpaybynum"; + + $payinfo = $cust_payby->payinfo; + $paymask = $cust_payby->paymask; + $paycvv = ''; + ( $month, $year ) = $cust_payby->paydate_mon_year; + $payname = $cust_payby->payname; + +} else { + + ## + # use new info + ## + + $cgi->param('year') =~ /^(\d+)$/ + or errorpage("illegal year ". $cgi->param('year')); + $year = $1; + + $cgi->param('month') =~ /^(\d+)$/ + or errorpage("illegal month ". $cgi->param('month')); + $month = $1; + + $cgi->param('payname') =~ /^([\w \,\.\-\']+)$/ + or errorpage(gettext('illegal_name'). " payname: ". $cgi->param('payname')); + $payname = $1; + + if ( $payby eq 'CHEK' ) { - } else { $cgi->param('payinfo1') =~ /^(\d+)$/ or errorpage("Illegal account number ". $cgi->param('payinfo1')); my $payinfo1 = $1; @@ -112,47 +122,30 @@ if ( $payby eq 'CHEK' ) { $payinfo2 = "$1.$payinfo2"; } $payinfo = $payinfo1. '@'. $payinfo2; - } -} elsif ( $payby eq 'CARD' ) { + } elsif ( $payby eq 'CARD' ) { - $payinfo = $cgi->param('payinfo'); - if ($payinfo =~ /xx/i) { + $payinfo = $cgi->param('payinfo'); - #paymask might not be saved in database, need to run paymask method for any potential match - foreach my $search_cust_payby ($cust_main->cust_payby('CARD','DCRD')) { - if ($payinfo eq $search_cust_payby->paymask) { - $loaded_cust_payby = $search_cust_payby; - last; - } - } - - errorpage("Masked payinfo not found") unless $loaded_cust_payby; - $payinfo = $loaded_cust_payby->payinfo; - $paymask = $loaded_cust_payby->paymask; - - } - - $payinfo =~ s/\D//g; - $payinfo =~ /^(\d{13,16}|\d{8,9})$/ - or errorpage(gettext('invalid_card')); - $payinfo = $1; - validate($payinfo) - or errorpage(gettext('invalid_card')); + $payinfo =~ s/\D//g; + $payinfo =~ /^(\d{13,16}|\d{8,9})$/ + or errorpage(gettext('invalid_card')); + $payinfo = $1; + validate($payinfo) + or errorpage(gettext('invalid_card')); - unless ( $payinfo =~ /^99\d{14}$/ ) { #token + unless ( $payinfo =~ /^99\d{14}$/ ) { #token - my $cardtype = cardtype($payinfo); + my $cardtype = cardtype($payinfo); - errorpage(gettext('unknown_card_type')) - if $cardtype eq "Unknown"; + errorpage(gettext('unknown_card_type')) + if $cardtype eq "Unknown"; - my %bop_card_types = map { $_=>1 } values %{ card_types() }; - errorpage("$cardtype not accepted") unless $bop_card_types{$cardtype}; + my %bop_card_types = map { $_=>1 } values %{ card_types() }; + errorpage("$cardtype not accepted") unless $bop_card_types{$cardtype}; - } + } - if ( defined $cust_main->dbdef_table->column('paycvv') ) { #is this test necessary anymore? if ( length($cgi->param('paycvv') ) ) { if ( cardtype($payinfo) eq 'American Express card' ) { $cgi->param('paycvv') =~ /^(\d{4})$/ @@ -163,48 +156,49 @@ if ( $payby eq 'CHEK' ) { or errorpage("CVV2 (CVC2/CID) is three digits."); $paycvv = $1; } - }elsif( $conf->exists('backoffice-require_cvv') ){ + } elsif ( $conf->exists('backoffice-require_cvv') ){ errorpage("CVV2 is required"); } - } -} else { - die "unknown payby $payby"; -} - -$cgi->param('discount_term') =~ /^(\d*)$/ - or errorpage("illegal discount_term"); -my $discount_term = $1; - -# save first, for proper tokenization later -if ( $cgi->param('save') ) { - - my %saveopt; - if ( $payby eq 'CARD' ) { - my $bill_location = FS::cust_location->new; - $bill_location->set( $_ => $cgi->param($_) ) - foreach @{$payby2fields{$payby}}; - $saveopt{'bill_location'} = $bill_location; - $saveopt{'paycvv'} = $paycvv; # save_cust_payby contains conf logic for when to use this - $saveopt{'paydate'} = "$year-$month-01"; } else { - # ss/stateid/stateid_state won't be saved, but should be harmless to pass - %saveopt = map { $_ => scalar($cgi->param($_)) } @{$payby2fields{$payby}}; + die "unknown payby $payby"; } - my $error = $cust_main->save_cust_payby( - 'payment_payby' => $payby, - 'auto' => scalar($cgi->param('auto')), - 'payinfo' => $payinfo, - 'paymask' => $paymask, - 'payname' => $payname, - %saveopt - ); + # save first, for proper tokenization later + if ( $cgi->param('save') ) { + + my %saveopt; + if ( $payby eq 'CARD' ) { + my $bill_location = FS::cust_location->new; + $bill_location->set( $_ => $cgi->param($_) ) + foreach @{$payby2fields{$payby}}; + $saveopt{'bill_location'} = $bill_location; + $saveopt{'paycvv'} = $paycvv; # save_cust_payby contains conf logic for when to use this + $saveopt{'paydate'} = "$year-$month-01"; + } else { + # ss/stateid/stateid_state won't be saved, but should be harmless to pass + %saveopt = map { $_ => scalar($cgi->param($_)) } @{$payby2fields{$payby}}; + } + + my $error = $cust_main->save_cust_payby( + 'payment_payby' => $payby, + 'auto' => scalar($cgi->param('auto')), + 'weight' => scalar($cgi->param('weight')), + 'payinfo' => $payinfo, + 'payname' => $payname, + %saveopt + ); + + errorpage("error saving info, payment not processed: $error") + if $error; + } - errorpage("error saving info, payment not processed: $error") - if $error; } +## +# now run the payment +## + my $error = ''; my $paynum = ''; if ( $cgi->param('batch') ) { @@ -218,7 +212,7 @@ if ( $cgi->param('batch') ) { 'payinfo' => $payinfo, 'paydate' => "$year-$month-01", 'payname' => $payname, - map { $_ => $cgi->param($_) } + map { $_ => scalar($cgi->param($_)) } @{$payby2fields{$payby}} ); errorpage($error) if $error; @@ -237,7 +231,7 @@ if ( $cgi->param('batch') ) { 'paycvv' => $paycvv, 'paynum_ref' => \$paynum, 'discount_term' => $discount_term, - map { $_ => $cgi->param($_) } @{$payby2fields{$payby}} + map { $_ => scalar($cgi->param($_)) } @{$payby2fields{$payby}} ); errorpage($error) if $error; @@ -261,6 +255,8 @@ if ( $cgi->param('batch') ) { } -#success! +## +# success! step 3: profit! +## -- 2.11.0