summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJonathan Prykop <jonathan@freeside.biz>2016-12-05 15:45:57 -0600
committerJonathan Prykop <jonathan@freeside.biz>2016-12-05 15:45:57 -0600
commite208512d4fef58a7dadf6a46e10bed24f5d777cb (patch)
tree46ed02b4e59e97ba14be5d6a5800163126a77058
parent34e42c0d926bf569ceaf8da784c2569bc7f83cec (diff)
71513: Card tokenization [v3 backport]
-rwxr-xr-xFS-Test/bin/freeside-test-stop2
-rw-r--r--FS/FS/ClientAPI/MyAccount.pm22
-rw-r--r--FS/FS/ClientAPI/Signup.pm71
-rw-r--r--FS/FS/Conf.pm4
-rw-r--r--FS/FS/Cron/cleanup.pm16
-rwxr-xr-xFS/FS/Cron/tax_rate_update.pm2
-rw-r--r--FS/FS/Upgrade.pm10
-rw-r--r--FS/FS/agent.pm147
-rw-r--r--FS/FS/agent_payment_gateway.pm15
-rw-r--r--FS/FS/cust_main.pm46
-rw-r--r--FS/FS/cust_main/Billing_Realtime.pm737
-rw-r--r--FS/FS/cust_pay.pm3
-rw-r--r--FS/FS/cust_refund.pm3
-rw-r--r--FS/FS/log_context.pm5
-rw-r--r--FS/FS/payinfo_Mixin.pm28
-rw-r--r--FS/FS/payment_gateway.pm113
-rwxr-xr-xFS/bin/freeside-daily2
-rw-r--r--FS/t/suite/13-tokenization.t224
-rwxr-xr-xhttemplate/browse/agent.cgi23
-rw-r--r--httemplate/edit/agent_payment_gateway.html28
-rw-r--r--httemplate/edit/elements/edit.html2
-rw-r--r--httemplate/edit/payment_gateway.html11
-rw-r--r--httemplate/edit/process/agent_payment_gateway.html13
-rw-r--r--httemplate/misc/process/payment.cgi7
24 files changed, 1190 insertions, 344 deletions
diff --git a/FS-Test/bin/freeside-test-stop b/FS-Test/bin/freeside-test-stop
index 5e221a8..ad355c3 100755
--- a/FS-Test/bin/freeside-test-stop
+++ b/FS-Test/bin/freeside-test-stop
@@ -22,7 +22,7 @@ if (sudo grep -q '^test:' /usr/local/etc/freeside/htpasswd); then
oldhtpasswd=$( cd /usr/local/etc/freeside; \
ls |grep -P 'htpasswd_\d{8}' | \
sort -nr |head -1 )
- if [ -f $oldhtpasswd ]; then
+ if [ -f /usr/local/etc/freeside/$oldhtpasswd ]; then
echo "Renaming $oldhtpasswd to htpasswd."
sudo mv /usr/local/etc/freeside/$oldhtpasswd \
/usr/local/etc/freeside/htpasswd
diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm
index 3d01c0d..386a063 100644
--- a/FS/FS/ClientAPI/MyAccount.pm
+++ b/FS/FS/ClientAPI/MyAccount.pm
@@ -373,20 +373,12 @@ sub payment_gateway {
my $conf = new FS::Conf;
my $cust_main = shift;
my $cust_payby = shift;
- my $gatewaynum = $conf->config('selfservice-payment_gateway');
- if ( $gatewaynum ) {
- my $pg = qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
- die "configured gatewaynum $gatewaynum not found!" if !$pg;
- return $pg;
- }
- else {
- return '' if ! FS::payby->realtime($cust_payby);
- my $pg = $cust_main->agent->payment_gateway(
- 'method' => FS::payby->payby2bop($cust_payby),
- 'nofatal' => 1
- );
- return $pg;
- }
+ return '' if ! FS::payby->realtime($cust_payby);
+ my $pg = $cust_main->agent->payment_gateway(
+ 'method' => FS::payby->payby2bop($cust_payby),
+ 'nofatal' => 1
+ );
+ return $pg;
}
sub access_info {
@@ -1030,7 +1022,7 @@ sub validate_payment {
validate($payinfo)
or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
return { 'error' => gettext('unknown_card_type') }
- if $payinfo !~ /^99\d{14}$/ && cardtype($payinfo) eq "Unknown";
+ if !$cust_main->tokenized($payinfo) && cardtype($payinfo) eq "Unknown";
if ( length($p->{'paycvv'}) && $p->{'paycvv'} !~ /^\s*$/ ) {
if ( cardtype($payinfo) eq 'American Express card' ) {
diff --git a/FS/FS/ClientAPI/Signup.pm b/FS/FS/ClientAPI/Signup.pm
index 0a9813f..08da337 100644
--- a/FS/FS/ClientAPI/Signup.pm
+++ b/FS/FS/ClientAPI/Signup.pm
@@ -346,20 +346,11 @@ sub signup_info {
my @paybys = @{ $signup_info->{'payby'} };
$signup_info->{'hide_payment_fields'} = [];
- my $gatewaynum = $conf->config('selfservice-payment_gateway');
- my $force_gateway;
- if ( $gatewaynum ) {
- $force_gateway = qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
- warn "using forced gateway #$gatewaynum - " .
- $force_gateway->gateway_username . '@' . $force_gateway->gateway_module
- if $DEBUG > 1;
- die "configured gatewaynum $gatewaynum not found!" if !$force_gateway;
- }
foreach my $payby (@paybys) {
warn "$me checking $payby payment fields\n" if $DEBUG > 1;
my $hide = 0;
if ( FS::payby->realtime($payby) ) {
- my $gateway = $force_gateway ||
+ my $gateway =
$agent->payment_gateway( 'method' => FS::payby->payby2bop($payby),
'nofatal' => 1,
);
@@ -622,17 +613,9 @@ sub new_customer {
return { 'error' => "Unknown reseller" }
unless $agent;
- my $gw;
- my $gatewaynum = $conf->config('selfservice-payment_gateway');
- if ( $gatewaynum ) {
- $gw = qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
- die "configured gatewaynum $gatewaynum not found!" if !$gw;
- }
- else {
- $gw = $agent->payment_gateway( 'method' => FS::payby->payby2bop($payby),
- 'nofatal' => 1,
+ my $gw = $agent->payment_gateway( 'method' => FS::payby->payby2bop($payby),
+ 'nofatal' => 1,
);
- }
$cust_main->payby('BILL') # MCRD better? no, that's for something else
if $gw && $gw->gateway_namespace eq 'Business::OnlineThirdPartyPayment';
@@ -1124,36 +1107,28 @@ sub capture_payment {
my $conf = new FS::Conf;
- my $payment_gateway;
- if ( my $gwnum = $conf->config('selfservice-payment_gateway') ) {
- $payment_gateway = qsearchs('payment_gateway', { 'gatewaynum' => $gwnum })
- or die "configured gatewaynum $gwnum not found!";
- }
- else {
- my $url = $packet->{url};
-
- $payment_gateway = qsearchs('payment_gateway',
+ my $url = $packet->{url};
+ my $payment_gateway = qsearchs('payment_gateway',
{ 'gateway_callback_url' => popurl(0, $url) }
);
- if (!$payment_gateway) {
-
- my ( $processor, $login, $password, $action, @bop_options ) =
- $conf->config('business-onlinepayment');
- $action ||= 'normal authorization';
- pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
- die "No real-time processor is enabled - ".
- "did you set the business-onlinepayment configuration value?\n"
- unless $processor;
-
- $payment_gateway = new FS::payment_gateway( {
- gateway_namespace => $conf->config('business-onlinepayment-namespace'),
- gateway_module => $processor,
- gateway_username => $login,
- gateway_password => $password,
- gateway_action => $action,
- options => [ ( @bop_options ) ],
- });
- }
+ if (!$payment_gateway) {
+
+ my ( $processor, $login, $password, $action, @bop_options ) =
+ $conf->config('business-onlinepayment');
+ $action ||= 'normal authorization';
+ pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
+ die "No real-time processor is enabled - ".
+ "did you set the business-onlinepayment configuration value?\n"
+ unless $processor;
+
+ $payment_gateway = new FS::payment_gateway( {
+ gateway_namespace => $conf->config('business-onlinepayment-namespace'),
+ gateway_module => $processor,
+ gateway_username => $login,
+ gateway_password => $password,
+ gateway_action => $action,
+ options => [ ( @bop_options ) ],
+ });
}
die "No real-time third party processor is enabled - ".
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index 0d45ace..6ee7e3d 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -2349,8 +2349,8 @@ and customer address. Include units.',
{
'key' => 'selfservice-payment_gateway',
- 'section' => 'self-service',
- 'description' => 'Force the use of this payment gateway for self-service.',
+ 'section' => 'deprecated',
+ 'description' => '(no longer supported) Force the use of this payment gateway for self-service.',
%payment_gateway_options,
},
diff --git a/FS/FS/Cron/cleanup.pm b/FS/FS/Cron/cleanup.pm
index 6ec4013..9d0c067 100644
--- a/FS/FS/Cron/cleanup.pm
+++ b/FS/FS/Cron/cleanup.pm
@@ -8,12 +8,26 @@ use FS::Record qw( qsearch );
# start janitor jobs
sub cleanup {
-# fix locations that are missing coordinates
+ my %opt = @_;
+
+ # fix locations that are missing coordinates
my $job = FS::queue->new({
'job' => 'FS::cust_location::process_set_coord',
'status' => 'new'
});
$job->insert('_JOB');
+
+ # check card number tokenization
+ $job = FS::queue->new({
+ 'job' => 'FS::cust_main::Billing_Realtime::token_check',
+ 'status' => 'new'
+ });
+ $job->insert(
+ %opt,
+ 'queue' => 1,
+ 'daily' => 1,
+ );
+
}
sub cleanup_before_backup {
diff --git a/FS/FS/Cron/tax_rate_update.pm b/FS/FS/Cron/tax_rate_update.pm
index b6ac63c..fec696f 100755
--- a/FS/FS/Cron/tax_rate_update.pm
+++ b/FS/FS/Cron/tax_rate_update.pm
@@ -31,7 +31,7 @@ sub tax_rate_update {
my %opt = @_;
my $oldAutoCommit = $FS::UID::AutoCommit;
- $FS::UID::AutoCommit = 0;
+ local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
my $conf = FS::Conf->new;
diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm
index d06b7d8..506ff15 100644
--- a/FS/FS/Upgrade.pm
+++ b/FS/FS/Upgrade.pm
@@ -46,6 +46,10 @@ sub upgrade_config {
my $conf = new FS::Conf;
+ # to simplify tokenization upgrades
+ die "Conf selfservice-payment_gateway no longer supported"
+ if $conf->config('selfservice-payment_gateway');
+
$conf->touch('payment_receipt')
if $conf->exists('payment_receipt_email')
|| $conf->config('payment_receipt_msgnum');
@@ -332,7 +336,11 @@ sub upgrade_data {
#fix whitespace - before cust_main
'cust_location' => [],
- #cust_main (remove paycvv from history)
+ # need before cust_main tokenization upgrade,
+ # blocks tokenization upgrade if deprecated features still in use
+ 'agent_payment_gateway' => [],
+
+ #cust_main (tokenizes cards, remove paycvv from history, locations, cust_payby, etc)
'cust_main' => [],
#msgcat
diff --git a/FS/FS/agent.pm b/FS/FS/agent.pm
index d6171c6..aad3f1c 100644
--- a/FS/FS/agent.pm
+++ b/FS/FS/agent.pm
@@ -9,6 +9,7 @@ use FS::cust_main;
use FS::cust_pkg;
use FS::agent_type;
use FS::reg_code;
+use FS::payment_gateway;
use FS::TicketSystem;
use FS::Conf;
@@ -227,51 +228,42 @@ sub ticketing_queue {
Returns a payment gateway object (see L<FS::payment_gateway>) for this agent.
-Currently available options are I<nofatal>, I<invnum>, I<method>,
-I<payinfo>, and I<thirdparty>.
+Currently available options are I<nofatal>, I<method>, I<thirdparty> and I<conf>.
If I<nofatal> is set, and no gateway is available, then the empty string
will be returned instead of throwing a fatal exception.
-If I<invnum> is set to the number of an invoice (see L<FS::cust_bill>) then
-an attempt will be made to select a gateway suited for the taxes paid on
-the invoice.
+The I<method> option can be used to influence the choice
+as well. Presently only CHEK/ECHECK and PAYPAL methods are meaningful.
-The I<method> and I<payinfo> options can be used to influence the choice
-as well. Presently only 'CC', 'ECHECK', and 'PAYPAL' methods are meaningful.
+If I<method> is CHEK/ECHECK and the default gateway is being returned,
+the business-onlinepayment-ach gateway will be returned if available.
-When the I<method> is 'CC' then the card number in I<payinfo> can direct
-this routine to route to a gateway suited for that type of card.
+If I<thirdparty> is set and the I<method> is PAYPAL, the defined paypal
+gateway will be returned.
-If I<thirdparty> is set, the defined self-service payment gateway will
-be returned.
+Exisisting I<$conf> may be passed for efficiency.
=cut
+# opts invnum/payinfo for cardtype/taxclass overrides no longer supported
+# any future overrides added here need to be reconciled with the tokenization process
+
sub payment_gateway {
my ( $self, %options ) = @_;
- my $conf = new FS::Conf;
+ $options{'conf'} ||= new FS::Conf;
+ my $conf = $options{'conf'};
if ( $options{thirdparty} ) {
- # still a kludge, but it gets the job done
- # and the 'cardtype' semantics don't really apply to thirdparty
- # gateways because we have to choose a gateway without ever
- # seeing the card number
- my $gatewaynum =
- $conf->config('selfservice-payment_gateway', $self->agentnum);
- my $gateway;
- $gateway = FS::payment_gateway->by_key($gatewaynum) if $gatewaynum;
- return $gateway if $gateway;
-
- # a little less kludgey than the above, and allows PayPal to coexist
- # with credit card gateways
+
+ # allows PayPal to coexist with credit card gateways
my $is_paypal = { op => '!=', value => 'PayPal' };
if ( uc($options{method}) eq 'PAYPAL' ) {
$is_paypal = 'PayPal';
}
- $gateway = qsearchs({
+ my $gateway = qsearchs({
table => 'payment_gateway',
addl_from => ' JOIN agent_payment_gateway USING (gatewaynum) ',
hashref => {
@@ -291,105 +283,12 @@ sub payment_gateway {
}
}
- my $taxclass = '';
- if ( $options{invnum} ) {
-
- my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{invnum} } );
- die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
-
- my @part_pkg =
- map { $_->part_pkg }
- grep { $_ }
- map { $_->cust_pkg }
- $cust_bill->cust_bill_pkg;
-
- my @taxclasses = map $_->taxclass, @part_pkg;
-
- $taxclass = $taxclasses[0]
- unless grep { $taxclasses[0] ne $_ } @taxclasses; #unless there are
- #different taxclasses
- }
-
- #look for an agent gateway override first
- my $cardtype = '';
- if ( $options{method} ) {
- if ( $options{method} eq 'CC' && $options{payinfo} ) {
- $cardtype = cardtype($options{payinfo});
- } elsif ( $options{method} eq 'ECHECK' ) {
- $cardtype = 'ACH';
- } else {
- $cardtype = $options{method}
- }
- }
-
- my $override =
- qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
- cardtype => $cardtype,
- taxclass => $taxclass, } )
- || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
- cardtype => '',
- taxclass => $taxclass, } )
- || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
- cardtype => $cardtype,
- taxclass => '', } )
- || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
- cardtype => '',
- taxclass => '', } );
-
- my $payment_gateway;
- if ( $override ) { #use a payment gateway override
-
- $payment_gateway = $override->payment_gateway;
-
- $payment_gateway->gateway_namespace('Business::OnlinePayment')
- unless $payment_gateway->gateway_namespace;
-
- } else { #use the standard settings from the config
-
- # the standard settings from the config could be moved to a null agent
- # agent_payment_gateway referenced payment_gateway
-
- unless ( $conf->exists('business-onlinepayment') ) {
- if ( $options{'nofatal'} ) {
- return '';
- } else {
- die "Real-time processing not enabled\n";
- }
- }
-
- #load up config
- my $bop_config = 'business-onlinepayment';
- $bop_config .= '-ach'
- if ( $options{method}
- && $options{method} =~ /^(ECHECK|CHEK)$/
- && $conf->exists($bop_config. '-ach')
- );
- my ( $processor, $login, $password, $action, @bop_options ) =
- $conf->config($bop_config);
- $action ||= 'normal authorization';
- pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
- die "No real-time processor is enabled - ".
- "did you set the business-onlinepayment configuration value?\n"
- unless $processor;
-
- $payment_gateway = new FS::payment_gateway;
-
- $payment_gateway->gateway_namespace( $conf->config('business-onlinepayment-namespace') ||
- 'Business::OnlinePayment');
- $payment_gateway->gateway_module($processor);
- $payment_gateway->gateway_username($login);
- $payment_gateway->gateway_password($password);
- $payment_gateway->gateway_action($action);
- $payment_gateway->set('options', [ @bop_options ]);
-
- }
-
- unless ( $payment_gateway->gateway_namespace ) {
- $payment_gateway->gateway_namespace(
- scalar($conf->config('business-onlinepayment-namespace'))
- || 'Business::OnlinePayment'
- );
- }
+ my $override = qsearchs('agent_payment_gateway', { agentnum => $self->agentnum } );
+
+ my $payment_gateway = FS::payment_gateway->by_key_or_default(
+ gatewaynum => $override ? $override->gatewaynum : '',
+ %options,
+ );
$payment_gateway;
}
diff --git a/FS/FS/agent_payment_gateway.pm b/FS/FS/agent_payment_gateway.pm
index bd99d0c..d189b88 100644
--- a/FS/FS/agent_payment_gateway.pm
+++ b/FS/FS/agent_payment_gateway.pm
@@ -115,6 +115,21 @@ sub check {
$self->SUPER::check;
}
+sub _upgrade_data {
+ # to simplify tokenization upgrades
+ die "Agent taxclass override no longer supported"
+ if qsearch({
+ 'table' => 'agent_payment_gateway',
+ 'extra_sql' => ' WHERE taxclass IS NOT NULL AND taxclass != \'\'',
+ });
+ die "Agent cardtype override no longer supported"
+ if qsearch({
+ 'table' => 'agent_payment_gateway',
+ 'extra_sql' => ' WHERE cardtype IS NOT NULL AND cardtype != \'\'',
+ });
+ return '';
+}
+
=item payment_gateway
=cut
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 0165bc4..33dab90 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -463,10 +463,11 @@ sub insert {
if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
my $error = $self->check_payinfo_cardtype
+ || $self->check # needed now for tokenize
+ || $self->realtime_tokenize # needs to happen before initial insert
|| $self->SUPER::insert;
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
- #return "inserting cust_main record (transaction rolled back): $error";
return $error;
}
@@ -1555,24 +1556,26 @@ sub replace {
my $error = $self->check_payinfo_cardtype;
return $error if $error;
- if ( $conf->exists('business-onlinepayment-verification') ) {
- #need to standardize paydate for this, false laziness with check
- 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");
+ #need to standardize paydate for this, false laziness with check
+ 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");
+ if ( $conf->exists('business-onlinepayment-verification') ) {
$error = $self->realtime_verify_bop({ 'method'=>'CC' });
- return $error if $error;
+ } else {
+ $error = $self->realtime_tokenize;
}
+ return $error if $error;
}
return "Invoicing locale is required"
@@ -1712,7 +1715,7 @@ sub replace {
if ( $self->payby =~ /^(CARD|CHEK|LECB)$/
&& ( ( $self->get('payinfo') ne $old->get('payinfo')
- && $self->get('payinfo') !~ /^99\d{14}$/
+ && !$self->tokenized
)
|| grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
)
@@ -1969,7 +1972,7 @@ sub check {
or return gettext('invalid_card'); # . ": ". $self->payinfo;
my $cardtype = cardtype($payinfo);
- $cardtype = 'Tokenized' if $self->payinfo =~ /^99\d{14}$/; # token
+ $cardtype = 'Tokenized' if $self->tokenized; # token
return gettext('unknown_card_type') if $cardtype eq 'Unknown';
@@ -2185,7 +2188,7 @@ sub check_payinfo_cardtype {
my $payinfo = $self->payinfo;
$payinfo =~ s/\D//g;
- if ( $payinfo =~ /^99\d{14}$/ ) {
+ if ( $self->tokenized($payinfo) ) {
$self->set('paycardtype', 'Tokenized');
return '';
}
@@ -5736,6 +5739,11 @@ sub _upgrade_data { #class method
}
+sub queueable_upgrade {
+ my $class = shift;
+ FS::cust_main::Billing_Realtime::token_check(@_);
+}
+
=back
=head1 BUGS
diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm
index bc98b88..40e7097 100644
--- a/FS/FS/cust_main/Billing_Realtime.pm
+++ b/FS/FS/cust_main/Billing_Realtime.pm
@@ -14,6 +14,7 @@ use FS::cust_pay_pending;
use FS::cust_bill_pay;
use FS::cust_refund;
use FS::banned_pay;
+use FS::payment_gateway;
$realtime_bop_decline_quiet = 0;
@@ -188,17 +189,10 @@ sub _bop_recurring_billing {
}
+#can run safely as class method if opt payment_gateway already exists
sub _payment_gateway {
my ($self, $options) = @_;
- if ( $options->{'selfservice'} ) {
- my $gatewaynum = FS::Conf->new->config('selfservice-payment_gateway');
- if ( $gatewaynum ) {
- return $options->{payment_gateway} ||=
- qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
- }
- }
-
if ( $options->{'fake_gatewaynum'} ) {
$options->{payment_gateway} =
qsearchs('payment_gateway',
@@ -212,8 +206,9 @@ sub _payment_gateway {
$options->{payment_gateway};
}
+# not a method!!!
sub _bop_auth {
- my ($self, $options) = @_;
+ my ($options) = @_;
(
'login' => $options->{payment_gateway}->gateway_username,
@@ -221,8 +216,9 @@ sub _bop_auth {
);
}
+### not a method!
sub _bop_options {
- my ($self, $options) = @_;
+ my ($options) = @_;
$options->{payment_gateway}->gatewaynum
? $options->{payment_gateway}->options
@@ -260,11 +256,13 @@ sub _bop_defaults {
$options->{payname} = $self->payname unless exists( $options->{payname} );
}
+# can be called as class method,
+# but can't load default fields as class method
sub _bop_content {
my ($self, $options) = @_;
my %content = ();
- my $payip = exists($options->{'payip'}) ? $options->{'payip'} : $self->payip;
+ my $payip = exists($options->{'payip'}) ? $options->{'payip'} : (ref($self) ? $self->payip : '');
$content{customer_ip} = $payip if length($payip);
$content{invoice_number} = $options->{'invnum'}
@@ -280,43 +278,45 @@ sub _bop_content {
/^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
or return "Illegal payname $payname";
($payfirst, $paylast) = ($1, $2);
- } else {
+ } elsif (ref($self)) { # can't set payname if called as class method
$payfirst = $self->getfield('first');
$paylast = $self->getfield('last');
$payname = "$payfirst $paylast";
}
- $content{last_name} = $paylast;
- $content{first_name} = $payfirst;
+ $content{last_name} = $paylast if $paylast;
+ $content{first_name} = $payfirst if $payfirst;
- $content{name} = $payname;
+ $content{name} = $payname if $payname;
$content{address} = exists($options->{'address1'})
? $options->{'address1'}
- : $self->address1;
+ : (ref($self) ? $self->address1 : '');
my $address2 = exists($options->{'address2'})
? $options->{'address2'}
- : $self->address2;
+ : (ref($self) ? $self->address2 : '');
$content{address} .= ", ". $address2 if length($address2);
$content{city} = exists($options->{city})
? $options->{city}
- : $self->city;
+ : (ref($self) ? $self->city : '');
$content{state} = exists($options->{state})
? $options->{state}
- : $self->state;
+ : (ref($self) ? $self->state : '');
$content{zip} = exists($options->{zip})
? $options->{'zip'}
- : $self->zip;
+ : (ref($self) ? $self->zip : '');
$content{country} = exists($options->{country})
? $options->{country}
- : $self->country;
+ : (ref($self) ? $self->country : '');
#3.0 is a good a time as any to get rid of this... add a config to pass it
# if anyone still needs it
#$content{referer} = 'http://cleanwhisker.420.am/';
- $content{phone} = $self->daytime || $self->night;
+ # can't set phone if called as class method
+ $content{phone} = $self->daytime || $self->night
+ if ref($self);
my $currency = $conf->exists('business-onlinepayment-currency')
&& $conf->config('business-onlinepayment-currency');
@@ -325,6 +325,22 @@ sub _bop_content {
\%content;
}
+# updates payinfo option & cust_main with token from transaction
+# can be called as a class method
+sub _tokenize_card {
+ my ($self,$transaction,$options) = @_;
+ if ( $transaction->can('card_token')
+ and $transaction->card_token
+ and !FS::payinfo_Mixin->tokenized($options->{'payinfo'})
+ ) {
+ $self->payinfo($transaction->card_token)
+ if ref($self) && $self->payinfo eq $options->{'payinfo'};
+ $options->{'payinfo'} = $transaction->card_token;
+ return $transaction->card_token;
+ }
+ return '';
+}
+
my %bop_method2payby = (
'CC' => 'CARD',
'ECHECK' => 'CHEK',
@@ -335,6 +351,8 @@ sub realtime_bop {
my $self = shift;
local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_bop');
my %options = ();
if (ref($_[0]) eq 'HASH') {
@@ -385,6 +403,19 @@ sub realtime_bop {
$self->_bop_defaults(\%options);
+ # possibly run a separate transaction to tokenize card number,
+ # so that we never store tokenized card info in cust_pay_pending
+ if (($options{method} eq 'CC') && !$self->tokenized($options{'payinfo'})) {
+ my $save_token = ( $options{'payinfo'} eq $self->payinfo ) ? 1 : 0;
+ my $token_error = $self->realtime_tokenize(\%options);
+ return $token_error if $token_error;
+ if ( $save_token && $self->tokenized($options{'payinfo'}) ) {
+ $self->payinfo($options{'payinfo'});
+ $token_error = $self->replace;
+ return $token_error if $token_error;
+ }
+ }
+
###
# set trans_is_recur based on invnum if there is one
###
@@ -628,12 +659,12 @@ sub realtime_bop {
split( /\s*\,\s*/, $payment_gateway->gateway_action );
my $transaction = new $namespace( $payment_gateway->gateway_module,
- $self->_bop_options(\%options),
+ _bop_options(\%options),
);
$transaction->content(
'type' => $options{method},
- $self->_bop_auth(\%options),
+ _bop_auth(\%options),
'action' => $action1,
'description' => $options{'description'},
'amount' => $options{amount},
@@ -688,14 +719,14 @@ sub realtime_bop {
my $capture =
new Business::OnlinePayment( $payment_gateway->gateway_module,
- $self->_bop_options(\%options),
+ _bop_options(\%options),
);
my %capture = (
%content,
type => $options{method},
action => $action2,
- $self->_bop_auth(\%options),
+ _bop_auth(\%options),
order_number => $ordernum,
amount => $options{amount},
authorization => $auth,
@@ -736,6 +767,8 @@ sub realtime_bop {
) {
my $error = $self->remove_cvv;
if ( $error ) {
+ $log->critical('Error removing cvv for cust '.$self->custnum.': '.$error);
+ #not returning error, should at least attempt to handle results of an otherwise valid transaction
warn "WARNING: error removing cvv: $error\n";
}
}
@@ -744,17 +777,19 @@ sub realtime_bop {
# Tokenize
###
-
+ # This block will only run if the B::OP module supports card_token but not the Tokenize transaction;
+ # if that never happens, we should get rid of it (as it has the potential to store real card numbers on error)
if ( $transaction->can('card_token') && $transaction->card_token ) {
-
if ( $options{'payinfo'} eq $self->payinfo ) {
$self->payinfo($transaction->card_token);
my $error = $self->replace;
if ( $error ) {
+ $log->critical('Error storing token for cust '.$self->custnum.': '.$error);
+ #not returning error, should at least attempt to handle results of an otherwise valid transaction
+ #this leaves real card number in cust_main, but can't do much else if cust_main won't replace
warn "WARNING: error storing token: $error, but proceeding anyway\n";
}
}
-
}
###
@@ -792,9 +827,7 @@ sub fake_bop {
'paid' => $options{amount},
'_date' => '',
'payby' => $bop_method2payby{$options{method}},
- #'payinfo' => $payinfo,
'payinfo' => '4111111111111111',
- #'paydate' => $paydate,
'paydate' => '2012-05-01',
'processor' => 'FakeProcessor',
'auth' => '54',
@@ -854,7 +887,7 @@ sub _realtime_bop_result {
or return "no payment gateway in arguments to _realtime_bop_result";
$cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
- my $cpp_captured_err = $cust_pay_pending->replace;
+ my $cpp_captured_err = $cust_pay_pending->replace; #also saves post-transaction tokenization, if that happens
return $cpp_captured_err if $cpp_captured_err;
if ( $transaction->is_success() ) {
@@ -1237,14 +1270,14 @@ sub realtime_botpp_capture {
my $transaction =
new Business::OnlineThirdPartyPayment( $payment_gateway->gateway_module,
- $self->_bop_options(\%options),
+ _bop_options(\%options),
);
$transaction->reference({ %options });
$transaction->content(
'type' => $method,
- $self->_bop_auth(\%options),
+ _bop_auth(\%options),
'action' => 'Post Authorization',
'description' => $options{'description'},
'amount' => $cust_pay_pending->paid,
@@ -1455,10 +1488,12 @@ sub realtime_refund_bop {
@bop_options = $payment_gateway->gatewaynum
? $payment_gateway->options
: @{ $payment_gateway->get('options') };
+ my %bop_options = @bop_options;
return "processor of payment $options{'paynum'} $processor does not".
" match default processor $conf_processor"
- unless $processor eq $conf_processor;
+ unless ($processor eq $conf_processor)
+ || (($conf_processor eq 'CardFortress') && ($processor eq $bop_options{'gateway'}));
}
@@ -1467,9 +1502,7 @@ sub realtime_refund_bop {
# like a normal transaction
my $payment_gateway =
- $self->agent->payment_gateway( 'method' => $options{method},
- #'payinfo' => $payinfo,
- );
+ $self->agent->payment_gateway( 'method' => $options{method} );
my( $processor, $login, $password, $namespace ) =
map { my $method = "gateway_$_"; $payment_gateway->$method }
qw( module username password namespace );
@@ -1601,15 +1634,18 @@ sub realtime_refund_bop {
if length($payip);
my $payinfo = '';
+ my $paymask = ''; # for refund record
if ( $options{method} eq 'CC' ) {
if ( $cust_pay ) {
$content{card_number} = $payinfo = $cust_pay->payinfo;
+ $paymask = $cust_pay->paymask;
(exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
=~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
($content{expiration} = "$2/$1"); # where available
} else {
$content{card_number} = $payinfo = $self->payinfo;
+ $paymask = $self->paymask;
(exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
=~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
$content{expiration} = "$2/$1";
@@ -1676,6 +1712,7 @@ sub realtime_refund_bop {
'_date' => '',
'payby' => $bop_method2payby{$options{method}},
'payinfo' => $payinfo,
+ 'paymask' => $paymask,
'reasonnum' => $reason->reasonnum,
'gatewaynum' => $gatewaynum, # may be null
'processor' => $processor,
@@ -1745,6 +1782,15 @@ sub realtime_verify_bop {
warn " $_ => $options{$_}\n" foreach keys %options;
}
+ # possibly run a separate transaction to tokenize card number,
+ # so that we never store tokenized card info in cust_pay_pending
+ if (($options{method} eq 'CC') && !$self->tokenized($options{'payinfo'})) {
+ my $token_error = $self->realtime_tokenize(\%options);
+ return $token_error if $token_error;
+ #important that we not replace cust_main here,
+ #because cust_main->replace uses realtime_verify_bop!
+ }
+
###
# select a gateway
###
@@ -1820,17 +1866,16 @@ sub realtime_verify_bop {
$content{issue_number} = $payissue if $payissue;
} elsif ( $options{method} eq 'ECHECK' ){
-
- #nop for checks (though it shouldn't be called...)
-
+ #cannot verify, move along (though it shouldn't be called...)
+ return '';
} else {
- die "unknown method ". $options{method};
+ return "unknown method ". $options{method};
}
-
} elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) {
- #move along
+ #cannot verify, move along
+ return '';
} else {
- die "unknown namespace $namespace";
+ return "unknown namespace $namespace";
}
###
@@ -1839,6 +1884,7 @@ sub realtime_verify_bop {
my $error;
my $transaction; #need this back so we can do _tokenize_card
+
# don't mutex the customer here, because they might be uncommitted. and
# this is only verification. it doesn't matter if they have other
# unfinished verifications.
@@ -1851,12 +1897,10 @@ sub realtime_verify_bop {
'payinfo' => $options{payinfo} || $self->payinfo,
'paymask' => $options{paymask} || $self->paymask,
'paydate' => $paydate,
- #'recurring_billing' => $content{recurring_billing},
'pkgnum' => $options{'pkgnum'},
'status' => 'new',
'gatewaynum' => $payment_gateway->gatewaynum || '',
'session_id' => $options{session_id} || '',
- #'jobnum' => $options{depend_jobnum} || '',
};
$cust_pay_pending->payunique( $options{payunique} )
if defined($options{payunique}) && length($options{payunique});
@@ -1888,21 +1932,18 @@ sub realtime_verify_bop {
warn Dumper($cust_pay_pending) if $DEBUG > 2;
$transaction = new $namespace( $payment_gateway->gateway_module,
- $self->_bop_options(\%options),
+ _bop_options(\%options),
);
$transaction->content(
'type' => 'CC',
- $self->_bop_auth(\%options),
+ _bop_auth(\%options),
'action' => 'Authorization Only',
'description' => $options{'description'},
'amount' => '1.00',
- #'invoice_number' => $options{'invnum'},
'customer_id' => $self->custnum,
%$bop_content,
'reference' => $cust_pay_pending->paypendingnum, #for now
- 'callback_url' => $payment_gateway->gateway_callback_url,
- 'cancel_url' => $payment_gateway->gateway_cancel_url,
'email' => $email,
%content, #after
);
@@ -1939,11 +1980,11 @@ sub realtime_verify_bop {
: '';
my $reverse = new $namespace( $payment_gateway->gateway_module,
- $self->_bop_options(\%options),
+ _bop_options(\%options),
);
$reverse->content( 'action' => 'Reverse Authorization',
- $self->_bop_auth(\%options),
+ _bop_auth(\%options),
# B:OP
'amount' => '1.00',
@@ -2112,23 +2153,26 @@ sub realtime_verify_bop {
}
###
- # Tokenize
+ # remove paycvv here? need to find out if a reversed auth
+ # counts as an initial transaction for paycvv retention requirements
###
- if ( $transaction->can('card_token') && $transaction->card_token ) {
-
- if ( $options{'payinfo'} eq $self->payinfo ) {
- $self->payinfo($transaction->card_token);
- my $error = $self->replace;
- if ( $error ) {
- my $warning = "WARNING: error storing token: $error, but proceeding anyway\n";
- $log->warning($warning);
- warn $warning;
- }
- }
+ ###
+ # Tokenize
+ ###
+ # This block will only run if the B::OP module supports card_token but not the Tokenize transaction;
+ # if that never happens, we should get rid of it (as it has the potential to store real card numbers on error)
+ if (my $card_token = $self->_tokenize_card($transaction,\%options)) {
+ $cust_pay_pending->payinfo($card_token);
+ my $cpp_token_err = $cust_pay_pending->replace;
+ #this leaves real card number in cust_pay_pending, but can't do much else if cpp won't replace
+ return $cpp_token_err if $cpp_token_err;
+ #important that we not replace cust_main here,
+ #because cust_main->replace uses realtime_verify_bop!
}
+
###
# result handling
###
@@ -2139,12 +2183,569 @@ sub realtime_verify_bop {
}
+=item realtime_tokenize [ OPTION => VALUE ... ]
+
+If possible and necessary, runs a tokenize transaction.
+In order to be possible, a credit card
+and a Business::OnlinePayment gateway capable
+of Tokenize transactions must be configured for this user.
+Is only necessary if payinfo is not yet tokenized.
+
+Returns the empty string if the authorization was sucessful
+or was not possible/necessary (thus allowing this to be safely called with
+non-tokenizable records/gateways, without having to perform separate tests),
+or an error message otherwise.
+
+Customer object payinfo will be tokenized if possible, but that change will not be
+updated in database (must be inserted/replaced afterwards.)
+
+Otherwise, options I<method>, I<payinfo> and other cust_payby fields
+may be passed. If options are passed as a hashref, I<payinfo>
+will be updated as appropriate in the passed hashref. Customer
+object will only be updated if passed payinfo matches customer payinfo.
+
+Can be run as a class method if option I<payment_gateway> is passed,
+but default customer info can't be set in that case. This
+is really only intended for tokenizing old records on upgrade.
+
+=cut
+
+# careful--might be run as a class method
+sub realtime_tokenize {
+ my $self = shift;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+ my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_tokenize');
+
+ my %options = ();
+ my $outoptions; #for returning payinfo
+ if (ref($_[0]) eq 'HASH') {
+ %options = %{$_[0]};
+ $outoptions = $_[0];
+ } else {
+ %options = @_;
+ $outoptions = \%options;
+ }
+
+ # set fields from passed cust_main
+ unless ($options{'payinfo'}) {
+ $options{'method'} = FS::payby->payby2bop( $self->payby );
+ $options{$_} = $self->$_()
+ for qw( payinfo paycvv paymask paystart_month paystart_year paydate
+ payissue payname paystate paytype payip );
+ $outoptions->{'payinfo'} = $options{'payinfo'};
+ }
+ return '' unless $options{method} eq 'CC';
+ return '' if FS::payinfo_Mixin->tokenized($options{payinfo}); #already tokenized
+
+ ###
+ # select a gateway
+ ###
+
+ $options{'nofatal'} = 1;
+ my $payment_gateway = $self->_payment_gateway( \%options );
+ return '' unless $payment_gateway;
+ my $namespace = $payment_gateway->gateway_namespace;
+ return '' unless $namespace eq 'Business::OnlinePayment';
+
+ eval "use $namespace";
+ return $@ if $@;
+
+ ###
+ # check for tokenize ability
+ ###
+
+ my $transaction = new $namespace( $payment_gateway->gateway_module,
+ _bop_options(\%options),
+ );
+
+ return '' unless $transaction->can('info');
+
+ my %supported_actions = $transaction->info('supported_actions');
+ return '' unless $supported_actions{'CC'} and grep(/^Tokenize$/,@{$supported_actions{'CC'}});
+
+ ###
+ # check for banned credit card/ACH
+ ###
+
+ my $ban = FS::banned_pay->ban_search(
+ 'payby' => $bop_method2payby{'CC'},
+ 'payinfo' => $options{payinfo},
+ );
+ return "Banned credit card" if $ban && $ban->bantype ne 'warn';
+
+ ###
+ # massage data
+ ###
+
+ ### Currently, cardfortress only keys in on card number and exp date.
+ ### We pass everything we'd pass to a normal transaction,
+ ### for ease of current and future development,
+ ### but note, when tokenizing old records, we may only have access to payinfo/paydate
+
+ my $bop_content = $self->_bop_content(\%options);
+ return $bop_content unless ref($bop_content);
+
+ my $paydate = '';
+ my %content = ();
+
+ $content{card_number} = $options{payinfo};
+ $paydate = $options{'paydate'};
+ $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+ $content{expiration} = "$2/$1";
+
+ $content{cvv2} = $options{'paycvv'}
+ if length($options{'paycvv'});
+
+ my $paystart_month = $options{'paystart_month'};
+ my $paystart_year = $options{'paystart_year'};
+
+ $content{card_start} = "$paystart_month/$paystart_year"
+ if $paystart_month && $paystart_year;
+
+ my $payissue = $options{'payissue'};
+ $content{issue_number} = $payissue if $payissue;
+
+ $content{customer_id} = $self->custnum
+ if ref($self);
+
+ ###
+ # run transaction
+ ###
+
+ my $error;
+
+ # no cust_pay_pending---this is not a financial transaction
+
+ $transaction->content(
+ 'type' => 'CC',
+ _bop_auth(\%options),
+ 'action' => 'Tokenize',
+ 'description' => $options{'description'},
+ %$bop_content,
+ %content, #after
+ );
+
+ # no $BOP_TESTING handling for this
+ $transaction->test_transaction(1)
+ if $conf->exists('business-onlinepayment-test_transaction');
+ $transaction->submit();
+
+ if ( $transaction->card_token() ) { # no is_success flag
+
+ # realtime_tokenize should not clear paycvv at this time. it might be
+ # needed for the first transaction, and a tokenize isn't actually a
+ # transaction that hits the gateway. at some point in the future, card
+ # fortress should take on the "store paycvv until first transaction"
+ # functionality and we should fix this in freeside, but i that's a bigger
+ # project for another time.
+
+ #important that we not replace cust_main here,
+ #because cust_main->replace uses realtime_tokenize!
+ $self->_tokenize_card($transaction,$outoptions);
+
+ } else {
+
+ $error = $transaction->error_message || 'Unknown error when tokenizing card';
+
+ }
+
+ return $error;
+
+}
+
+=item token_check [ quiet => 1, queue => 1, daily => 1 ]
+
+NOT A METHOD. Acts on all customers. Placed here because it makes
+use of module-internal methods, and to keep everything that uses
+Billing::OnlinePayment all in one place.
+
+Tokenizes all tokenizable card numbers from payinfo in cust_main and
+CARD transactions in cust_pay_pending, cust_pay, cust_pay_void and cust_refund.
+
+If the I<queue> flag is set, newly tokenized records will be immediately
+committed, regardless of AutoCommit, so as to release the mutex on the record.
+
+If all configured gateways have the ability to tokenize, detection of an
+untokenizable record will cause a fatal error. However, if the I<queue> flag
+is set, this will instead cause a critical error to be recorded in the log,
+and any other tokenizable records will still be committed.
+
+If the I<daily> flag is also set, detection of existing untokenized records will
+record a critical error in the system log (because they should have never appeared
+in the first place.) Tokenization will still be attempted.
+
+If any configured gateways do NOT have the ability to tokenize, or if a
+default gateway is not configured, then untokenized records are not considered
+a threat, and no critical errors will be generated in the log.
+
+=cut
+
+sub token_check {
+ #acts on all customers
+ my %opt = @_;
+ my $debug = !$opt{'quiet'} || $DEBUG;
+
+ warn "token_check called with opts\n".Dumper(\%opt) if $debug;
+
+ # force some explicitness when invoking this method
+ die "token_check must run with queue flag if run with daily flag"
+ if $opt{'daily'} && !$opt{'queue'};
+
+ my $conf = FS::Conf->new;
+
+ my $log = FS::Log->new('FS::cust_main::Billing_Realtime::token_check');
+
+ my $cache = {}; #cache for module info
+
+ # look for a gateway that can't tokenize
+ my $require_tokenized = 1;
+ foreach my $gateway (
+ FS::payment_gateway->all_gateways(
+ 'method' => 'CC',
+ 'conf' => $conf,
+ 'nofatal' => 1,
+ )
+ ) {
+ if (!$gateway) {
+ # no default gateway, no promise to tokenize
+ # can just load other gateways as-needeed below
+ $require_tokenized = 0;
+ last;
+ }
+ my $info = _token_check_gateway_info($cache,$gateway);
+ die $info unless ref($info); # means it's an error message
+ unless ($info->{'can_tokenize'}) {
+ # a configured gateway can't tokenize, that's all we need to know right now
+ # can just load other gateways as-needeed below
+ $require_tokenized = 0;
+ last;
+ }
+ }
+
+ warn "REQUIRE TOKENIZED" if $require_tokenized && $debug;
+
+ # upgrade does not call this with autocommit turned on,
+ # and autocommit will be ignored if opt queue is set,
+ # but might as well be thorough...
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ # for retrieving data in chunks
+ my $step = 500;
+ my $offset = 0;
+
+ ### Tokenize cust_main
+
+ my @recnums;
+
+ while (my $custnum = _token_check_next_recnum($dbh,'cust_main',$step,\$offset,\@recnums)) {
+ my $cust_main = FS::cust_main->by_key($custnum);
+ next unless $cust_main->payby =~ /^(CARD|DCRD)$/;
+
+ # see if it's already tokenized
+ if ($cust_main->tokenized) {
+ warn "cust_main ".$cust_main->custnum." already tokenized" if $debug;
+ next;
+ }
+
+ if ($require_tokenized && $opt{'daily'}) {
+ $log->critical("Untokenized card number detected in cust_main ".$cust_main->custnum);
+ $dbh->commit or die $dbh->errstr; # commit log message
+ }
+
+ # load gateway
+ my $payment_gateway = $cust_main->_payment_gateway({
+ 'method' => 'CC',
+ 'conf' => $conf,
+ 'nofatal' => 1, # handle lack of gateway smoothly below
+ });
+ unless ($payment_gateway) {
+ # no reason to have untokenized card numbers saved if no gateway,
+ # but only a problem if we expected everyone to tokenize card numbers
+ unless ($require_tokenized) {
+ warn "Skipping cust_payby for cust_main ".$cust_main->custnum.", no payment gateway" if $debug;
+ next;
+ }
+ my $error = "No gateway found for custnum ".$cust_main->custnum;
+ if ($opt{'queue'}) {
+ $log->critical($error);
+ $dbh->commit or die $dbh->errstr; # commit error message
+ next;
+ }
+ $dbh->rollback if $oldAutoCommit;
+ die $error;
+ }
+
+ my $info = _token_check_gateway_info($cache,$payment_gateway);
+ unless (ref($info)) {
+ # only throws error if Business::OnlinePayment won't load,
+ # which is just cause to abort this whole process, even if queue
+ $dbh->rollback if $oldAutoCommit;
+ die $info; # error message
+ }
+ # no fail here--a configured gateway can't tokenize, so be it
+ unless ($info->{'can_tokenize'}) {
+ warn "Skipping ".$cust_main->custnum." cannot tokenize" if $debug;
+ next;
+ }
+
+ # time to tokenize
+ $cust_main = $cust_main->select_for_update;
+ my %tokenopts = (
+ 'payment_gateway' => $payment_gateway,
+ );
+ my $error = $cust_main->realtime_tokenize(\%tokenopts);
+ if ($cust_main->tokenized) { # implies no error
+ $error = $cust_main->replace;
+ } else {
+ $error ||= 'Unknown error';
+ }
+ if ($error) {
+ $error = "Error tokenizing cust_main ".$cust_main->custnum.": ".$error;
+ if ($opt{'queue'}) {
+ $log->critical($error);
+ $dbh->commit or die $dbh->errstr; # commit log message, release mutex
+ next;
+ }
+ $dbh->rollback if $oldAutoCommit;
+ die $error;
+ }
+ $dbh->commit or die $dbh->errstr if $opt{'queue'}; # release mutex
+ warn "TOKENIZED cust_main ".$cust_main->custnum if $debug;
+ }
+
+ ### Tokenize/mask transaction tables
+
+ # allow tokenization of closed cust_pay/cust_refund records
+ local $FS::payinfo_Mixin::allow_closed_replace = 1;
+
+ # grep assistance:
+ # $cust_pay_pending->replace, $cust_pay->replace, $cust_pay_void->replace, $cust_refund->replace all run here
+ foreach my $table ( qw(cust_pay_pending cust_pay cust_pay_void cust_refund) ) {
+ warn "Checking $table" if $debug;
+
+ # FS::Cursor does not seem to work over multiple commits (gives cursor not found errors)
+ # loading only record ids, then loading individual records one at a time
+ my $tclass = 'FS::'.$table;
+ $offset = 0;
+ @recnums = ();
+
+ while (my $recnum = _token_check_next_recnum($dbh,$table,$step,\$offset,\@recnums)) {
+ my $record = $tclass->by_key($recnum);
+ if (FS::payinfo_Mixin->tokenized($record->payinfo)) {
+ warn "Skipping tokenized record for $table ".$record->get($record->primary_key) if $debug;
+ next;
+ }
+ if (!$record->payinfo) { #shouldn't happen, but at least it's not a card number
+ warn "Skipping blank payinfo for $table ".$record->get($record->primary_key) if $debug;
+ next;
+ }
+ if ($record->payinfo =~ /N\/A/) { # ??? Not sure why we do this, but it's not a card number
+ warn "Skipping NA payinfo for $table ".$record->get($record->primary_key) if $debug;
+ next;
+ }
+
+ if ($require_tokenized && $opt{'daily'}) {
+ $log->critical("Untokenized card number detected in $table ".$record->get($record->primary_key));
+ $dbh->commit or die $dbh->errstr; # commit log message
+ }
+
+ my $cust_main = $record->cust_main;
+ if (!$cust_main) {
+ # might happen for cust_pay_pending from failed verify records,
+ # in which case we attempt tokenization without cust_main
+ # everything else should absolutely have a cust_main
+ if ($table eq 'cust_pay_pending' and !$record->custnum ) {
+ # override the usual safety check and allow the record to be
+ # updated even without a custnum.
+ $record->set('custnum_pending', 1);
+ } else {
+ my $error = "Could not load cust_main for $table ".$record->get($record->primary_key);
+ if ($opt{'queue'}) {
+ $log->critical($error);
+ $dbh->commit or die $dbh->errstr; # commit log message
+ next;
+ }
+ $dbh->rollback if $oldAutoCommit;
+ die $error;
+ }
+ }
+
+ my $gateway;
+
+ # use the gatewaynum specified by the record if possible
+ $gateway = FS::payment_gateway->by_key_with_namespace(
+ 'gatewaynum' => $record->gatewaynum,
+ ) if $record->gateway;
+
+ # otherwise use the cust agent gateway if possible (which realtime_refund_bop would do)
+ # otherwise just use default gateway
+ unless ($gateway) {
+
+ $gateway = $cust_main
+ ? $cust_main->agent->payment_gateway
+ : FS::payment_gateway->default_gateway;
+
+ # check for processor mismatch
+ unless ($table eq 'cust_pay_pending') { # has no processor table
+ if (my $processor = $record->processor) {
+
+ my $conf_processor = $gateway->gateway_module;
+ my %bop_options = $gateway->gatewaynum
+ ? $gateway->options
+ : @{ $gateway->get('options') };
+
+ # this is the same standard used by realtime_refund_bop
+ unless (
+ ($processor eq $conf_processor) ||
+ (($conf_processor eq 'CardFortress') && ($processor eq $bop_options{'gateway'}))
+ ) {
+
+ # processors don't match, so refund already cannot be run on this object,
+ # regardless of what we do now...
+ # but unless we gotta tokenize everything, just leave well enough alone
+ unless ($require_tokenized) {
+ warn "Skipping mismatched processor for $table ".$record->get($record->primary_key) if $debug;
+ next;
+ }
+ ### no error--we'll tokenize using the new gateway, just to remove stored payinfo,
+ ### because refunds are already impossible for this record, anyway
+
+ } # end processor mismatch
+
+ } # end record has processor
+ } # end not cust_pay_pending
+
+ }
+
+ # means no default gateway, no promise to tokenize, can skip
+ unless ($gateway) {
+ warn "Skipping missing gateway for $table ".$record->get($record->primary_key) if $debug;
+ next;
+ }
+
+ my $info = _token_check_gateway_info($cache,$gateway);
+ unless (ref($info)) {
+ # only throws error if Business::OnlinePayment won't load,
+ # which is just cause to abort this whole process, even if queue
+ $dbh->rollback if $oldAutoCommit;
+ die $info; # error message
+ }
+
+ # a configured gateway can't tokenize, move along
+ unless ($info->{'can_tokenize'}) {
+ warn "Skipping, cannot tokenize $table ".$record->get($record->primary_key) if $debug;
+ next;
+ }
+
+ warn "ATTEMPTING GATEWAY-ONLY TOKENIZE" if $debug && !$cust_main;
+
+ # if we got this far, time to mutex
+ $record->select_for_update;
+
+ # no clear record of name/address/etc used for transaction,
+ # but will load name/phone/id from customer if run as an object method,
+ # so we try that if we can
+ my %tokenopts = (
+ 'payment_gateway' => $gateway,
+ 'method' => 'CC',
+ 'payinfo' => $record->payinfo,
+ 'paydate' => $record->paydate,
+ );
+ my $error = $cust_main
+ ? $cust_main->realtime_tokenize(\%tokenopts)
+ : FS::cust_main::Billing_Realtime->realtime_tokenize(\%tokenopts);
+ if (FS::payinfo_Mixin->tokenized($tokenopts{'payinfo'})) { # implies no error
+ $record->payinfo($tokenopts{'payinfo'});
+ $error = $record->replace;
+ } else {
+ $error ||= 'Unknown error';
+ }
+ if ($error) {
+ $error = "Error tokenizing $table ".$record->get($record->primary_key).": ".$error;
+ if ($opt{'queue'}) {
+ $log->critical($error);
+ $dbh->commit or die $dbh->errstr; # commit log message, release mutex
+ next;
+ }
+ $dbh->rollback if $oldAutoCommit;
+ die $error;
+ }
+ $dbh->commit or die $dbh->errstr if $opt{'queue'}; # release mutex
+ warn "TOKENIZED $table ".$record->get($record->primary_key) if $debug;
+
+ } # end record loop
+ } # end table loop
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ return '';
+}
+
+# not a method!
+sub _token_check_next_recnum {
+ my ($dbh,$table,$step,$offset,$recnums) = @_;
+ my $recnum = shift @$recnums;
+ return $recnum if $recnum;
+ my $tclass = 'FS::'.$table;
+ my $sth = $dbh->prepare('SELECT '.$tclass->primary_key.' FROM '.$table.' ORDER BY '.$tclass->primary_key.' LIMIT '.$step.' OFFSET '.$$offset) or die $dbh->errstr;
+ $sth->execute() or die $sth->errstr;
+ my @recnums;
+ while (my $rec = $sth->fetchrow_hashref) {
+ push @$recnums, $rec->{$tclass->primary_key};
+ }
+ $sth->finish();
+ $$offset += $step;
+ return shift @$recnums;
+}
+
+# not a method!
+sub _token_check_gateway_info {
+ my ($cache,$payment_gateway) = @_;
+
+ return $cache->{$payment_gateway->gateway_module}
+ if $cache->{$payment_gateway->gateway_module};
+
+ my $info = {};
+ $cache->{$payment_gateway->gateway_module} = $info;
+
+ my $namespace = $payment_gateway->gateway_namespace;
+ return $info unless $namespace eq 'Business::OnlinePayment';
+ $info->{'is_bop'} = 1;
+
+ # only need to load this once,
+ # don't want to load if nothing is_bop
+ unless ($cache->{'Business::OnlinePayment'}) {
+ eval "use $namespace";
+ return "Error initializing Business:OnlinePayment: ".$@ if $@;
+ $cache->{'Business::OnlinePayment'} = 1;
+ }
+
+ my $transaction = new $namespace( $payment_gateway->gateway_module,
+ _bop_options({ 'payment_gateway' => $payment_gateway }),
+ );
+
+ return $info unless $transaction->can('info');
+ $info->{'can_info'} = 1;
+
+ my %supported_actions = $transaction->info('supported_actions');
+ $info->{'can_tokenize'} = 1
+ if $supported_actions{'CC'}
+ && grep /^Tokenize$/, @{$supported_actions{'CC'}};
+
+ # not using this any more, but for future reference...
+ $info->{'void_requires_card'} = 1
+ if $transaction->info('CC_void_requires_card');
+
+ return $info;
+}
+
=back
=head1 BUGS
-Not autoloaded.
-
=head1 SEE ALSO
L<FS::cust_main>, L<FS::cust_main::Billing>
diff --git a/FS/FS/cust_pay.pm b/FS/FS/cust_pay.pm
index d45d2e3..eed735a 100644
--- a/FS/FS/cust_pay.pm
+++ b/FS/FS/cust_pay.pm
@@ -548,7 +548,8 @@ otherwise returns false.
sub replace {
my $self = shift;
- return "Can't modify closed payment" if $self->closed =~ /^Y/i;
+ return "Can't modify closed payment"
+ if $self->closed =~ /^Y/i && !$FS::payinfo_Mixin::allow_closed_replace;
$self->SUPER::replace(@_);
}
diff --git a/FS/FS/cust_refund.pm b/FS/FS/cust_refund.pm
index 4affb15..adc19f7 100644
--- a/FS/FS/cust_refund.pm
+++ b/FS/FS/cust_refund.pm
@@ -285,7 +285,8 @@ otherwise returns false.
sub replace {
my $self = shift;
- return "Can't modify closed refund" if $self->closed =~ /^Y/i;
+ return "Can't modify closed refund"
+ if $self->closed =~ /^Y/i && !$FS::payinfo_Mixin::allow_closed_replace;
$self->SUPER::replace(@_);
}
diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm
index 7a59ea7..788cfff 100644
--- a/FS/FS/log_context.pm
+++ b/FS/FS/log_context.pm
@@ -5,11 +5,13 @@ use base qw( FS::Record );
use FS::Record qw( qsearch qsearchs );
my @contexts = ( qw(
- test
bill_and_collect
FS::cust_main::Billing::bill_and_collect
FS::cust_main::Billing::bill
+ FS::cust_main::Billing_Realtime::realtime_bop
+ FS::cust_main::Billing_Realtime::realtime_tokenize
FS::cust_main::Billing_Realtime::realtime_verify_bop
+ FS::cust_main::Billing_Realtime::token_check
FS::part_pkg
FS::Misc::Geo::standardize_uscensus
FS::saved_search::send
@@ -24,6 +26,7 @@ my @contexts = ( qw(
upgrade_taxable_billpkgnum
freeside-paymentech-upload
freeside-paymentech-download
+ test
) );
=head1 NAME
diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm
index 4da40e3..9f3ac16 100644
--- a/FS/FS/payinfo_Mixin.pm
+++ b/FS/FS/payinfo_Mixin.pm
@@ -8,7 +8,8 @@ use FS::UID qw(driver_name);
use FS::Cursor;
use Time::Local qw(timelocal);
-use vars qw($ignore_masked_payinfo);
+# allow_closed_replace only relevant to cust_pay/cust_refund, for upgrade tokenizing
+use vars qw( $ignore_masked_payinfo $allow_closed_replace );
=head1 NAME
@@ -67,8 +68,9 @@ sub payinfo {
my($self,$payinfo) = @_;
if ( defined($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 $payinfo =~ /^99\d{14}$/; #token
+ $self->paymask($self->mask_payinfo) unless $self->tokenized($payinfo); #remask unless tokenizing
} else {
$self->getfield('payinfo');
}
@@ -133,7 +135,7 @@ sub mask_payinfo {
# Check to see if it's encrypted...
if ( ref($self) && $self->is_encrypted($payinfo) ) {
return 'N/A';
- } elsif ( $payinfo =~ /^99\d{14}$/ || $payinfo eq 'N/A' ) { #token
+ } elsif ( $self->tokenized($payinfo) || $payinfo eq 'N/A' ) { #token
return 'N/A (tokenized)'; #?
} else { # if not, mask it...
@@ -201,7 +203,7 @@ sub payinfo_check {
my $payinfo = $self->payinfo;
my $cardtype = cardtype($payinfo);
- $cardtype = 'Tokenized' if $payinfo =~ /^99\d{14}$/;
+ $cardtype = 'Tokenized' if $self->tokenized;
$self->set('paycardtype', $cardtype);
if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) {
@@ -216,7 +218,7 @@ sub payinfo_check {
validate($self->payinfo) or return "Illegal credit card number";
return "Unknown card type" if $cardtype eq "Unknown";
} else {
- $self->payinfo('N/A'); #???
+ $self->payinfo('N/A'); #??? re-masks card
}
}
} else {
@@ -236,6 +238,7 @@ sub payinfo_check {
}
}
+ return '';
}
=item payby_payinfo_pretty [ LOCALE ]
@@ -342,6 +345,21 @@ sub upgrade_set_cardtype {
}
}
+=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
diff --git a/FS/FS/payment_gateway.pm b/FS/FS/payment_gateway.pm
index d2695ed..d0272cd 100644
--- a/FS/FS/payment_gateway.pm
+++ b/FS/FS/payment_gateway.pm
@@ -332,6 +332,119 @@ sub processor {
}
}
+=item default_gateway OPTIONS
+
+Class method.
+
+Returns default gateway (from business-onlinepayment conf) as a payment_gateway object.
+
+Accepts options
+
+conf - existing conf object
+
+nofatal - return blank instead of dying if no default gateway is configured
+
+method - if set to CHEK or ECHECK, returns object for business-onlinepayment-ach if available
+
+Before using this, be sure you wouldn't rather be using L</by_key_or_default> or,
+more likely, L<FS::agent/payment_gateway>.
+
+=cut
+
+# the standard settings from the config could be moved to a null agent
+# agent_payment_gateway referenced payment_gateway
+
+sub default_gateway {
+ my ($self,%options) = @_;
+
+ $options{'conf'} ||= new FS::Conf;
+ my $conf = $options{'conf'};
+
+ unless ( $conf->exists('business-onlinepayment') ) {
+ if ( $options{'nofatal'} ) {
+ return '';
+ } else {
+ die "Real-time processing not enabled\n";
+ }
+ }
+
+ #load up config
+ my $bop_config = 'business-onlinepayment';
+ $bop_config .= '-ach'
+ if ( $options{method}
+ && $options{method} =~ /^(ECHECK|CHEK)$/
+ && $conf->exists($bop_config. '-ach')
+ );
+ my ( $processor, $login, $password, $action, @bop_options ) =
+ $conf->config($bop_config);
+ $action ||= 'normal authorization';
+ pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
+ die "No real-time processor is enabled - ".
+ "did you set the business-onlinepayment configuration value?\n"
+ unless $processor;
+
+ my $payment_gateway = new FS::payment_gateway;
+ $payment_gateway->gateway_namespace( $conf->config('business-onlinepayment-namespace') ||
+ 'Business::OnlinePayment');
+ $payment_gateway->gateway_module($processor);
+ $payment_gateway->gateway_username($login);
+ $payment_gateway->gateway_password($password);
+ $payment_gateway->gateway_action($action);
+ $payment_gateway->set('options', [ @bop_options ]);
+ return $payment_gateway;
+}
+
+=item by_key_with_namespace GATEWAYNUM
+
+Like usual by_key, but makes sure namespace is set,
+and dies if not found.
+
+=cut
+
+sub by_key_with_namespace {
+ my $self = shift;
+ my $payment_gateway = $self->by_key(@_);
+ die "payment_gateway not found"
+ unless $payment_gateway;
+ $payment_gateway->gateway_namespace('Business::OnlinePayment')
+ unless $payment_gateway->gateway_namespace;
+ return $payment_gateway;
+}
+
+=item by_key_or_default OPTIONS
+
+Either returns the gateway specified by option gatewaynum, or the default gateway.
+
+Accepts the same options as L</default_gateway>.
+
+Also ensures that the gateway_namespace has been set.
+
+=cut
+
+sub by_key_or_default {
+ my ($self,%options) = @_;
+
+ if ($options{'gatewaynum'}) {
+ return $self->by_key_with_namespace($options{'gatewaynum'});
+ } else {
+ return $self->default_gateway(%options);
+ }
+}
+
+# if it weren't for the way gateway_namespace default is set, this method would not be necessary
+# that should really go in check() with an accompanying upgrade, so we could just use qsearch safely,
+# but currently short on time to test deeper changes...
+#
+# if no default gateway is set and nofatal is passed, first value returned is blank string
+sub all_gateways {
+ my ($self,%options) = @_;
+ my @out;
+ foreach my $gatewaynum ('',( map {$_->gatewaynum} qsearch('payment_gateway') )) {
+ push @out, $self->by_key_or_default( %options, gatewaynum => $gatewaynum );
+ }
+ return @out;
+}
+
# _upgrade_data
#
# Used by FS::Upgrade to migrate to a new database.
diff --git a/FS/bin/freeside-daily b/FS/bin/freeside-daily
index e21569d..7aec3bf 100755
--- a/FS/bin/freeside-daily
+++ b/FS/bin/freeside-daily
@@ -92,7 +92,7 @@ use FS::Cron::backup qw(backup);
backup();
#except we'd rather not start cleanup jobs until the backup is done
-cleanup();
+cleanup( quiet => !$opt{'v'} );
$log->info('finish');
diff --git a/FS/t/suite/13-tokenization.t b/FS/t/suite/13-tokenization.t
new file mode 100644
index 0000000..019b61c
--- /dev/null
+++ b/FS/t/suite/13-tokenization.t
@@ -0,0 +1,224 @@
+#!/usr/bin/perl
+
+use strict;
+use FS::Test;
+use Test::More;
+use FS::Conf;
+use FS::cust_main;
+use Business::CreditCard qw(generate_last_digit);
+use DateTime;
+if ( stat('/usr/local/etc/freeside/cardfortresstest.txt') ) {
+ plan tests => 21;
+} else {
+ plan skip_all => 'CardFortress test encryption key is not installed.';
+}
+
+### can only run on test database (company name "Freeside Test")
+### will run upgrade, which uses lots of prints & warns beyond regular test output
+
+my $fs = FS::Test->new( user => 'admin' );
+my $conf = FS::Conf->new;
+my $err;
+my $bopconf;
+
+like( $conf->config('company_name'), qr/^Freeside Test/, 'using test database' ) or BAIL_OUT('');
+
+# upgrade just schema, or v3 test db cannot create refunds
+$err = system('freeside-upgrade','-s','admin');
+ok( !$err, 'schema upgrade' ) or BAIL_OUT('Error string: '.$!);
+
+# some pre-upgrade cleanup, upgrade will fail if these are still configured
+foreach my $cust_main ( $fs->qsearch('cust_main') ) {
+ my @count = $fs->qsearch('agent_payment_gateway', { agentnum => $cust_main->agentnum } );
+ if (@count > 1) {
+ note("DELETING CARDTYPE GATEWAYS");
+ foreach my $apg (@count) {
+ $err = $apg->delete if $apg->cardtype;
+ last if $err;
+ }
+ @count = $fs->qsearch('agent_payment_gateway', { agentnum => $cust_main->agentnum } );
+ if (@count > 1) {
+ $err = "Still found ".@count." gateways for custnum ".$cust_main->custnum;
+ last;
+ }
+ }
+}
+ok( !$err, "remove obsolete payment gateways" ) or BAIL_OUT($err);
+
+$bopconf =
+'IPPay
+TESTTERMINAL';
+$conf->set('business-onlinepayment' => $bopconf);
+is( join("\n",$conf->config('business-onlinepayment')), $bopconf, "setting first default gateway" ) or BAIL_OUT('');
+
+# generate a few void/refund records for upgrading
+my $counter = 20;
+foreach my $cust_pay ( $fs->qsearch('cust_pay',{ payby => 'CARD' }) ) {
+ if ($counter % 2) {
+ $err = $cust_pay->void('Testing');
+ $err = "Voiding: $err" if $err;
+ } else {
+ if ($fs->qsearch('cust_refund',{ source_paynum => $cust_pay->paynum })) {
+ note('refund skipping cust_pay '.$cust_pay->paynum.', already refunded');
+ next;
+ }
+ # from realtime_refund_bop, just the important bits
+ while ( $cust_pay->unapplied < $cust_pay->paid ) {
+ my @cust_bill_pay = $cust_pay->cust_bill_pay;
+ last unless @cust_bill_pay;
+ my $cust_bill_pay = pop @cust_bill_pay;
+ $err = $cust_bill_pay->delete;
+ $err = "Refund unapply: $err" if $err;
+ last if $err;
+ }
+ last if $err;
+ my $cust_refund = new FS::cust_refund ( {
+ 'custnum' => $cust_pay->cust_main->custnum,
+ 'paynum' => $cust_pay->paynum,
+ 'source_paynum' => $cust_pay->paynum,
+ 'refund' => $cust_pay->paid,
+ '_date' => '',
+ 'payby' => $cust_pay->payby,
+ 'payinfo' => $cust_pay->payinfo,
+ 'reason' => 'Testing',
+ 'gatewaynum' => $cust_pay->gatewaynum,
+ 'processor' => $cust_pay->payment_gateway ? $cust_pay->payment_gateway->processor : '',
+ 'auth' => $cust_pay->auth,
+ 'order_number' => $cust_pay->order_number,
+ } );
+ $err = $cust_refund->insert( reason_type => 'Refund' );
+ $err = "Refunding ".$cust_pay->paynum.": $err" if $err;
+ }
+ last if $err;
+ $counter -= 1;
+ last unless $counter > 0;
+}
+$err ||= 'not enough records' if $counter;
+ok( !$err, "create some refunds and voids" ) or BAIL_OUT($err);
+
+# also, just to test behavior in this case, create a record for an aborted
+# verification payment. this will have no customer number.
+
+my $pending_failed = FS::cust_pay_pending->new({
+ 'custnum_pending' => 1,
+ 'paid' => '1.00',
+ '_date' => time - 86400,
+ random_card(),
+ 'status' => 'failed',
+ 'statustext' => 'Tokenization upgrade test',
+});
+$err = $pending_failed->insert;
+ok( !$err, "create a failed payment attempt" ) or BAIL_OUT($err);
+
+# find two stored credit cards, and one stored checking account (to overwrite with CARD)
+my @cust = (
+ $fs->qsearch({ table=>'cust_main', hashref=>{payby=>'CARD'}, extra_sql=>' LIMIT 2' }),
+ $fs->qsearch({ table=>'cust_main', hashref=>{payby=>'CHEK'}, extra_sql=>' LIMIT 1' }),
+);
+my @payment;
+
+ok( $cust[0]->payby eq 'CARD' && !$cust[0]->tokenized,
+ "first customer has a non-tokenized card"
+ ) or BAIL_OUT();
+
+$err = $cust[0]->realtime_bop({method => 'CC', amount => '2.00'});
+ok( !$err, "create a payment through IPPay" )
+ or BAIL_OUT($err);
+$payment[0] = $fs->qsearchs('cust_pay', { custnum => $cust[0]->custnum,
+ paid => '2.00' })
+ or BAIL_OUT("can't find payment record");
+
+$err = system('freeside-upgrade','admin');
+ok( !$err, 'initial upgrade' ) or BAIL_OUT('Error string: '.$!);
+
+# switch to CardFortress
+$bopconf =
+'CardFortress
+cardfortresstest
+(TEST54)
+Normal Authorization
+gateway
+IPPay
+gateway_login
+TESTTERMINAL
+gateway_password
+
+private_key
+/usr/local/etc/freeside/cardfortresstest.txt';
+$conf->set('business-onlinepayment' => $bopconf);
+is( join("\n",$conf->config('business-onlinepayment')), $bopconf, "setting tokenizable default gateway" ) or BAIL_OUT('');
+
+foreach my $pg ($fs->qsearch('payment_gateway')) {
+ unless ($pg->gateway_module eq 'CardFortress') {
+ note('UPGRADING NON-CF PAYMENT GATEWAY');
+ my %pgopts = (
+ gateway => $pg->gateway_module,
+ gateway_login => $pg->gateway_username,
+ gateway_password => $pg->gateway_password,
+ private_key => '/usr/local/etc/freeside/cardfortresstest.txt',
+ );
+ $pg->gateway_module('CardFortress');
+ $pg->gateway_username('cardfortresstest');
+ $pg->gateway_password('(TEST54)');
+ $err = $pg->replace(\%pgopts);
+ last if $err;
+ }
+}
+ok( !$err, "remove non-CF payment gateways" ) or BAIL_OUT($err);
+
+# create a payment using a non-tokenized card. this should immediately
+# trigger tokenization.
+ok( $cust[1]->payby eq 'CARD' && ! $cust[1]->tokenized,
+ "second customer has a non-tokenized card"
+ ) or BAIL_OUT();
+
+$err = $cust[1]->realtime_bop({method => 'CC', amount => '3.00'});
+ok( !$err, "tokenize a card when it's first used for payment" )
+ or BAIL_OUT($err);
+$payment[1] = $fs->qsearchs('cust_pay', { custnum => $cust[1]->custnum,
+ paid => '3.00' })
+ or BAIL_OUT("can't find payment record");
+ok( $payment[1]->tokenized, "payment is tokenized" );
+$cust[1] = $cust[1]->replace_old;
+ok( $cust[1]->tokenized, "card is now tokenized" );
+
+
+# invoke the part of freeside-upgrade that tokenizes
+FS::cust_main->queueable_upgrade();
+#$err = system('freeside-upgrade','admin');
+#ok( !$err, 'tokenizable upgrade' ) or BAIL_OUT('Error string: '.$!);
+
+$cust[0] = $cust[0]->replace_old;
+ok( $cust[0]->tokenized, "old card was tokenized during upgrade" );
+$payment[0] = $payment[0]->replace_old;
+ok( $payment[0]->tokenized, "old payment was tokenized during upgrade" );
+my $old_pending = $fs->qsearchs('cust_pay_pending',{ paynum => $payment[0]->paynum });
+ok( $old_pending->tokenized, "old cust_pay_pending was tokenized during upgrade" );
+
+$pending_failed = $pending_failed->replace_old;
+ok( $pending_failed->tokenized, "cust_pay_pending with no customer was tokenized" );
+
+# add a new payment card to one customer
+my %newcard = random_card();
+$cust[2]->$_($newcard{$_}) foreach keys %newcard;
+$err = $cust[2]->replace;
+ok( !$err, "new card was saved" ) or BAIL_OUT($err);
+ok($cust[2]->tokenized, "new card is tokenized" );
+
+sub random_card {
+ my $payinfo = '4111' . join('', map { int(rand(10)) } 1 .. 11);
+ $payinfo .= generate_last_digit($payinfo);
+ my $paydate = DateTime->now
+ ->add('years' => 1)
+ ->truncate(to => 'month')
+ ->strftime('%F');
+ return ( 'payby' => 'CARD',
+ 'payinfo' => $payinfo,
+ 'paydate' => $paydate,
+ 'payname' => 'Tokenize Me',
+ );
+}
+
+1;
+
+
diff --git a/httemplate/browse/agent.cgi b/httemplate/browse/agent.cgi
index ae8c618..23da3af 100755
--- a/httemplate/browse/agent.cgi
+++ b/httemplate/browse/agent.cgi
@@ -38,7 +38,7 @@ full offerings (via their type).<BR><BR>
<TH CLASS="grid" BGCOLOR="#cccccc">Ticketing</TH>
% }
- <TH CLASS="grid" BGCOLOR="#cccccc"><FONT SIZE=-1>Payment Gateway Overrides</FONT></TH>
+ <TH CLASS="grid" BGCOLOR="#cccccc"><FONT SIZE=-1>Payment Gateway Override</FONT></TH>
<TH CLASS="grid" BGCOLOR="#cccccc"><FONT SIZE=-1>Configuration Overrides</FONT></TH>
</TR>
@@ -317,31 +317,24 @@ Unused
% ##
-% # payment gateway overrides
+% # payment gateway override
% ##
<TD CLASS="inv" BGCOLOR="<% $bgcolor %>">
<TABLE CLASS="inv" CELLSPACING=0 CELLPADDING=0>
-% foreach my $override (
-% # sort { } want taxclass-full stuff first? and default cards (empty cardtype)
-% qsearch('agent_payment_gateway', { 'agentnum' => $agent->agentnum } )
-% ) {
-%
+% my $gw_override = qsearchs('agent_payment_gateway', { 'agentnum' => $agent->agentnum } );
+% if ($gw_override) {
<TR>
<TD>
- <% $override->cardtype || 'Default' %> to <% $override->payment_gateway->gateway_module %> (<% $override->payment_gateway->gateway_username %>)
- <% $override->taxclass
- ? ' for '. $override->taxclass. ' only'
- : ''
- %>
- <FONT SIZE=-1><A HREF="javascript:areyousure('delete this payment gateway override', '<%$p%>misc/delete-agent_payment_gateway.cgi?<% $override->agentgatewaynum %>')">(delete)</A></FONT>
+ <% $gw_override->payment_gateway->gateway_module %> (<% $gw_override->payment_gateway->gateway_username %>)
+ <FONT SIZE=-1><A HREF="javascript:areyousure('delete this payment gateway override', '<%$p%>misc/delete-agent_payment_gateway.cgi?<% $gw_override->agentgatewaynum %>')">(delete)</A></FONT>
</TD>
</TR>
-% }
-
+% } else {
<TR>
<TD><FONT SIZE=-1><A HREF="<%$p%>edit/agent_payment_gateway.html?agentnum=<% $agent->agentnum %>">(add override)</A></FONT></TD>
</TR>
+% }
</TABLE>
</TD>
diff --git a/httemplate/edit/agent_payment_gateway.html b/httemplate/edit/agent_payment_gateway.html
index 41a9f3e..753bc76 100644
--- a/httemplate/edit/agent_payment_gateway.html
+++ b/httemplate/edit/agent_payment_gateway.html
@@ -1,6 +1,6 @@
<% include("/elements/header.html","$action payment gateway override for ". $agent->agent, menubar(
#'View all payment gateways' => $p. 'browse/payment_gateway.html',
- 'View all agents' => $p. 'browse/agent.html',
+ 'View all agents' => $p. 'browse/agent.cgi',
)) %>
<% include('/elements/error.html') %>
@@ -20,32 +20,6 @@ Use gateway <SELECT NAME="gatewaynum">
</SELECT>
<BR><BR>
-for <SELECT NAME="cardtype" MULTIPLE>
-% foreach my $cardtype (
-% "",
-% "VISA card",
-% "MasterCard",
-% "Discover card",
-% "American Express card",
-% "Diner's Club/Carte Blanche",
-% "enRoute",
-% "JCB",
-% "BankCard",
-% "Switch",
-% "Solo",
-% 'ACH',
-% 'PayPal',
-%) {
-
- <OPTION VALUE="<% $cardtype %>"><% $cardtype || '(Default fallback)' %>
-% }
-
-</SELECT>
-<BR><BR>
-
-(optional) when invoice contains only items of taxclass <INPUT TYPE="text" NAME="taxclass">
-<BR><BR>
-
<INPUT TYPE="submit" VALUE="Add gateway override">
</FORM>
diff --git a/httemplate/edit/elements/edit.html b/httemplate/edit/elements/edit.html
index 5bee788..b521cd0 100644
--- a/httemplate/edit/elements/edit.html
+++ b/httemplate/edit/elements/edit.html
@@ -238,7 +238,7 @@ Example:
>
<INPUT TYPE="hidden" NAME="svcdb" VALUE="<% $table %>">
- <INPUT TYPE="hidden" NAME="<% $pkey %>" VALUE="<% $clone ? '' : $object->$pkey() %>">
+ <INPUT TYPE="hidden" ID="<% $pkey %>" NAME="<% $pkey %>" VALUE="<% $clone ? '' : $object->$pkey() %>">
<FONT SIZE="+1"><B>
<% ( $opt{labels} && exists $opt{labels}->{$pkey} )
diff --git a/httemplate/edit/payment_gateway.html b/httemplate/edit/payment_gateway.html
index b28be23..2c7d315 100644
--- a/httemplate/edit/payment_gateway.html
+++ b/httemplate/edit/payment_gateway.html
@@ -22,6 +22,9 @@
<SCRIPT TYPE="text/javascript">
var modulesForNamespace = <% $json->encode(\%modules) %>;
function changeNamespace() {
+ if (document.getElementById('gatewaynum').value) {
+ return true;
+ }
var ns = document.getElementById('gateway_namespace').value;
var select_module = document.getElementById('gateway_module');
select_module.options.length = 0;
@@ -180,7 +183,13 @@ my $field_callback = sub {
my ($cgi, $object, $field_hashref ) = @_;
if ($object->gatewaynum) {
if ( $field_hashref->{field} eq 'gateway_module' ) {
- $field_hashref->{type} = 'fixed';
+ if ($object->gateway_namespace eq 'Business::OnlinePayment' &&
+ $object->gateway_module ne 'CardFortress'
+ ) {
+ $field_hashref->{options} = [ $object->gateway_module, 'CardFortress' ]
+ } else {
+ $field_hashref->{type} = 'fixed';
+ }
} elsif ( $field_hashref->{field} eq 'gateway_namespace' ) {
$field_hashref->{type} = 'fixed';
$field_hashref->{formatted_value} = $object->namespace_description;
diff --git a/httemplate/edit/process/agent_payment_gateway.html b/httemplate/edit/process/agent_payment_gateway.html
index 5b5fd94..c9789cf 100644
--- a/httemplate/edit/process/agent_payment_gateway.html
+++ b/httemplate/edit/process/agent_payment_gateway.html
@@ -10,20 +10,13 @@ die "agentnum $1 not found" unless $agent;
#my $old
-my @new = map {
- my $cardtype = $_;
- new FS::agent_payment_gateway {
+my $new = new FS::agent_payment_gateway {
( map { $_ => scalar($cgi->param($_)) }
fields('agent_payment_gateway')
),
- 'cardtype' => $cardtype,
};
- }
- $cgi->param('cardtype');
-foreach my $new (@new) {
- my $error = $new->insert;
- die $error if $error;
-}
+my $error = $new->insert;
+die $error if $error;
</%init>
diff --git a/httemplate/misc/process/payment.cgi b/httemplate/misc/process/payment.cgi
index 8c12b4d..75c89f2 100644
--- a/httemplate/misc/process/payment.cgi
+++ b/httemplate/misc/process/payment.cgi
@@ -110,7 +110,7 @@ if ( $payby eq 'CHEK' ) {
validate($payinfo)
or errorpage(gettext('invalid_card'));
- unless ( $payinfo =~ /^99\d{14}$/ ) { #token
+ unless ( $cust_main->tokenized($payinfo) ) { #token
my $cardtype = cardtype($payinfo);
@@ -187,6 +187,11 @@ if ( $cgi->param('save') ) {
errorpage("error saving info, payment not processed: $error")
if $error;
$cust_main = $new;
+
+} elsif ( $payby eq 'CARD' ) { # not saving
+
+ $paymask = FS::payinfo_Mixin->mask_payinfo('CARD',$payinfo); # for untokenized but tokenizable payinfo
+
}
my $error = '';