package FS::pay_batch;
+use base qw( FS::Record );
use strict;
-use vars qw( @ISA $DEBUG %import_info %export_info $conf );
+use vars qw( $DEBUG %import_info %export_info $conf );
+use Scalar::Util qw(blessed);
+use IO::Scalar;
+use List::Util qw(sum);
use Time::Local;
use Text::CSV_XS;
+use Date::Parse qw(str2time);
+use Business::CreditCard qw( 0.35 cardtype );
use FS::Record qw( dbh qsearch qsearchs );
-use FS::cust_pay;
use FS::Conf;
-
-@ISA = qw(FS::Record);
+use FS::cust_pay;
+use FS::Log;
+use Try::Tiny;
=head1 NAME
=item batchnum - primary key
+=item agentnum - optional agent number for agent batches
+
=item payby - CARD or CHEK
=item status - O (Open), I (In-transit), or R (Resolved)
-=item download -
+=item download - time when the batch was first downloaded
+
+=item upload - time when the batch was first uploaded
-=item upload -
+=item title - unique batch identifier
+For incoming batches, the combination of 'title', 'payby', and 'agentnum'
+must be unique.
=back
$self->ut_numbern('batchnum')
|| $self->ut_enum('payby', [ 'CARD', 'CHEK' ])
|| $self->ut_enum('status', [ 'O', 'I', 'R' ])
+ || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
+ || $self->ut_alphan('title')
;
return $error if $error;
+ if ( $self->title ) {
+ my @existing =
+ grep { !$self->batchnum or $_->batchnum != $self->batchnum }
+ qsearch('pay_batch', {
+ payby => $self->payby,
+ agentnum => $self->agentnum,
+ title => $self->title,
+ });
+ return "Batch already exists as batchnum ".$existing[0]->batchnum
+ if @existing;
+ }
+
$self->SUPER::check;
}
+=item agent
+
+Returns the L<FS::agent> object for this batch.
+
+=item cust_pay_batch
+
+Returns all L<FS::cust_pay_batch> objects for this batch.
+
=item rebalance
=cut
\\%FS::pay_batch::$mod\::export_info,
\$FS::pay_batch::$mod\::name)";
$name ||= $mod; # in case it's not defined
- if( $@) {
+ if ($@) {
# in FS::cdr this is a die, not a warn. That's probably a bug.
warn "error using FS::pay_batch::$mod (skipping): $@\n";
next;
=item import_results OPTION => VALUE, ...
-Import batch results.
+Import batch results. Can be called as an instance method, if you want to
+automatically adjust status on a specific batch, or a class method, if you
+don't know which batch(es) the results apply to.
Options are:
I<filehandle> - open filehandle of results file.
-I<format> - "csv-td_canada_trust-merchant_pc_batch", "csv-chase_canada-E-xactBatch", "ach-spiritone", or "PAP"
+I<format> - an L<FS::pay_batch> module
+
+I<gateway> - an L<FS::payment_gateway> object for a batch gateway. This
+takes precedence over I<format>.
+
+I<no_close> - do not try to close batches
+
+Supported format keys (defined in the specified FS::pay_batch module) are:
+
+I<filetype> - required, can be CSV, fixed, variable, XML
+
+I<fields> - required list of field names for each row/line
+
+I<formatre> - regular expression for fixed filetype
+
+I<parse> - required for variable filetype
+
+I<xmlkeys> - required for XML filetype
+
+I<xmlrow> - required for XML filetype
+
+I<begin_condition> - sub, ignore all lines before this returns true
+
+I<end_condition> - sub, stop processing lines when this returns true
+
+I<end_hook> - sub, runs immediately after end_condition returns true
+
+I<skip_condition> - sub, skip lines when this returns true
+
+I<hook> - required, sub, runs before approved/declined conditions are checked
+
+I<approved> - required, sub, returns true when approved
+
+I<declined> - required, sub, returns true when declined
+
+I<close_condition> - sub, decide whether or not to close the batch
=cut
my $param = ref($_[0]) ? shift : { @_ };
my $fh = $param->{'filehandle'};
+ my $job = $param->{'job'};
+ $job->update_statustext(0) if $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 or fixed
+ my $filetype = $info->{'filetype'}; # CSV, fixed, variable
my @fields = @{ $info->{'fields'}};
my $formatre = $info->{'formatre'}; # for fixed
+ my $parse = $info->{'parse'}; # for variable
my @all_values;
my $begin_condition = $info->{'begin_condition'};
my $end_condition = $info->{'end_condition'};
my $hook = $info->{'hook'};
my $approved_condition = $info->{'approved'};
my $declined_condition = $info->{'declined'};
+ my $close_condition = $info->{'close_condition'};
+
+ my %target_batches; # batches that had at least one payment updated
my $csv = new Text::CSV_XS;
local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
- my $reself = $self->select_for_update;
-
- unless ( $reself->status eq 'I' ) {
- $dbh->rollback if $oldAutoCommit;
- return "batchnum ". $self->batchnum. "no longer in transit";
- }
+ if ( ref($self) ) {
+ # if called on a specific pay_batch, check the status of that batch
+ # before continuing
+ my $reself = $self->select_for_update;
- my $error = $self->set_status('R');
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return $error;
- }
+ if ( $reself->status ne 'I'
+ and !$conf->exists('batch-manual_approval') ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "batchnum ". $self->batchnum. "no longer in transit";
+ }
+ } # otherwise we can't enforce this constraint. sorry.
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 $@;
};
push @values, $line;
push @all_values, \@values;
- }else{
+ }
+ elsif ($filetype eq 'variable') {
+ # no longer used
+ my @values = ( eval { $parse->($self, $line) } );
+ if( $@ ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $@;
+ };
+ push @values, $line;
+ push @all_values, \@values;
+ }
+ else {
$dbh->rollback if $oldAutoCommit;
return "Unknown file type $filetype";
}
unless ( $cust_pay_batch ) {
return "unknown paybatchnum $hash{'paybatchnum'}\n";
}
+ # remember that we've touched this batch
+ $target_batches{ $cust_pay_batch->batchnum } = 1;
+
my $custnum = $cust_pay_batch->custnum,
my $payby = $cust_pay_batch->payby,
- my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
-
&{$hook}(\%hash, $cust_pay_batch->hashref);
+ my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
+
+ my $error = '';
if ( &{$approved_condition}(\%hash) ) {
- $new_cust_pay_batch->status('Approved');
+ foreach ('paid', '_date', 'payinfo') {
+ $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
+ }
+ $error = $new_cust_pay_batch->approve(%hash);
+ $total += $hash{'paid'};
} elsif ( &{$declined_condition}(\%hash) ) {
- $new_cust_pay_batch->status('Declined');
+ $error = $new_cust_pay_batch->decline($hash{'error_message'});;
}
- my $error = $new_cust_pay_batch->replace($cust_pay_batch);
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
- return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
+ return $error;
}
# purge CVV when the batch is processed
- $new_cust_pay_batch->cust_main->remove_cvv
- if ( $payby eq 'CARD' or $payby eq 'DCRD' );
-
- if ( $new_cust_pay_batch->status =~ /Approved/i ) {
-
- my $cust_pay = new FS::cust_pay ( {
- 'custnum' => $custnum,
- 'payby' => $payby,
- 'paybatch' => $self->batchnum,
- 'payinfo' => ( $hash{'payinfo'} || $cust_pay_batch->payinfo ),
- map { $_ => $hash{$_} } (qw( paid _date )),
- } );
- $error = $cust_pay->insert;
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
+ if ( $payby =~ /^(CARD|DCRD)$/ ) {
+ my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
+ if ( ! grep { $_ eq cardtype($payinfo) }
+ $conf->config('cvv-save') ) {
+ $new_cust_pay_batch->cust_main->remove_cvv;
}
- $total += $hash{'paid'};
-
- $cust_pay->cust_main->apply_payments;
-
- } elsif ( $new_cust_pay_batch->status =~ /Declined/i ) {
- #false laziness w/cust_main::collect
+ }
- my $due_cust_event = $new_cust_pay_batch->cust_main->due_cust_event(
- #'check_freq' => '1d', #?
- 'eventtable' => 'cust_pay_batch',
- 'objects' => [ $new_cust_pay_batch ],
- );
- unless( ref($due_cust_event) ) {
- $dbh->rollback if $oldAutoCommit;
- return $due_cust_event;
+ } # foreach (@all_values)
+
+ # decide whether to close batches that had payments posted
+ if ( !$param->{no_close} ) {
+ foreach my $batchnum (keys %target_batches) {
+ my $pay_batch = FS::pay_batch->by_key($batchnum);
+ my $close = 1;
+ if ( defined($close_condition) ) {
+ # Allow the module to decide whether to close the batch.
+ # $close_condition can also die() to abort the whole import.
+ $close = eval { $close_condition->($pay_batch) };
+ if ( $@ ) {
+ $dbh->rollback;
+ die $@;
+ }
}
-
- foreach my $cust_event ( @$due_cust_event ) {
-
- #XXX lock event
-
- #re-eval event conditions (a previous event could have changed things)
- next unless $cust_event->test_conditions;
-
- if ( my $error = $cust_event->do_event() ) {
- # gah, even with transactions.
- #$dbh->commit if $oldAutoCommit; #well.
- $dbh->rollback if $oldAutoCommit;
+ if ( $close ) {
+ my $error = $pay_batch->set_status('R');
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
return $error;
- }
-
- } # foreach $cust_event
-
- } # if(status eq 'Approved')
+ }
+ }
+ } # foreach $batchnum
+ } # if (!$param->{no_close})
- } # foreach(@all_values)
-
$dbh->commit or die $dbh->errstr if $oldAutoCommit;
'';
}
-use MIME::Base64;
-use Storable 'thaw';
use Data::Dumper;
sub process_import_results {
my $job = shift;
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
$param->{'job'} = $job;
warn Dumper($param) if $DEBUG;
- 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/;
'<',
"$dir/$file" )
or die "unable to open '$file'.\n";
- my $error = $batch->import_results($param);
+
+ my $error;
+ if ( $param->{gateway} ) {
+ $error = FS::pay_batch->import_from_gateway(%$param);
+ } else {
+ 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";
+ $error = $batch->import_results($param);
+ }
unlink $file;
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 [ OPTIONS ]
- my $curuser = $FS::CurrentUser::CurrentUser;
+Import results from a L<FS::payment_gateway>, 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:
+
+- gateway: the L<FS::payment_gateway>, required
+- filehandle: a file name or handle to use as a data source.
+- job: an L<FS::queue> object to update with progress messages.
+
+=cut
+
+sub import_from_gateway {
+ my $class = shift;
+ my %opt = @_;
+ my $gateway = $opt{'gateway'};
+ 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{'filehandle'}, # will do nothing if it's empty
+ # any other constructor options go here
+ );
+
+ my @item_errors;
+ my $errors_not_fatal = $conf->config('batch-errors_not_fatal');
+ if ( $errors_not_fatal ) {
+ # construct error trap
+ $proc_opt{'on_parse_error'} = sub {
+ my ($self, $line, $error) = @_;
+ push @item_errors, " '$line'\n$error";
+ };
+ }
+
+ my $processor = $gateway->batch_processor(%proc_opt);
+
+ my @processor_ids = map { $_->processor_id }
+ qsearch({
+ 'table' => 'pay_batch',
+ 'hashref' => { 'status' => 'I' },
+ 'extra_sql' => q( AND processor_id != '' AND processor_id IS NOT NULL)
+ });
+
+ my @batches = $processor->receive(@processor_ids);
+
+ my $num = 0;
+
+ my $total_items = sum( map{$_->count} @batches);
+
+ # whether to allow items to change status
+ my $reconsider = $conf->exists('batch-reconsider');
+
+ # mutex all affected batches
+ my %pay_batch_for_update;
+
+ my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
+
+ BATCH: foreach my $batch (@batches) {
+
+ my %incoming_batch = (
+ 'CARD' => {},
+ 'CHEK' => {},
+ );
+
+ ITEM: foreach my $item ($batch->elements) {
+
+ my $cust_pay_batch; # the new batch entry (with status)
+ my $pay_batch; # the freeside batch it belongs to
+ my $payby; # CARD or CHEK
+ my $error;
+
+ my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module .
+ ':' . ($item->authorization || '') .
+ ':' . ($item->order_number || '');
+
+ if ( $batch->incoming ) {
+ # This is a one-way batch.
+ # Locate the customer, find an open batch correct for them,
+ # create a payment. Don't bother creating a cust_pay_batch
+ # entry.
+ my $cust_main;
+ if ( defined($item->customer_id)
+ and $item->customer_id =~ /^\d+$/
+ and $item->customer_id > 0 ) {
+
+ $cust_main = FS::cust_main->by_key($item->customer_id)
+ || qsearchs('cust_main',
+ { 'agent_custid' => $item->customer_id }
+ );
+ if ( !$cust_main ) {
+ push @item_errors, "Unknown customer_id ".$item->customer_id;
+ next ITEM;
+ }
+ }
+ else {
+ push @item_errors, "Illegal customer_id '".$item->customer_id."'";
+ next ITEM;
+ }
+ # it may also make sense to allow selecting the customer by
+ # invoice_number, but no modules currently work that way
+
+ $payby = $bop2payby{ $item->payment_type };
+ my $agentnum = '';
+ $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
+
+ # create a batch if necessary
+ $pay_batch = $incoming_batch{$payby}->{$agentnum} ||=
+ FS::pay_batch->new({
+ status => 'R', # pre-resolve it
+ payby => $payby,
+ agentnum => $agentnum,
+ upload => time,
+ title => $batch->batch_id,
+ });
+ if ( !$pay_batch->batchnum ) {
+ $error = $pay_batch->insert;
+ die $error if $error; # can't do anything if this fails
+ }
+
+ if ( !$item->approved ) {
+ $error ||= "payment rejected - ".$item->error_message;
+ }
+ if ( !defined($item->amount) or $item->amount <= 0 ) {
+ $error ||= "no amount in item $num";
+ }
+
+ my $payinfo;
+ if ( $item->check_number ) {
+ $payby = 'BILL'; # right?
+ $payinfo = $item->check_number;
+ } elsif ( $item->assigned_token ) {
+ $payinfo = $item->assigned_token;
+ }
+ # create the payment
+ my $cust_pay = FS::cust_pay->new(
+ {
+ custnum => $cust_main->custnum,
+ _date => $item->payment_date->epoch,
+ paid => sprintf('%.2f',$item->amount),
+ payby => $payby,
+ invnum => $item->invoice_number,
+ batchnum => $pay_batch->batchnum,
+ payinfo => $payinfo,
+ gatewaynum => $gateway->gatewaynum,
+ processor => $gateway->gateway_module,
+ auth => $item->authorization,
+ order_number => $item->order_number,
+ }
+ );
+ $error ||= $cust_pay->insert;
+ eval { $cust_main->apply_payments };
+ $error ||= $@;
+
+ if ( $error ) {
+ push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
+ }
+
+ } else {
+ # This is a request/reply batch.
+ # Locate the request (the 'tid' attribute is the paybatchnum).
+ my $paybatchnum = $item->tid;
+ $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
+ if (!$cust_pay_batch) {
+ push @item_errors, "paybatchnum $paybatchnum not found";
+ next ITEM;
+ }
+ $payby = $cust_pay_batch->payby;
+
+ 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
+ $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";
+ }
+
+ 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 . "'";
+ }
+ }
+
+ if ( $error ) {
+ push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
+ next ITEM;
+ }
+
+ my $new_payinfo;
+ # update payinfo, if needed
+ if ( $item->assigned_token ) {
+ $new_payinfo = $item->assigned_token;
+ } elsif ( $payby eq 'CARD' ) {
+ $new_payinfo = $item->card_number if $item->card_number;
+ } else { #$payby eq 'CHEK'
+ $new_payinfo = $item->account_number . '@' . $item->routing_code
+ if $item->account_number;
+ }
+ $cust_pay_batch->set('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 ) {
+ $cust_pay_batch->set('paid', $item->amount);
+ } else {
+ $cust_pay_batch->set('paid', $cust_pay_batch->amount);
+ }
+
+ # set payment date to when it was processed
+ $cust_pay_batch->_date($item->payment_date->epoch)
+ if $item->payment_date;
+
+ # approval status
+ if ( $item->approved ) {
+ # follow Billing_Realtime format for paybatch
+ $error = $cust_pay_batch->approve(
+ 'gatewaynum' => $gateway->gatewaynum,
+ 'processor' => $gateway->gateway_module,
+ 'auth' => $item->authorization,
+ 'order_number' => $item->order_number,
+ );
+ $total += $cust_pay_batch->paid;
+ }
+ else {
+ $error = $cust_pay_batch->decline($item->error_message,
+ $item->failure_status);
+ }
+
+ if ( $error ) {
+ push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
+ next ITEM;
+ }
+ } # $batch->incoming
+
+ $num++;
+ $job->update_statustext(int(100 * $num/( $total_items ) ),
+ 'Importing batch items')
+ if $job;
+
+ } #foreach $item
+
+ } #foreach $batch (input batch, not pay_batch)
+
+ # Format an error message
+ if ( @item_errors ) {
+ my $error_text = join("\n\n",
+ "Errors during batch import: ".scalar(@item_errors),
+ @item_errors
+ );
+ if ( $errors_not_fatal ) {
+ my $message = "Import from gateway ".$gateway->label." errors: ".$error_text;
+ my $log = FS::Log->new('FS::pay_batch::import_from_gateway');
+ $log->error($message);
+ } else {
+ # Bail out.
+ $dbh->rollback if $oldAutoCommit;
+ die $error_text;
+ }
+ }
+
+ # Auto-resolve (with brute-force error handling)
+ foreach my $pay_batch (values %pay_batch_for_update) {
+ my $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 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 and $conf->exists('batch-auto_resolve_days') ) {
+ my $days = $conf->config('batch-auto_resolve_days'); # can be zero
+ # 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;
+ }
+ } elsif ( @unresolved ) {
+ # auto resolve is not enabled, and we're not ready to resolve
+ return;
+ }
+
+ $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 (caution: all cust_pay_batch
+ entries might 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;
} elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
$first_download = 0;
+ } elsif ($status eq 'R' &&
+ $curuser->access_right('Redownload resolved 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 }
+ $self->cust_pay_batch;
+
+ # handle batch-increment_expiration option
+ if ( $self->payby eq 'CARD' ) {
+ my ($cmon, $cyear) = (localtime(time))[4,5];
+ foreach (@cust_pay_batch) {
+ my $etime = str2time($_->exp) or next;
+ my ($day, $mon, $year) = (localtime($etime))[3,4,5];
+ if( $conf->exists('batch-increment_expiration') ) {
+ $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
+ $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
+ }
+ my $error = $_->replace;
+ return $error if $error;
+ }
+ }
- my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
- qsearch('cust_pay_batch', { batchnum => $self->batchnum } );
+ if ($first_download) { #remove or reduce entries if customer's balance changed
- my $h = $info->{'header'};
- if(ref($h) eq 'CODE') {
- $batch .= &$h($self, \@cust_pay_batch) . "\n";
- }
- else {
- $batch .= $h . "\n";
- }
- foreach my $cust_pay_batch (@cust_pay_batch) {
+ foreach my $cust_pay_batch (@cust_pay_batch) {
- if ($first_download) {
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;
+ my $error = $cust_pay_batch->unbatch_and_delete;
+ 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
}
+ #need to do this after unbatch_and_delete
+ my $error = $self->set_status('I');
+ return "error updating pay_batch status: $error\n" if $error;
+
+ } #if $first_download
+
+ '';
+}
+
+=item export_batch [ format => FORMAT | gateway => GATEWAY ]
+
+Export batch for processing. FORMAT is the name of an L<FS::pay_batch>
+module, in which case the configuration options are in 'batchconfig-FORMAT'.
+
+Alternatively, GATEWAY can be an L<FS::payment_gateway> object set to a
+L<Business::BatchPayment> module.
+
+Returns the text of the batch. If batch contains no cust_pay_batch entries
+(or has them all removed by L</prepare_for_export>) then the batch will be
+resolved and a blank string will be returned. All other errors are fatal.
+
+=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, $self->agentnum) 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;
+ unless (@cust_pay_batch) {
+ # if it's empty, just resolve the batch
+ $self->set_status('R');
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ return '';
+ }
+
+ my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
+
+ my $h = $info->{'header'};
+ if (ref($h) eq 'CODE') {
+ $batch .= &$h($self, \@cust_pay_batch). $delim;
+ } else {
+ $batch .= $h. $delim;
+ }
+
+ foreach my $cust_pay_batch (@cust_pay_batch) {
$batchcount++;
$batchtotal += $cust_pay_batch->amount;
- $batch .= &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal) . "\n";
-
+ $batch .=
+ &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
+ $delim;
}
my $f = $info->{'footer'};
- if(ref($f) eq 'CODE') {
- $batch .= &$f($self, $batchcount, $batchtotal) . "\n";
- }
- else {
- $batch .= $f . "\n";
+ if (ref($f) eq 'CODE') {
+ $batch .= &$f($self, $batchcount, $batchtotal). $delim;
+ } else {
+ $batch .= $f. $delim;
}
if ($info->{'autopost'}) {
return $batch;
}
+=item export_to_gateway GATEWAY OPTIONS
+
+Given L<FS::payment_gateway> 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)
+
+If batch contains no cust_pay_batch entries (or has them all removed by
+L</prepare_for_export>) then nothing will be transported (or written to
+the override file) and the batch will be resolved.
+
+=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;
+ unless (@items) {
+ # if it's empty, just resolve the batch
+ $self->set_status('R');
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ return '';
+ }
+
+ try {
+ my $batch = Business::BatchPayment->create(Batch =>
+ batch_id => $self->batchnum,
+ items => \@items
+ );
+ $processor->submit($batch);
+
+ if ($batch->processor_id) {
+ $self->set('processor_id',$batch->processor_id);
+ $self->replace;
+ }
+ } catch {
+ $dbh->rollback if $oldAutoCommit;
+ die $_;
+ };
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ '';
+}
+
+sub manual_approve {
+ my $self = shift;
+ my $date = time;
+ my %opt = @_;
+ my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
+ my $conf = FS::Conf->new;
+ return 'manual batch approval disabled'
+ if ( ! $conf->exists('batch-manual_approval') );
+ return 'batch already resolved' if $self->status eq 'R';
+ return 'batch not yet submitted' if $self->status eq 'O';
+
+ 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 $payments = 0;
+ foreach my $cust_pay_batch (
+ qsearch('cust_pay_batch', { batchnum => $self->batchnum,
+ status => '' })
+ ) {
+ my $new_cust_pay_batch = new FS::cust_pay_batch {
+ $cust_pay_batch->hash,
+ 'paid' => $cust_pay_batch->amount,
+ '_date' => $date,
+ 'usernum' => $usernum,
+ };
+ my $error = $new_cust_pay_batch->approve();
+ # there are no approval options here (authorization, order_number, etc.)
+ # because the transaction wasn't really approved
+ if ( $error ) {
+ $dbh->rollback;
+ return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
+ }
+ $payments++;
+ }
+ $self->set_status('R');
+ $dbh->commit;
+ 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 $conf->exists("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