From 92aedddd3684167abb60cd3f1d77bbc156c592e6 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Sat, 30 Jun 2012 16:37:06 -0700 Subject: [PATCH] Business::BatchPayment interface, #17373 --- FS/FS/Conf.pm | 64 ++- FS/FS/Cron/pay_batch.pm | 129 ++++++ FS/FS/Upgrade.pm | 3 + FS/FS/cust_pay_batch.pm | 87 ++++ FS/FS/pay_batch.pm | 454 +++++++++++++++++++-- FS/FS/pay_batch/paymentech.pm | 13 + FS/FS/payment_gateway.pm | 75 +++- FS/bin/freeside-daily | 5 + FS/bin/freeside-eftca-upload | 2 +- FS/bin/freeside-paymentech-upload | 2 +- httemplate/browse/payment_gateway.html | 4 +- httemplate/edit/payment_gateway.html | 56 ++- httemplate/misc/download-batch.cgi | 13 +- httemplate/search/elements/cust_pay_batch_top.html | 35 +- httemplate/search/pay_batch.cgi | 20 +- 15 files changed, 868 insertions(+), 94 deletions(-) create mode 100644 FS/FS/Cron/pay_batch.pm diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 13625da25..8c9d56fef 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -690,12 +690,6 @@ my %msg_template_options = ( 'per_agent' => 1, ); -my $_gateway_name = sub { - my $g = shift; - return '' if !$g; - ($g->gateway_username . '@' . $g->gateway_module); -}; - my %payment_gateway_options = ( 'type' => 'select-sub', 'options_sub' => sub { @@ -703,11 +697,24 @@ my %payment_gateway_options = ( 'table' => 'payment_gateway', 'hashref' => { 'disabled' => '' }, }); - map { $_->gatewaynum, $_gateway_name->($_) } @gateways; + map { $_->gatewaynum, $_->label } @gateways; }, 'option_sub' => sub { my $gateway = FS::payment_gateway->by_key(shift); - $_gateway_name->($gateway); + $gateway ? $gateway->label : '' + }, +); + +my %batch_gateway_options = ( + %payment_gateway_options, + 'options_sub' => sub { + my @gateways = qsearch('payment_gateway', + { + 'disabled' => '', + 'gateway_namespace' => 'Business::BatchPayment', + } + ); + map { $_->gatewaynum, $_->label } @gateways; }, ); @@ -963,6 +970,13 @@ sub reason_type_options { }, { + 'key' => 'business-batchpayment-test_transaction', + 'section' => 'billing', + 'description' => 'Turns on the Business::BatchPayment test_mode flag. Note that not all gateway modules support this flag; if yours does not, using the batch gateway will fail.', + 'type' => 'checkbox', + }, + + { 'key' => 'countrydefault', 'section' => 'UI', 'description' => 'Default two-letter country code (if not supplied, the default is `US\')', @@ -3403,6 +3417,40 @@ and customer address. Include units.', ] }, + { 'key' => 'batch-gateway-CARD', + 'section' => 'billing', + 'description' => 'Business::BatchPayment gateway for credit card batches.', + %batch_gateway_options, + }, + + { 'key' => 'batch-gateway-CHEK', + 'section' => 'billing', + 'description' => 'Business::BatchPayment gateway for check batches.', + %batch_gateway_options, + }, + + { + 'key' => 'batch-reconsider', + 'section' => 'billing', + 'description' => 'Allow imported batch results to change the status of payments from previous imports. Enable this only if your gateway is known to send both positive and negative results for the same batch.', + 'type' => 'checkbox', + }, + + { + 'key' => 'batch-auto_resolve_days', + 'section' => 'billing', + 'description' => 'Automatically resolve payment batches this many days after they were first downloaded.', + 'type' => 'text', + }, + + { + 'key' => 'batch-auto_resolve_status', + 'section' => 'billing', + 'description' => 'When automatically resolving payment batches, take this action for payments of unknown status.', + 'type' => 'select', + 'select_enum' => [ 'approve', 'decline' ], + }, + #lists could be auto-generated from pay_batch info { 'key' => 'batch-fixed_format-CARD', diff --git a/FS/FS/Cron/pay_batch.pm b/FS/FS/Cron/pay_batch.pm new file mode 100644 index 000000000..c7cedafb9 --- /dev/null +++ b/FS/FS/Cron/pay_batch.pm @@ -0,0 +1,129 @@ +package FS::Cron::pay_batch; + +use strict; +use vars qw( @ISA @EXPORT_OK $me $DEBUG ); +use Exporter; +use Date::Format; +use FS::UID qw(dbh); +use FS::Record qw( qsearch qsearchs ); +use FS::Conf; +use FS::queue; +use FS::agent; + +@ISA = qw( Exporter ); +@EXPORT_OK = qw ( batch_submit batch_receive ); +$DEBUG = 0; +$me = '[FS::Cron::pay_batch]'; + +#freeside-daily %opt: +# -v: enable debugging +# -l: debugging level +# -m: Experimental multi-process mode uses the job queue for multi-process and/or multi-machine billing. +# -r: Multi-process mode dry run option +# -a: Only process customers with the specified agentnum + +sub batch_submit { + my %opt = @_; + local $DEBUG = ($opt{l} || 1) if $opt{v}; + # if anything goes wrong, don't try to roll back previously submitted batches + local $FS::UID::AutoCommit = 1; + + my $dbh = dbh; + + warn "$me batch_submit\n" if $DEBUG; + my $conf = FS::Conf->new; + + # need to respect -a somehow, but for now none of this is per-agent + if ( $opt{a} ) { + warn "Payment batch processing skipped in per-agent mode.\n" if $DEBUG; + return; + } + my %gateways; + foreach my $payby ('CARD', 'CHEK') { + my $gatewaynum = $conf->config("batch-gateway-$payby"); + next if !$gatewaynum; + my $gateway = FS::payment_gateway->by_key($gatewaynum) + or die "payment_gateway '$gatewaynum' not found\n"; + + if ( $gateway->batch_processor->can('default_transport') ) { + + foreach my $pay_batch ( + qsearch('pay_batch', { status => 'O', payby => $payby }) + ) { + + warn "Exporting batch ".$pay_batch->batchnum."\n" if $DEBUG; + eval { $pay_batch->export_to_gateway( $gateway, debug => $DEBUG ); }; + + if ( $@ ) { + # warn the error and continue. rolling back the transaction once + # we've started sending batches is bad. + warn "error submitting batch ".$pay_batch->batchnum." to gateway '". + $gateway->label."\n$@\n"; + } + } + + } else { #can't(default_transport) + warn "Payment gateway '".$gateway->label. + "' doesn't support automatic transport; skipped.\n"; + } + } #$payby + + 1; +} + +sub batch_receive { + my %opt = @_; + local $DEBUG = ($opt{l} || 1) if $opt{v}; + local $FS::UID::AutoCommit = 0; + + my $dbh = dbh; + my $error; + + warn "$me batch_receive\n" if $DEBUG; + my $conf = FS::Conf->new; + + # need to respect -a somehow, but for now none of this is per-agent + if ( $opt{a} ) { + warn "Payment batch processing skipped in per-agent mode.\n" if $DEBUG; + return; + } + my %gateways; + foreach my $payby ('CARD', 'CHEK') { + my $gatewaynum = $conf->config("batch-gateway-$payby"); + next if !$gatewaynum; + # If the same gateway is selected for both paybys, only import it once + $gateways{$gatewaynum} = FS::payment_gateway->by_key($gatewaynum); + if ( !$gateways{$gatewaynum} ) { + $dbh->rollback; + die "batch-gateway-$payby gateway $gatewaynum not found\n"; + } + } + + foreach my $gateway (values %gateways) { + if ( $gateway->batch_processor->can('default_transport') ) { + warn "Importing results from '".$gateway->label."'\n" if $DEBUG; + $error = eval { + FS::pay_batch->import_from_gateway( $gateway, debug => $DEBUG ) + } || $@; + if ( $error ) { + # this we can roll back + $dbh->rollback; + die "error receiving from gateway '".$gateway->label."':\n$error\n"; + } + } + # else we already warned about it above + } #$gateway + + # resolve batches if we can + foreach my $pay_batch (qsearch('pay_batch', { status => 'I' })) { + warn "Trying to resolve batch ".$pay_batch->batchnum."\n" if $DEBUG; + $error = $pay_batch->try_to_resolve; + if ( $error ) { + $dbh->rollback; + die "unable to resolve batch ".$pay_batch->batchnum.":\n$error\n"; + } + } + + $dbh->commit; +} +1; diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index aabc4e72f..8d4b34601 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -269,6 +269,9 @@ sub upgrade_data { #routernum/blocknum 'svc_broadband' => [], + + #set up payment gateways if needed + 'pay_batch' => [], ; \%hash; diff --git a/FS/FS/cust_pay_batch.pm b/FS/FS/cust_pay_batch.pm index f5e6a4bf1..5f21ff4b1 100644 --- a/FS/FS/cust_pay_batch.pm +++ b/FS/FS/cust_pay_batch.pm @@ -204,6 +204,35 @@ sub cust_main { qsearchs( 'cust_main', { 'custnum' => $self->custnum } ); } +=item expmmyy + +Returns the credit card expiration date in MMYY format. If this is a +CHEK payment, returns an empty string. + +=cut + +sub expmmyy { + my $self = shift; + if ( $self->payby eq 'CARD' ) { + $self->get('exp') =~ /^(\d{4})-(\d{2})-(\d{2})$/; + return sprintf('%02u%02u', $2, ($1 % 100)); + } + else { + return ''; + } +} + +=item pay_batch + +Returns the payment batch this payment belongs to (Lby_key($self->batchnum); +} + #you know what, screw this in the new world of events. we should be able to #get the event defs to retry (remove once.pm condition, add every.pm) without #mucking about with statuses of previous cust_event records. right? @@ -276,6 +305,8 @@ sub approve { my $paybatchnum = $new->paybatchnum; my $old = qsearchs('cust_pay_batch', { paybatchnum => $paybatchnum }) or return "paybatchnum $paybatchnum not found"; + # leave these restrictions in place until TD EFT is converted over + # to B::BP return "paybatchnum $paybatchnum already resolved ('".$old->status."')" if $old->status; $new->status('Approved'); @@ -364,6 +395,62 @@ sub decline { return; } +=item request_item [ OPTIONS ] + +Returns a L object for this batch payment +entry. This can be submitted to a processor. + +OPTIONS can be a list of key/values to append to the attributes. The most +useful case of this is "process_date" to set a processing date based on the +date the batch is being submitted. + +=cut + +sub request_item { + local $@; + my $self = shift; + + eval "use Business::BatchPayment;"; + die "couldn't load Business::BatchPayment: $@" if $@; + + my $cust_main = $self->cust_main; + my $location = $cust_main->bill_location; + my $pay_batch = $self->pay_batch; + + my %payment; + $payment{payment_type} = FS::payby->payby2bop( $pay_batch->payby ); + if ( $payment{payment_type} eq 'CC' ) { + $payment{card_number} = $self->payinfo, + $payment{expiration} = $self->expmmyy, + } elsif ( $payment{payment_type} eq 'ECHECK' ) { + $self->payinfo =~ /(\d+)@(\d+)/; # or else what? + $payment{account_number} = $1; + $payment{routing_code} = $2; + $payment{account_type} = $cust_main->paytype; + # XXX what if this isn't their regular payment method? + } else { + die "unsupported BatchPayment method: ".$pay_batch->payby; + } + + Business::BatchPayment->create(Item => + # required + action => 'payment', + tid => $self->paybatchnum, + amount => $self->amount, + + # customer info + customer_id => $self->custnum, + first_name => $cust_main->first, + last_name => $cust_main->last, + company => $cust_main->company, + address => $location->address1, + ( map { $_ => $location->$_ } qw(address2 city state country zip) ), + + invoice_number => $self->invnum, + %payment, + ); +} + =back =head1 BUGS diff --git a/FS/FS/pay_batch.pm b/FS/FS/pay_batch.pm index bb92bdf2f..4f223e113 100644 --- a/FS/FS/pay_batch.pm +++ b/FS/FS/pay_batch.pm @@ -10,6 +10,8 @@ use FS::cust_pay; use FS::agent; use Date::Parse qw(str2time); use Business::CreditCard qw(cardtype); +use Scalar::Util 'blessed'; +use IO::Scalar; @ISA = qw(FS::Record); @@ -124,7 +126,7 @@ sub check { =item agent -Returns the L object for this template. +Returns the L object for this batch. =cut @@ -132,6 +134,16 @@ sub agent { qsearchs('agent', { 'agentnum' => $_[0]->agentnum }); } +=item cust_pay_batch + +Returns all L objects for this batch. + +=cut + +sub cust_pay_batch { + qsearch('cust_pay_batch', { 'batchnum' => $_[0]->batchnum }); +} + =item rebalance =cut @@ -198,7 +210,10 @@ Options are: I - open filehandle of results file. -I - "csv-td_canada_trust-merchant_pc_batch", "csv-chase_canada-E-xactBatch", "ach-spiritone", or "PAP" +I - an L module + +I - an L object for a batch gateway. This +takes precedence over I. =cut @@ -207,13 +222,18 @@ sub import_results { my $param = ref($_[0]) ? shift : { @_ }; my $fh = $param->{'filehandle'}; + my $job = $param->{'job'}; + $job->update_statustext(0) if $job; + + my $gateway = $param->{'gateway'}; + if ( $gateway ) { + return $self->import_from_gateway($gateway, 'file' => $fh, 'job' => $job); + } + my $format = $param->{'format'}; my $info = $import_info{$format} or die "unknown format $format"; - my $job = $param->{'job'}; - $job->update_statustext(0) if $job; - my $conf = new FS::Conf; my $filetype = $info->{'filetype'}; # CSV, fixed, variable @@ -254,10 +274,6 @@ sub import_results { my $total = 0; my $line; - # Order of operations has been changed here. - # We now slurp everything into @all_values, then - # process one line at a time. - if ($filetype eq 'XML') { eval "use XML::Simple"; die $@ if $@; @@ -431,6 +447,13 @@ sub process_import_results { my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n"; my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n"; + my $gatewaynum = delete $param->{'gatewaynum'}; + if ( $gatewaynum ) { + $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum) + or die "gatewaynum '$gatewaynum' not found\n"; + delete $param->{'format'}; # to avoid confusion + } + my $file = $param->{'uploaded_files'} or die "no files provided\n"; $file =~ s/^(\w+):([\.\w]+)$/$2/; my $dir = '%%%FREESIDE_CACHE%%%/cache.' . $FS::UID::datasrc; @@ -443,39 +466,258 @@ sub process_import_results { die $error if $error; } -# Formerly httemplate/misc/download-batch.cgi -sub export_batch { - my $self = shift; - my $conf = new FS::Conf; - my $format = shift || $conf->config('batch-default_format') - or die "No batch format configured\n"; - my $info = $export_info{$format} or die "Format not found: '$format'\n"; - &{$info->{'init'}}($conf) if exists($info->{'init'}); +=item import_from_gateway GATEWAY [ OPTIONS ] - my $curuser = $FS::CurrentUser::CurrentUser; +Import results from a L, using Business::BatchPayment, +and apply them. GATEWAY must use the Business::BatchPayment namespace. + +This is a class method, since results can be applied to any batch. +The 'batch-reconsider' option determines whether an already-approved +or declined payment can have its status changed by a later import. + +OPTIONS may include: + +- file: a file name or handle to use as a data source. +- job: an L object to update with progress messages. + +=cut + +sub import_from_gateway { + my $class = shift; + my $gateway = shift; + my %opt = @_; + my $conf = FS::Conf->new; + + # unavoidable duplication with import_batch, for now + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; - my $dbh = dbh; + my $dbh = dbh; + + my $job = delete($opt{'job'}); + $job->update_statustext(0) if $job; + + my $total = 0; + return "import_from_gateway requires a payment_gateway" + unless eval { $gateway->isa('FS::payment_gateway') }; + + my %proc_opt = ( + 'input' => $opt{'file'}, # will do nothing if it's empty + # any other constructor options go here + ); + + my $processor = $gateway->batch_processor(%proc_opt); + + my @batches = $processor->receive; + my $error; + my $num = 0; + + # whether to allow items to change status + my $reconsider = $conf->exists('batch-reconsider'); + + # mutex all affected batches + my %pay_batch_for_update; + + BATCH: foreach my $batch (@batches) { + ITEM: foreach my $item ($batch->elements) { + # cust_pay_batch.paybatchnum should be in the 'tid' attribute + my $paybatchnum = $item->tid; + my $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum); + if (!$cust_pay_batch) { + # XXX for one-way batch protocol this needs to create new payments + $error = "unknown paybatchnum $paybatchnum"; + last ITEM; + } + + my $batchnum = $cust_pay_batch->batchnum; + if ( $batch->batch_id and $batch->batch_id != $batchnum ) { + warn "batch ID ".$batch->batch_id. + " does not match batchnum ".$cust_pay_batch->batchnum."\n"; + } + + # lock the batch and check its status + my $pay_batch = FS::pay_batch->by_key($batchnum); + $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update; + if ( $pay_batch->status ne 'I' and !$reconsider ) { + $error = "batch $batchnum no longer in transit"; + last ITEM; + } + + if ( $cust_pay_batch->status ) { + my $new_status = $item->approved ? 'approved' : 'declined'; + if ( lc( $cust_pay_batch->status ) eq $new_status ) { + # already imported with this status, so don't touch + next ITEM; + } + elsif ( !$reconsider ) { + # then we're not allowed to change its status, so bail out + $error = "paybatchnum ".$item->tid. + " already resolved with status '". $cust_pay_batch->status . "'"; + last ITEM; + } + } + + # create a new cust_pay_batch with whatever information we got back + my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash }; + my $new_payinfo; + # update payinfo, if needed + if ( $item->assigned_token ) { + $new_payinfo = $item->assigned_token; + } elsif ( $cust_pay_batch->payby eq 'CARD' ) { + $new_payinfo = $item->card_number if $item->card_number; + } else { #$cust_pay_batch->payby eq 'CHEK' + $new_payinfo = $item->account_number . '@' . $item->routing_code + if $item->account_number; + } + $new_cust_pay_batch->payinfo($new_payinfo) if $new_payinfo; + + # set "paid" pseudo-field (transfers to cust_pay) to the actual amount + # paid, if the batch says it's different from the amount requested + if ( defined $item->amount ) { + $new_cust_pay_batch->paid($item->amount); + } else { + $new_cust_pay_batch->paid($cust_pay_batch->amount); + } + + # set payment date to when it was processed + $new_cust_pay_batch->_date($item->payment_date->epoch) + if $item->payment_date; + + # approval status + if ( $item->approved ) { + # follow Billing_Realtime format for paybatch + my $paybatch = $gateway->gatewaynum . + '-' . + $gateway->gateway_module . + ':' . + $item->authorization . + ':' . + $item->order_number; + + $error = $new_cust_pay_batch->approve($paybatch); + $total += $new_cust_pay_batch->paid; + } + else { + $error = $new_cust_pay_batch->decline($item->error_message); + } + last ITEM if $error; + $num++; + $job->update_statustext(int(100 * $num/( $batch->count + 1 ) ), + 'Importing batch items') + if $job; + } #foreach $item + + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + } #foreach $batch (input batch, not pay_batch) + + # Auto-resolve + foreach my $pay_batch (values %pay_batch_for_update) { + $error = $pay_batch->try_to_resolve; + + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + $dbh->commit if $oldAutoCommit; + return; +} + +=item try_to_resolve + +Resolve this batch if possible. A batch can be resolved if all of its +entries have a status. If the system options 'batch-auto_resolve_days' +and 'batch-auto_resolve_status' are set, and the batch's download date is +at least (batch-auto_resolve_days) before the current time, then it can +be auto-resolved; entries with no status will be approved or declined +according to the batch-auto_resolve_status setting. + +=cut + +sub try_to_resolve { + my $self = shift; + my $conf = FS::Conf->new;; + + return if $self->status ne 'I'; + + my @unresolved = qsearch('cust_pay_batch', + { + batchnum => $self->batchnum, + status => '' + } + ); + + if ( @unresolved ) { + my $days = $conf->config('batch-auto_resolve_days') || ''; + # either 'approve' or 'decline' + my $action = $conf->config('batch-auto_resolve_status') || ''; + return unless + length($days) and + length($action) and + time > ($self->download + 86400 * $days) + ; + + my $error; + foreach my $cpb (@unresolved) { + if ( $action eq 'approve' ) { + # approve it for the full amount + $cpb->set('paid', $cpb->amount) unless ($cpb->paid || 0) > 0; + $error = $cpb->approve($self->batchnum); + } + elsif ( $action eq 'decline' ) { + $error = $cpb->decline('No response from processor'); + } + return $error if $error; + } + } + + $self->set_status('R'); +} + +=item prepare_for_export + +Prepare the batch to be exported. This will: +- Set the status to "in transit". +- If batch-increment_expiration is set and this is a credit card batch, + increment expiration dates that are in the past. +- If this is the first download for this batch, adjust payment amounts to + not be greater than the customer's current balance. If the customer's + balance is zero, the entry will be removed. + +Use this within a transaction. + +=cut + +sub prepare_for_export { + my $self = shift; + my $conf = FS::Conf->new; + my $curuser = $FS::CurrentUser::CurrentUser; my $first_download; my $status = $self->status; if ($status eq 'O') { $first_download = 1; my $error = $self->set_status('I'); - die "error updating pay_batch status: $error\n" if $error; + return "error updating pay_batch status: $error\n" if $error; } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) { $first_download = 0; } else { die "No pending batch.\n"; } - my $batch = ''; - my $batchtotal = 0; - my $batchcount = 0; - - my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum } - qsearch('cust_pay_batch', { batchnum => $self->batchnum } ); + my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum } + $self->cust_pay_batch; # handle batch-increment_expiration option if ( $self->payby eq 'CARD' ) { @@ -487,40 +729,76 @@ sub export_batch { $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) ); $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) ); } - $_->setfield('expmmyy', sprintf('%02u%02u', $mon+1, $year % 100)); + my $error = $_->replace; + return $error if $error; } } if ($first_download) { #remove or reduce entries if customer's balance changed - my @new = (); foreach my $cust_pay_batch (@cust_pay_batch) { my $balance = $cust_pay_batch->cust_main->balance; if ($balance <= 0) { # then don't charge this customer my $error = $cust_pay_batch->delete; - if ( $error ) { - $dbh->rollback or die $dbh->errstr if $oldAutoCommit; - die $error; - } - next; + return $error if $error; } elsif ($balance < $cust_pay_batch->amount) { # reduce the charge to the remaining balance $cust_pay_batch->amount($balance); my $error = $cust_pay_batch->replace; - if ( $error ) { - $dbh->rollback or die $dbh->errstr if $oldAutoCommit; - die $error; - } + return $error if $error; } # else $balance >= $cust_pay_batch->amount - - push @new, $cust_pay_batch; } - @cust_pay_batch = @new; + } #if $first_download + + ''; +} + +=item export_batch [ format => FORMAT | gateway => GATEWAY ] + +Export batch for processing. FORMAT is the name of an L +module, in which case the configuration options are in 'batchconfig-FORMAT'. + +Alternatively, GATEWAY can be an L object set to a +L module. +=cut + +sub export_batch { + my $self = shift; + my %opt = @_; + + my $conf = new FS::Conf; + my $batch; + + my $gateway = $opt{'gateway'}; + if ( $gateway ) { + # welcome to the future + my $fh = IO::Scalar->new(\$batch); + $self->export_to_gateway($gateway, 'file' => $fh); + return $batch; } + my $format = $opt{'format'} || $conf->config('batch-default_format') + or die "No batch format configured\n"; + + my $info = $export_info{$format} or die "Format not found: '$format'\n"; + + &{$info->{'init'}}($conf) if exists($info->{'init'}); + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my $error = $self->prepare_for_export; + + die $error if $error; + my $batchtotal = 0; + my $batchcount = 0; + + my @cust_pay_batch = $self->cust_pay_batch; + my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n"; my $h = $info->{'header'}; @@ -534,8 +812,8 @@ sub export_batch { $batchcount++; $batchtotal += $cust_pay_batch->amount; $batch .= - &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal). - $delim; + &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal). + $delim; } my $f = $info->{'footer'}; @@ -557,6 +835,43 @@ sub export_batch { return $batch; } +=item export_to_gateway GATEWAY OPTIONS + +Given L GATEWAY, export the items in this batch to +that gateway via Business::BatchPayment. OPTIONS may include: + +- file: override the default transport and write to this file (name or handle) + +=cut + +sub export_to_gateway { + + my ($self, $gateway, %opt) = @_; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my $error = $self->prepare_for_export; + die $error if $error; + + my %proc_opt = ( + 'output' => $opt{'file'}, # will do nothing if it's empty + # any other constructor options go here + ); + my $processor = $gateway->batch_processor(%proc_opt); + + my @items = map { $_->request_item } $self->cust_pay_batch; + my $batch = Business::BatchPayment->create(Batch => + batch_id => $self->batchnum, + items => \@items + ); + $processor->submit($batch); + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; +} + sub manual_approve { my $self = shift; my $date = time; @@ -603,6 +918,61 @@ sub manual_approve { return; } +sub _upgrade_data { + # Set up configuration for gateways that have a Business::BatchPayment + # module. + + eval "use Class::MOP;"; + if ( $@ ) { + warn "Moose/Class::MOP not available.\n$@\nSkipping pay_batch upgrade.\n"; + return; + } + my $conf = FS::Conf->new; + for my $format (keys %export_info) { + my $mod = "FS::pay_batch::$format"; + if ( $mod->can('_upgrade_gateway') + and length( $conf->config("batchconfig-$format") ) ) { + + local $@; + my ($module, %gw_options) = $mod->_upgrade_gateway; + my $gateway = FS::payment_gateway->new({ + gateway_namespace => 'Business::BatchPayment', + gateway_module => $module, + }); + my $error = $gateway->insert(%gw_options); + if ( $error ) { + warn "Failed to migrate '$format' to a Business::BatchPayment::$module gateway:\n$error\n"; + next; + } + + # test whether it loads + my $processor = eval { $gateway->batch_processor }; + if ( !$processor ) { + warn "Couldn't load Business::BatchPayment module for '$format'.\n"; + # if not, remove it so it doesn't hang around and break things + $gateway->delete; + } + else { + # remove the batchconfig-* + warn "Created Business::BatchPayment gateway '".$gateway->label. + "' for '$format' batch processing.\n"; + $conf->delete("batchconfig-$format"); + + # and if appropriate, make it the system default + for my $payby (qw(CARD CHEK)) { + if ( $conf->config("batch-fixed_format-$payby") eq $format ) { + warn "Setting as default for $payby.\n"; + $conf->set("batch-gateway-$payby", $gateway->gatewaynum); + $conf->delete("batch-fixed_format-$payby"); + } + } + } # if $processor + } #if can('_upgrade_gateway') and batchconfig-$format + } #for $format + + ''; +} + =back =head1 BUGS diff --git a/FS/FS/pay_batch/paymentech.pm b/FS/FS/pay_batch/paymentech.pm index f22a80f89..2ac5a6624 100644 --- a/FS/FS/pay_batch/paymentech.pm +++ b/FS/FS/pay_batch/paymentech.pm @@ -140,5 +140,18 @@ my %paytype = ( row => sub {}, ); +# Including this means that there is a Business::BatchPayment module for +# this gateway and we want to upgrade it. +# Must return the name of the module, followed by a hash of options. + +sub _upgrade_gateway { + my $conf = FS::Conf->new; + my @batchconfig = $conf->config('batchconfig-paymentech'); + my %options; + @options{ qw(bin terminalID merchantID login password ) } = @batchconfig; + $options{'industryType'} = 'EC'; + ( 'Paymentech', %options ); +} + 1; diff --git a/FS/FS/payment_gateway.pm b/FS/FS/payment_gateway.pm index bc8b875c3..fac738499 100644 --- a/FS/FS/payment_gateway.pm +++ b/FS/FS/payment_gateway.pm @@ -39,7 +39,7 @@ currently supported: =item gatewaynum - primary key -=item gateway_namespace - Business::OnlinePayment or Business::OnlineThirdPartyPayment +=item gateway_namespace - Business::OnlinePayment, Business::OnlineThirdPartyPayment, or Business::BatchPayment =item gateway_module - Business::OnlinePayment:: module name @@ -51,6 +51,13 @@ currently supported: =item disabled - Disabled flag, empty or 'Y' +=item auto_resolve_status - For BatchPayment only, set to 'approve' to +auto-approve unresolved payments after some number of days, 'reject' to +auto-decline them, or null to do nothing. + +=item auto_resolve_days - For BatchPayment, the number of days to wait before +auto-resolving the batch. + =back =head1 METHODS @@ -116,16 +123,21 @@ sub check { || $self->ut_alpha('gateway_module') || $self->ut_enum('gateway_namespace', ['Business::OnlinePayment', 'Business::OnlineThirdPartyPayment', + 'Business::BatchPayment', ] ) || $self->ut_textn('gateway_username') || $self->ut_anything('gateway_password') || $self->ut_textn('gateway_callback_url') # a bit too permissive || $self->ut_enum('disabled', [ '', 'Y' ] ) + || $self->ut_enum('auto_resolve_status', [ '', 'approve', 'reject' ]) + || $self->ut_numbern('auto_resolve_days') #|| $self->ut_textn('gateway_action') ; return $error if $error; - if ( $self->gateway_action ) { + if ( $self->gateway_namespace eq 'Business::BatchPayment' ) { + $self->gateway_action('Payment'); + } elsif ( $self->gateway_action ) { my @actions = split(/,\s*/, $self->gateway_action); $self->gateway_action( join( ',', map { /^(Normal Authorization|Authorization Only|Credit|Post Authorization)$/ @@ -198,6 +210,19 @@ sub disable { } +=item label + +Returns a semi-friendly label for the gateway. + +=cut + +sub label { + my $self = shift; + $self->gatewaynum . ': ' . + $self->gateway_username . '@' . + $self->gateway_module +} + =item namespace_description returns a friendly name for the namespace @@ -208,12 +233,58 @@ my %namespace2description = ( '' => 'Direct', 'Business::OnlinePayment' => 'Direct', 'Business::OnlineThirdPartyPayment' => 'Hosted', + 'Business::BatchPayment' => 'Batch', ); sub namespace_description { $namespace2description{shift->gateway_namespace} || 'Unknown'; } +=item batch_processor OPTIONS + +For BatchPayment gateways only. Returns a +L object to communicate with the +gateway. + +OPTIONS will be passed to the constructor, along with any gateway +options in the database for this L. Useful things +to include there may include 'input' and 'output' (to direct transport +to files), 'debug', and 'test_mode'. + +If the global 'business-batchpayment-test_transaction' flag is set, +'test_mode' will be forced on, and gateways that don't support test +mode will be disabled. + +=cut + +sub batch_processor { + local $@; + my $self = shift; + my %opt = @_; + my $batch = $opt{batch}; + my $output = $opt{output}; + die 'gateway '.$self->gatewaynum.' is not a Business::BatchPayment gateway' + unless $self->gateway_namespace eq 'Business::BatchPayment'; + eval "use Business::BatchPayment;"; + die "couldn't load Business::BatchPayment: $@" if $@; + + my $conf = new FS::Conf; + my $test_mode = $conf->exists('business-batchpayment-test_transaction'); + $opt{'test_mode'} = 1 if $test_mode; + + my $module = $self->gateway_module; + my $processor = eval { + Business::BatchPayment->create($module, $self->options, %opt) + }; + die "failed to create Business::BatchPayment::$module object: $@" + if $@; + + die "$module does not support test mode" + if $test_mode and not $processor->does('Business::BatchPayment::TestMode'); + + return $processor; +} + # _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 b73d0b112..2b33d1671 100755 --- a/FS/bin/freeside-daily +++ b/FS/bin/freeside-daily @@ -65,6 +65,11 @@ backup(); use FS::Cron::rt_tasks qw(rt_daily); rt_daily(%opt); +#does nothing unless batch-gateway-* configs are set +use FS::Cron::pay_batch qw(batch_submit batch_receive); +batch_submit(%opt); +batch_receive(%opt); + my $deldir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc/"; unlink <${deldir}.invoice*>; unlink <${deldir}.letter*>; diff --git a/FS/bin/freeside-eftca-upload b/FS/bin/freeside-eftca-upload index 45a358b23..b66765af3 100755 --- a/FS/bin/freeside-eftca-upload +++ b/FS/bin/freeside-eftca-upload @@ -46,7 +46,7 @@ foreach my $pay_batch (@batches) { my $batchnum = $pay_batch->batchnum; my $filename = time2str('%Y%m%d', time) . '-' . sprintf('%06d.csv',$batchnum); print STDERR "Exporting batch $batchnum to $filename...\n" if $opt_v; - my $text = $pay_batch->export_batch('eft_canada'); + my $text = $pay_batch->export_batch(format => 'eft_canada'); open OUT, ">$tmpdir/$filename"; print OUT $text; close OUT; diff --git a/FS/bin/freeside-paymentech-upload b/FS/bin/freeside-paymentech-upload index 3f8abc047..609019eb2 100755 --- a/FS/bin/freeside-paymentech-upload +++ b/FS/bin/freeside-paymentech-upload @@ -59,7 +59,7 @@ foreach my $pay_batch (@batches) { my $batchnum = $pay_batch->batchnum; my $filename = sprintf('%06d',$batchnum) . '-' .time2str('%Y%m%d%H%M%S', time); print STDERR "Exporting batch $batchnum to $filename...\n" if $opt_v; - my $text = $pay_batch->export_batch('paymentech'); + my $text = $pay_batch->export_batch(format => 'paymentech'); $text =~ s!FILEID!$filename! or die "couldn't find FILEID tag\n"; open OUT, ">$tmpdir/$filename.xml"; diff --git a/httemplate/browse/payment_gateway.html b/httemplate/browse/payment_gateway.html index a06e5cf7c..7a8a668d7 100644 --- a/httemplate/browse/payment_gateway.html +++ b/httemplate/browse/payment_gateway.html @@ -77,9 +77,9 @@ my $options_sub = sub { my $html = ''; - my %options = $payment_gateway->options; + tie my %options, 'Tie::IxHash', $payment_gateway->options; foreach my $option ( keys %options ) { - $html .= ''. + $html .= ''. ''; } $html .= '
'. $option. ':
'. $option. ':'. $options{$option}. '
'; diff --git a/httemplate/edit/payment_gateway.html b/httemplate/edit/payment_gateway.html index cfb86048c..2840df35b 100644 --- a/httemplate/edit/payment_gateway.html +++ b/httemplate/edit/payment_gateway.html @@ -6,11 +6,12 @@ 'field_callback' => $field_callback, 'labels' => { 'gatewaynum' => 'Gateway', + 'gateway_namespace' => 'Gateway type', 'gateway_module' => 'Gateway', 'gateway_username' => 'Username', 'gateway_password' => 'Password', 'gateway_action' => 'Action', - 'gateway_options' => 'Options: (Name/Value pairs, one element per line)', + 'gateway_options' => 'Options (Name/Value pairs,
one element per line)', 'gateway_callback_url' => 'Callback URL', }, ) @@ -18,18 +19,18 @@ <%init> @@ -67,6 +68,7 @@ my %modules = ( 'OpenECHO' => 'Business::OnlinePayment', 'PayConnect' => 'Business::OnlinePayment', 'PayflowPro' => 'Business::OnlinePayment', + 'Paymentech' => 'Business::BatchPayment', 'PaymenTech' => 'Business::OnlinePayment', 'PaymentsGateway' => 'Business::OnlinePayment', 'PayPal' => 'Business::OnlinePayment', @@ -88,7 +90,13 @@ my %modules = ( 'VirtualNet' => 'Business::OnlinePayment', 'WesternACH' => 'Business::OnlinePayment', 'WorldPay' => 'Business::OnlinePayment', -); +); + +my %modules_for_namespace; +for (keys %modules) { + $modules_for_namespace{$modules{$_}} ||= []; + push @{ $modules_for_namespace{$modules{$_}} }, $_; +} my @actions = ( 'Normal Authorization', @@ -99,17 +107,23 @@ my @actions = ( my $fields = [ { field => 'gateway_namespace', - type => 'hidden', - curr_value_callback => sub { my($cgi, $object, $fref) = @_; - $modules{$object->gateway_module} - || 'Business::OnlinePayment' - }, + type => 'select', + options => [ qw( + Business::OnlinePayment + Business::BatchPayment + Business::OnlineThirdPartyPayment + ) ], + labels => { + 'Business::OnlinePayment' => 'Direct', + 'Business::BatchPayment' => 'Batch', + 'Business::OnlineThirdPartyPayment' => 'Hosted', + }, + onchange => 'changeNamespace', }, { field => 'gateway_module', type => 'select', options => [ sort { lc($a) cmp lc ($b) } keys %modules ], - onchange => 'setNamespace', }, 'gateway_username', 'gateway_password', @@ -126,6 +140,8 @@ my $fields = [ { field => 'gateway_options', type => 'textarea', + rows => '8', + cols => '40', curr_value_callback => sub { my($cgi, $object, $fref) = @_; join("\r", $object->options ); }, @@ -135,7 +151,7 @@ my $fields = [ my $field_callback = sub { my ($cgi, $object, $field_hashref ) = @_; if ($object->gatewaynum) { - if ( $field_hashref->{field} eq 'gateway_module' ) { + if ( $field_hashref->{field} =~ /gateway_(module|namespace)/ ) { $field_hashref->{type} = 'fixed'; } } diff --git a/httemplate/misc/download-batch.cgi b/httemplate/misc/download-batch.cgi index 23deba712..f3a31eb3b 100644 --- a/httemplate/misc/download-batch.cgi +++ b/httemplate/misc/download-batch.cgi @@ -1,4 +1,4 @@ -<% $pay_batch->export_batch($format) %><%init> +<% $pay_batch->export_batch(%opt) %><%init> #http_header('Content-Type' => 'text/comma-separated-values' ); #IE chokes http_header('Content-Type' => 'text/plain' ); # not necessarily correct... @@ -10,9 +10,14 @@ if ( $cgi->param('batchnum') =~ /^(\d+)$/ ) { die "No batch number (bad URL) \n"; } -my $format; -if ( $cgi->param('format') =~ /^([\w\- ]+)$/ ) { - $format = $1; +my %opt; +if ( $cgi->param('gatewaynum') =~ /^(\d+)$/ ) { + my $gateway = FS::payment_gateway->by_key($1); + die "gatewaynum $1 not found" unless $gateway; + $opt{'gateway'} = $gateway; +} +elsif ( $cgi->param('format') =~ /^([\w\- ]+)$/ ) { + $opt{'format'} = $1; } my $pay_batch = qsearchs('pay_batch', { batchnum => $batchnum } ); diff --git a/httemplate/search/elements/cust_pay_batch_top.html b/httemplate/search/elements/cust_pay_batch_top.html index ce0ee9ed4..005b76182 100644 --- a/httemplate/search/elements/cust_pay_batch_top.html +++ b/httemplate/search/elements/cust_pay_batch_top.html @@ -14,7 +14,8 @@ Download batch in format + +<& .select_gateway &> % }

% } # end of download @@ -31,7 +32,7 @@ Download batch in format +Upload in format + % } % else { % } % } % else { -Format % foreach ( keys(%upload_formats) ) { % } + +<& .select_gateway &> % } # if $fixed
@@ -82,6 +85,26 @@ Batch is <% $statustext{$status} %>
<%$count%> payments batched
<%$money_char%><%$total%> total in batch
+<%def .select_gateway> +% if ( $show_gateways ) { + or from gateway +<& /elements/select-table.html, + empty_label => ' ', + field => 'gatewaynum', + table => 'payment_gateway', + name_col => 'label', + value_col => 'gatewaynum', + order_by => 'ORDER BY gatewaynum', + hashref => { + 'gateway_namespace' => 'Business::BatchPayment', + 'disabled' => '', + } +&> +% } + +<%shared> +my $show_gateways = FS::payment_gateway->count("gateway_namespace = 'Business::BatchPayment'"); + <%init> my %opt = @_; my $pay_batch = $opt{'pay_batch'} or return; @@ -91,7 +114,7 @@ my $payby = $pay_batch->payby; my $status = $pay_batch->status; my $curuser = $FS::CurrentUser::CurrentUser; my $batchnum = $pay_batch->batchnum; - + my $fixed = $conf->config("batch-fixed_format-$payby"); tie my %download_formats, 'Tie::IxHash', ( diff --git a/httemplate/search/pay_batch.cgi b/httemplate/search/pay_batch.cgi index b2a15ef3d..05415f36e 100755 --- a/httemplate/search/pay_batch.cgi +++ b/httemplate/search/pay_batch.cgi @@ -14,7 +14,8 @@ 'Type', 'First Download', 'Last Upload', - 'Item Count', + 'Items', + 'Unresolved', 'Amount', 'Status', ], @@ -46,13 +47,16 @@ } }, sub { - my $st = "SELECT COUNT(*) from cust_pay_batch WHERE batchnum=" . shift->batchnum; - my $sth = dbh->prepare($st) - or die dbh->errstr. "doing $st"; - $sth->execute - or die "Error executing \"$st\": ". $sth->errstr; - $sth->fetchrow_arrayref->[0]; - }, + FS::cust_pay_batch->count( + 'batchnum = '.$_[0]->batchnum + ) + }, + sub { + FS::cust_pay_batch->count( + 'status is null and batchnum = '. + $_[0]->batchnum + ) + }, sub { my $st = "SELECT SUM(amount) from cust_pay_batch WHERE batchnum=" . shift->batchnum; my $sth = dbh->prepare($st) -- 2.11.0