diff options
author | Mark Wells <mark@freeside.biz> | 2012-06-30 16:37:06 -0700 |
---|---|---|
committer | Mark Wells <mark@freeside.biz> | 2012-06-30 16:37:06 -0700 |
commit | 92aedddd3684167abb60cd3f1d77bbc156c592e6 (patch) | |
tree | f061fdd9dd2434ad6f490f01d682496b46a925d5 /FS/FS/pay_batch.pm | |
parent | e5fd495945bc0b907cf0d4d21d52bb6b12e7051a (diff) |
Business::BatchPayment interface, #17373
Diffstat (limited to 'FS/FS/pay_batch.pm')
-rw-r--r-- | FS/FS/pay_batch.pm | 454 |
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 |