+=item realtime_tokenize [ OPTION => VALUE ... ]
+
+If possible and necessary, runs a tokenize transaction.
+In order to be possible, a credit card cust_payby record
+must be passed 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.
+
+Option I<cust_payby> may be passed, even if it's not yet been inserted.
+Object 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.
+
+Can be run as a class method if option I<payment_gateway> is passed,
+but default customer id/name/phone 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 cust_payby/payinfo
+ if (ref($_[0]) eq 'HASH') {
+ %options = %{$_[0]};
+ $outoptions = $_[0];
+ } else {
+ %options = @_;
+ $outoptions = \%options;
+ }
+
+ # set fields from passed cust_payby
+ _bop_cust_payby_options(\%options);
+ return '' unless $options{method} eq 'CC';
+ return '' if $self->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'}
+ && 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_payby here,
+ #because cust_payby->replace uses realtime_tokenize!
+ $self->_tokenize_card($transaction,$outoptions);
+
+ } else {
+
+ $error = $transaction->error_message || 'Unknown error when tokenizing card';
+
+ }
+
+ return $error;
+
+}
+
+
+=item tokenized PAYINFO
+
+Convenience wrapper for L<FS::payinfo_Mixin/tokenized>
+
+PAYINFO is required.
+
+Can be run as class or object method, never loads from object.
+
+=cut
+
+sub tokenized {
+ my $this = shift;
+ my $payinfo = shift;
+ FS::cust_pay->tokenized($payinfo);
+}
+
+=item token_check
+
+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_payby and
+CARD transactions in cust_pay_pending, cust_pay, cust_pay_void and cust_refund.
+
+If all configured gateways have the ability to tokenize, then detection of
+an untokenizable record will cause a fatal error.
+
+=cut
+
+sub token_check {
+ # no input, acts on all customers
+
+ eval "use FS::Cursor";
+ return "Error initializing FS::Cursor: ".$@ if $@;
+
+ my $dbh = dbh;
+
+ # get list of all gateways in table (not counting default gateway)
+ my $cache = {}; #cache for module info
+ my $sth = $dbh->prepare('SELECT DISTINCT gatewaynum FROM payment_gateway')
+ or die $dbh->errstr;
+ $sth->execute or die $sth->errstr;
+ my @gatewaynums;
+ while (my $row = $sth->fetchrow_hashref) {
+ push(@gatewaynums,$row->{'gatewaynum'});
+ }
+ $sth->finish;
+
+ # look for a gateway that can't tokenize
+ my $disallow_untokenized = 1;
+ foreach my $gatewaynum ('',@gatewaynums) {
+ my $gateway = FS::agent->payment_gateway( load_gatewaynum => $gatewaynum, nofatal => 1 );
+ if (!$gateway) { # already died if $gatewaynum
+ # no default gateway, no promise to tokenize
+ # can just load other gateways as-needeed below
+ $disallow_untokenized = 0;
+ last;
+ }
+ my $info = _token_check_gateway_info($cache,$gateway);
+ return $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
+ $disallow_untokenized = 0;
+ last;
+ }
+ }
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+
+ ### Tokenize cust_payby
+
+ my $cust_search = FS::Cursor->new({ table => 'cust_main' },$dbh);
+ while (my $cust_main = $cust_search->fetch) {
+ foreach my $cust_payby ($cust_main->cust_payby('CARD','DCRD')) {
+ next if $cust_payby->tokenized;
+ # load gateway first, just so we can cache it
+ my $payment_gateway = $cust_main->_payment_gateway({
+ 'nofatal' => 1, # handle error smoothly below
+ });
+ unless ($payment_gateway) {
+ # no reason to have untokenized card numbers saved if no gateway,
+ # but only fatal if we expected everyone to tokenize card numbers
+ next unless $disallow_untokenized;
+ $cust_search->DESTROY;
+ $dbh->rollback if $oldAutoCommit;
+ return "No gateway found for custnum ".$cust_main->custnum;
+ }
+ my $info = _token_check_gateway_info($cache,$payment_gateway);
+ # no fail here--a configured gateway can't tokenize, so be it
+ next unless ref($info) && $info->{'can_tokenize'};
+ my %tokenopts = (
+ 'payment_gateway' => $payment_gateway,
+ 'cust_payby' => $cust_payby,
+ );
+ my $error = $cust_main->realtime_tokenize(\%tokenopts);
+ if ($cust_payby->tokenized) { # implies no error
+ $error = $cust_payby->replace;
+ } else {
+ $error ||= 'Unknown error';
+ }
+ if ($error) {
+ $cust_search->DESTROY;
+ $dbh->rollback if $oldAutoCommit;
+ return "Error tokenizing cust_payby ".$cust_payby->custpaybynum.": ".$error;
+ }
+ }
+ }
+
+ ### 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) ) {
+ my $search = FS::Cursor->new({
+ table => $table,
+ hashref => { 'payby' => 'CARD' },
+ },$dbh);
+ while (my $record = $search->fetch) {
+ next if $record->tokenized;
+ next if !$record->payinfo; #shouldn't happen, but at least it's not a card number
+ next if $record->payinfo =~ /N\/A/; # ??? Not sure why we do this, but it's not a card number
+
+ # don't use customer agent gateway here, use the gatewaynum specified by the record
+ my $gatewaynum = $record->gatewaynum || '';
+ my $gateway = FS::agent->payment_gateway( load_gatewaynum => $gatewaynum );
+ unless ($gateway) { # already died if $gatewaynum
+ # only fatal if we expected everyone to tokenize
+ next unless $disallow_untokenized;
+ $search->DESTROY;
+ $dbh->rollback if $oldAutoCommit;
+ return "No gateway found for $table ".$record->get($record->primary_key);
+ }
+ 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
+ $search->DESTROY;
+ $dbh->rollback if $oldAutoCommit;
+ return $info; # error message
+ }
+
+ # a configured gateway can't tokenize, move along
+ next unless $info->{'can_tokenize'};
+
+ my $cust_main = $record->cust_main;
+ unless ($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
+ $table eq 'cust_pay_pending'
+ && $record->{'custnum_pending'}
+ && !$disallow_untokenized
+ )) {
+ $search->DESTROY;
+ $dbh->rollback if $oldAutoCommit;
+ return "Could not load cust_main for $table ".$record->get($record->primary_key);
+ }
+ # 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::cust_main::Billing_Realtime->tokenized($tokenopts{'payinfo'})) { # implies no error
+ $record->payinfo($tokenopts{'payinfo'});
+ $error = $record->replace;
+ } else {
+ $error ||= 'Unknown error';
+ }
+ if ($error) {
+ $search->DESTROY;
+ $dbh->rollback if $oldAutoCommit;
+ return "Error tokenizing $table ".$record->get($record->primary_key).": ".$error;
+ }
+ } # end record loop
+ } # end table loop
+
+ $dbh->commit if $oldAutoCommit;
+
+ return '';
+}
+
+# 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');
+
+ $cache->{$payment_gateway->gateway_module} = $info;
+
+ return $info;
+}
+