X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fpay_batch.pm;h=c57c554984de1aaa5c0669afb38be5ebf1c2f688;hp=2a048a115321e3c1c36a7738fb635e590e313d9a;hb=f1d04f65cbacc2d5f4a286ef2a4c3f1b6b3943c2;hpb=61097b876e692dbf5571a17b9aa415949607085f diff --git a/FS/FS/pay_batch.pm b/FS/FS/pay_batch.pm index 2a048a115..c57c55498 100644 --- a/FS/FS/pay_batch.pm +++ b/FS/FS/pay_batch.pm @@ -1,21 +1,20 @@ 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::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; -use FS::Misc qw(send_email); # for error notification -use List::Util qw(sum); - -@ISA = qw(FS::Record); +use FS::Log; +use Try::Tiny; =head1 NAME @@ -57,6 +56,10 @@ from FS::Record. The following fields are currently supported: =item title - unique batch identifier +=item processor_id - + +=item type - batch type payents (DEBIT), or refunds (CREDIT) + For incoming batches, the combination of 'title', 'payby', and 'agentnum' must be unique. @@ -147,22 +150,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 @@ -223,7 +214,9 @@ foreach my $INC (@INC) { =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: @@ -234,6 +227,38 @@ I - an L module I - an L object for a batch gateway. This takes precedence over I. +I - do not try to close batches + +Supported format keys (defined in the specified FS::pay_batch module) are: + +I - required, can be CSV, fixed, variable, XML + +I - required list of field names for each row/line + +I - regular expression for fixed filetype + +I - required for variable filetype + +I - required for XML filetype + +I - required for XML filetype + +I - sub, ignore all lines before this returns true + +I - sub, stop processing lines when this returns true + +I - sub, runs immediately after end_condition returns true + +I - sub, skip lines when this returns true + +I - required, sub, runs before approved/declined conditions are checked + +I - required, sub, returns true when approved + +I - required, sub, returns true when declined + +I - sub, decide whether or not to close the batch + =cut sub import_results { @@ -264,6 +289,8 @@ sub import_results { 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 $SIG{HUP} = 'IGNORE'; @@ -277,13 +304,17 @@ sub import_results { local $FS::UID::AutoCommit = 0; my $dbh = dbh; - my $reself = $self->select_for_update; + if ( ref($self) ) { + # if called on a specific pay_batch, check the status of that batch + # before continuing + my $reself = $self->select_for_update; - if ( $reself->status ne 'I' - and !$conf->exists('batch-manual_approval') ) { - $dbh->rollback if $oldAutoCommit; - return "batchnum ". $self->batchnum. "no longer in transit"; - } + 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; @@ -329,6 +360,7 @@ sub import_results { push @all_values, \@values; } elsif ($filetype eq 'variable') { + # no longer used my @values = ( eval { $parse->($self, $line) } ); if( $@ ) { $dbh->rollback if $oldAutoCommit; @@ -388,6 +420,9 @@ sub import_results { 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, @@ -427,35 +462,39 @@ sub import_results { } # foreach (@all_values) - 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->($self) }; - if ( $@ ) { - $dbh->rollback; - die $@; - } - } - if ( $close ) { - my $error = $self->set_status('R'); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - } + # 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 $@; + } + } + if ( $close ) { + my $error = $pay_batch->set_status('R'); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + } # foreach $batchnum + } # if (!$param->{no_close}) $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 $gatewaynum = delete $param->{'gatewaynum'}; @@ -533,8 +572,8 @@ sub import_from_gateway { ); my @item_errors; - my $mail_on_error = $conf->config('batch-errors_to'); - if ( $mail_on_error ) { + 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) = @_; @@ -544,7 +583,14 @@ sub import_from_gateway { my $processor = $gateway->batch_processor(%proc_opt); - my @batches = $processor->receive; + 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; @@ -573,7 +619,8 @@ sub import_from_gateway { my $error; my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module . - ':' . $item->authorization . ':' . $item->order_number; + ':' . ($item->authorization || '') . + ':' . ($item->order_number || ''); if ( $batch->incoming ) { # This is a one-way batch. @@ -735,7 +782,8 @@ sub import_from_gateway { $total += $cust_pay_batch->paid; } else { - $error = $cust_pay_batch->decline($item->error_message); + $error = $cust_pay_batch->decline($item->error_message, + $item->failure_status); } if ( $error ) { @@ -759,15 +807,10 @@ sub import_from_gateway { "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, - ); + 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; @@ -851,7 +894,8 @@ Prepare the batch to be exported. This will: 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. + balance is zero, the entry will be removed (caution: all cust_pay_batch + entries might be removed!) Use this within a transaction. @@ -866,8 +910,6 @@ sub prepare_for_export { my $status = $self->status; if ($status eq 'O') { $first_download = 1; - my $error = $self->set_status('I'); - 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' && @@ -901,7 +943,7 @@ sub prepare_for_export { my $balance = $cust_pay_batch->cust_main->balance; if ($balance <= 0) { # then don't charge this customer - my $error = $cust_pay_batch->delete; + 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 @@ -911,6 +953,11 @@ sub prepare_for_export { } # 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 ''; @@ -924,6 +971,10 @@ module, in which case the configuration options are in 'batchconfig-FORMAT'. Alternatively, GATEWAY can be an L object set to a L module. +Returns the text of the batch. If batch contains no cust_pay_batch entries +(or has them all removed by L) then the batch will be +resolved and a blank string will be returned. All other errors are fatal. + =cut sub export_batch { @@ -946,7 +997,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; @@ -959,6 +1010,12 @@ sub export_batch { 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"; @@ -1003,6 +1060,10 @@ 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) then nothing will be transported (or written to +the override file) and the batch will be resolved. + =cut sub export_to_gateway { @@ -1023,11 +1084,28 @@ sub export_to_gateway { 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); + 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; ''; @@ -1080,7 +1158,152 @@ sub manual_approve { return; } +=item batch_download_formats + +returns a hash of batch download formats. + +my %download_formats = FS::pay_batch::batch_download_formats; + +=cut + +sub batch_download_formats { + + my @formats = ( + '' => + 'Default batch mode', + 'NACHA' => + '94 byte NACHA', + 'csv-td_canada_trust-merchant_pc_batch' => + 'CSV file for TD Canada Trust Merchant PC Batch', + 'csv-chase_canada-E-xactBatch' => + 'CSV file for Chase Canada E-xactBatch', + 'PAP' => + '80 byte file for TD Canada Trust PAP Batch', + 'BoM' => + 'Bank of Montreal ECA batch', + 'ach-spiritone' => + 'Spiritone ACH batch', + 'paymentech' => + 'XML file for Chase Paymentech', + 'RBC' => + 'Royal Bank of Canada PDS batch', + 'td_eft1464' => + '1464 byte file for TD Commercial Banking EFT', + 'eft_canada' => + 'EFT Canada CSV batch', + 'CIBC' => + '80 byte file for Canadian Imperial Bank of Commerce', + # insert new batch formats here + ); + +} + +=item batch_download_formats + +returns a hash of batch download formats. + +my %download_formats = FS::pay_batch::batch_download_formats; + +=cut + +sub can_handle_electronic_refunds { + + my $self = shift; + my $format = shift; + my $conf = new FS::Conf; + + tie my %download_formats, 'Tie::IxHash', batch_download_formats; + + my %paybatch_mods = ( + 'NACHA' => 'nacha', + 'csv-td_canada_trust-merchant_pc_batch' => 'td_canada_trust', + 'csv-chase_canada-E-xactBatch' => 'chase-canada', + 'PAP' => 'PAP', + 'BoM' => 'BoM', + 'ach-spiritone' => 'ach_spiritone', + 'paymentech' => 'paymentech', + 'RBC' => 'RBC', + 'td_eft1464' => 'td_eft1464', + 'eft_canada' => 'eft_canada', + 'CIBC' => 'CIBC', + ); + + %download_formats = ( $format => $download_formats{$format}, ) if $format; + + foreach my $key (keys %download_formats) { + my $mod = "FS::pay_batch::".$paybatch_mods{$key}; + if ($mod->can('can_handle_credits')) { + return '1' if $conf->exists('batchconfig-'.$key); + } + } + + return; + +} + +use FS::upgrade_journal; sub _upgrade_data { + + # check if there are any pending batch refunds and no download format configured + # that allows electronic refunds. + unless ( FS::upgrade_journal->is_done('removed_refunds_nodownload_format') ) { + + ## get a list of all refunds in batches. + my $extrasql = " LEFT JOIN pay_batch USING ( batchnum ) WHERE cust_pay_batch.paycode = 'C' AND pay_batch.download IS NULL AND pay_batch.type = 'DEBIT' "; + + my @batch_refunds = qsearch({ + 'table' => 'cust_pay_batch', + 'select' => 'cust_pay_batch.*', + 'extra_sql' => $extrasql, + }); + + my $replace_error; + + if (@batch_refunds) { + warn "found ".scalar @batch_refunds." batch refunds.\n"; + warn "Searching for their cust refunds...\n" if (scalar @batch_refunds > 0); + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + ## move refund to credit batch. + foreach my $cust_pay_batch (@batch_refunds) { + my $payby = $cust_pay_batch->payby eq "CARD" ? "CARD" : "CHEK"; + + my %pay_batch = ( + 'status' => 'O', + 'payby' => $payby, + 'type' => 'CREDIT', + ); + + my $pay_batch = qsearchs( 'pay_batch', \%pay_batch ); + + unless ( $pay_batch ) { + $pay_batch = new FS::pay_batch \%pay_batch; + my $error = $pay_batch->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + warn "error creating a $payby credit batch: $error\n"; + } + } + + $cust_pay_batch->batchnum($pay_batch->batchnum); + $replace_error = $cust_pay_batch->replace(); + if ( $replace_error ) { + $dbh->rollback if $oldAutoCommit; + warn "Unable to move credit to a credit batch: $replace_error"; + } + else { + warn "Moved cust pay credit ".$cust_pay_batch->paybatchnum." to ".$cust_pay_batch->payby." credit batch ".$cust_pay_batch->batchnum."\n"; + } + } + } #end @batch_refunds + else { warn "No batch refunds found\n"; } + + FS::upgrade_journal->set_done('removed_refunds_nodownload_format') unless $replace_error; + } + # Set up configuration for gateways that have a Business::BatchPayment # module.