X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fpay_batch.pm;h=b6b69f3adbd2442a3e65fde7c1dd7a3c1a2bbc91;hp=4f223e1137ba6a62291bd2930c30f53642b27c43;hb=7516e3da0f17eeecba27219ef96a8b5f46af2083;hpb=85e677b86fc37c54e6de2b06340351a28f5a5916 diff --git a/FS/FS/pay_batch.pm b/FS/FS/pay_batch.pm index 4f223e113..b6b69f3ad 100644 --- a/FS/FS/pay_batch.pm +++ b/FS/FS/pay_batch.pm @@ -1,19 +1,19 @@ 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(cardtype); +use FS::Misc qw(send_email); # for error notification use FS::Record qw( dbh qsearch qsearchs ); use FS::Conf; 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); =head1 NAME @@ -49,10 +49,14 @@ from FS::Record. The following fields are currently supported: =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 @@ -118,9 +122,22 @@ sub check { || $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; } @@ -128,22 +145,10 @@ sub check { Returns the L object for this batch. -=cut - -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 @@ -182,7 +187,7 @@ foreach my $INC (@INC) { \\%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; @@ -225,11 +230,6 @@ sub import_results { 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"; @@ -387,12 +387,12 @@ sub import_results { foreach ('paid', '_date', 'payinfo') { $new_cust_pay_batch->$_($hash{$_}) if $hash{$_}; } - $error = $new_cust_pay_batch->approve($hash{'paybatch'} || $self->batchnum); + $error = $new_cust_pay_batch->approve(%hash); $total += $hash{'paid'}; } elsif ( &{$declined_condition}(\%hash) ) { - $error = $new_cust_pay_batch->decline; + $error = $new_cust_pay_batch->decline($hash{'error_message'});; } @@ -436,17 +436,12 @@ sub import_results { } -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) @@ -461,12 +456,20 @@ sub process_import_results { '<', "$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; } -=item import_from_gateway GATEWAY [ OPTIONS ] +=item import_from_gateway [ OPTIONS ] Import results from a L, using Business::BatchPayment, and apply them. GATEWAY must use the Business::BatchPayment namespace. @@ -477,15 +480,16 @@ 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. +- gateway: the L, required +- filehandle: 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 $gateway = $opt{'gateway'}; my $conf = FS::Conf->new; # unavoidable duplication with import_batch, for now @@ -508,121 +512,257 @@ sub import_from_gateway { unless eval { $gateway->isa('FS::payment_gateway') }; my %proc_opt = ( - 'input' => $opt{'file'}, # will do nothing if it's empty + 'input' => $opt{'filehandle'}, # will do nothing if it's empty # any other constructor options go here ); + my @item_errors; + my $mail_on_error = $conf->config('batch-errors_to'); + if ( $mail_on_error ) { + # 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 @batches = $processor->receive; - my $error; + 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) { - # 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"; - } + 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; - # 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; - } + 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"; + } - 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 + 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; } - elsif ( !$reconsider ) { - # then we're not allowed to change its status, so bail out - $error = "paybatchnum ".$item->tid. + $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 . "'"; - 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; + if ( $error ) { + push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error"; + next ITEM; + } - # 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); - } + 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 - # 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 ) ), + $job->update_statustext(int(100 * $num/( $total_items ) ), 'Importing batch items') - if $job; + if $job; + } #foreach $item - if ( $error ) { + } #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 ( $mail_on_error ) { + my $subject = "Batch import errors"; #? + my $body = "Import from gateway ".$gateway->label."\n".$error_text; + send_email( + to => $mail_on_error, + from => $conf->config('invoice_from'), + subject => $subject, + body => $body, + ); + } else { + # Bail out. $dbh->rollback if $oldAutoCommit; - return $error; + die $error_text; } + } - } #foreach $batch (input batch, not pay_batch) - - # Auto-resolve + # Auto-resolve (with brute-force error handling) foreach my $pay_batch (values %pay_batch_for_update) { - $error = $pay_batch->try_to_resolve; + my $error = $pay_batch->try_to_resolve; if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -637,7 +777,7 @@ sub import_from_gateway { =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' +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 @@ -658,8 +798,8 @@ sub try_to_resolve { } ); - if ( @unresolved ) { - my $days = $conf->config('batch-auto_resolve_days') || ''; + 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 @@ -680,6 +820,9 @@ sub try_to_resolve { } return $error if $error; } + } elsif ( @unresolved ) { + # auto resolve is not enabled, and we're not ready to resolve + return; } $self->set_status('R'); @@ -712,6 +855,9 @@ sub prepare_for_export { return "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"; } @@ -785,7 +931,7 @@ sub export_batch { my $info = $export_info{$format} or die "Format not found: '$format'\n"; - &{$info->{'init'}}($conf) if exists($info->{'init'}); + &{$info->{'init'}}($conf, $self->agentnum) if exists($info->{'init'}); my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; @@ -876,7 +1022,6 @@ sub manual_approve { my $self = shift; my $date = time; my %opt = @_; - my $paybatch = $opt{'paybatch'} || $self->batchnum; my $usernum = $opt{'usernum'} || die "manual approval requires a usernum"; my $conf = FS::Conf->new; return 'manual batch approval disabled' @@ -906,7 +1051,9 @@ sub manual_approve { '_date' => $date, 'usernum' => $usernum, }; - my $error = $new_cust_pay_batch->approve($paybatch); + 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"; @@ -931,7 +1078,7 @@ sub _upgrade_data { for my $format (keys %export_info) { my $mod = "FS::pay_batch::$format"; if ( $mod->can('_upgrade_gateway') - and length( $conf->config("batchconfig-$format") ) ) { + and $conf->exists("batchconfig-$format") ) { local $@; my ($module, %gw_options) = $mod->_upgrade_gateway; @@ -960,7 +1107,7 @@ sub _upgrade_data { # and if appropriate, make it the system default for my $payby (qw(CARD CHEK)) { - if ( $conf->config("batch-fixed_format-$payby") eq $format ) { + 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");