summaryrefslogtreecommitdiff
path: root/FS/FS/pay_batch.pm
diff options
context:
space:
mode:
authorMark Wells <mark@freeside.biz>2012-06-30 16:37:06 -0700
committerMark Wells <mark@freeside.biz>2012-06-30 16:37:06 -0700
commit92aedddd3684167abb60cd3f1d77bbc156c592e6 (patch)
treef061fdd9dd2434ad6f490f01d682496b46a925d5 /FS/FS/pay_batch.pm
parente5fd495945bc0b907cf0d4d21d52bb6b12e7051a (diff)
Business::BatchPayment interface, #17373
Diffstat (limited to 'FS/FS/pay_batch.pm')
-rw-r--r--FS/FS/pay_batch.pm454
1 files changed, 412 insertions, 42 deletions
diff --git a/FS/FS/pay_batch.pm b/FS/FS/pay_batch.pm
index bb92bdf..4f223e1 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<FS::agent> object for this template.
+Returns the L<FS::agent> object for this batch.
=cut
@@ -132,6 +134,16 @@ sub agent {
qsearchs('agent', { 'agentnum' => $_[0]->agentnum });
}
+=item cust_pay_batch
+
+Returns all L<FS::cust_pay_batch> 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<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>.
=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<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:
+
+- file: 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 $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<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.
+=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<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)
+
+=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