4 use vars qw( @ISA $DEBUG %import_info %export_info $conf );
7 use FS::Record qw( dbh qsearch qsearchs );
11 use Date::Parse qw(str2time);
12 use Business::CreditCard qw(cardtype);
13 use Scalar::Util 'blessed';
15 use FS::Misc qw(send_email); # for error notification
16 use List::Util qw(sum);
18 @ISA = qw(FS::Record);
22 FS::pay_batch - Object methods for pay_batch records
28 $record = new FS::pay_batch \%hash;
29 $record = new FS::pay_batch { 'column' => 'value' };
31 $error = $record->insert;
33 $error = $new_record->replace($old_record);
35 $error = $record->delete;
37 $error = $record->check;
41 An FS::pay_batch object represents an payment batch. FS::pay_batch inherits
42 from FS::Record. The following fields are currently supported:
46 =item batchnum - primary key
48 =item agentnum - optional agent number for agent batches
50 =item payby - CARD or CHEK
52 =item status - O (Open), I (In-transit), or R (Resolved)
54 =item download - time when the batch was first downloaded
56 =item upload - time when the batch was first uploaded
58 =item title - unique batch identifier
60 For incoming batches, the combination of 'title', 'payby', and 'agentnum'
71 Creates a new batch. To add the batch to the database, see L<"insert">.
73 Note that this stores the hash reference, not a distinct copy of the hash it
74 points to. You can ask the object for a copy with the I<hash> method.
78 # the new method can be inherited from FS::Record, if a table method is defined
80 sub table { 'pay_batch'; }
84 Adds this record to the database. If there is an error, returns the error,
85 otherwise returns false.
89 # the insert method can be inherited from FS::Record
93 Delete this record from the database.
97 # the delete method can be inherited from FS::Record
99 =item replace OLD_RECORD
101 Replaces the OLD_RECORD with this one in the database. If there is an error,
102 returns the error, otherwise returns false.
106 # the replace method can be inherited from FS::Record
110 Checks all fields to make sure this is a valid batch. If there is
111 an error, returns the error, otherwise returns false. Called by the insert
116 # the check method should currently be supplied - FS::Record contains some
117 # data checking routines
123 $self->ut_numbern('batchnum')
124 || $self->ut_enum('payby', [ 'CARD', 'CHEK' ])
125 || $self->ut_enum('status', [ 'O', 'I', 'R' ])
126 || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
127 || $self->ut_alphan('title')
129 return $error if $error;
131 if ( $self->title ) {
133 grep { !$self->batchnum or $_->batchnum != $self->batchnum }
134 qsearch('pay_batch', {
135 payby => $self->payby,
136 agentnum => $self->agentnum,
137 title => $self->title,
139 return "Batch already exists as batchnum ".$existing[0]->batchnum
148 Returns the L<FS::agent> object for this batch.
153 qsearchs('agent', { 'agentnum' => $_[0]->agentnum });
158 Returns all L<FS::cust_pay_batch> objects for this batch.
163 qsearch('cust_pay_batch', { 'batchnum' => $_[0]->batchnum });
180 $self->status(shift);
181 $self->download(time)
182 if $self->status eq 'I' && ! $self->download;
184 if $self->status eq 'R' && ! $self->upload;
188 # further false laziness
190 %import_info = %export_info = ();
191 foreach my $INC (@INC) {
192 warn "globbing $INC/FS/pay_batch/*.pm\n" if $DEBUG;
193 foreach my $file ( glob("$INC/FS/pay_batch/*.pm")) {
194 warn "attempting to load batch format from $file\n" if $DEBUG;
195 $file =~ /\/(\w+)\.pm$/;
198 my ($import, $export, $name) =
199 eval "use FS::pay_batch::$mod;
200 ( \\%FS::pay_batch::$mod\::import_info,
201 \\%FS::pay_batch::$mod\::export_info,
202 \$FS::pay_batch::$mod\::name)";
203 $name ||= $mod; # in case it's not defined
205 # in FS::cdr this is a die, not a warn. That's probably a bug.
206 warn "error using FS::pay_batch::$mod (skipping): $@\n";
209 if(!keys(%$import)) {
210 warn "no \%import_info found in FS::pay_batch::$mod (skipping)\n";
213 $import_info{$name} = $import;
215 if(!keys(%$export)) {
216 warn "no \%export_info found in FS::pay_batch::$mod (skipping)\n";
219 $export_info{$name} = $export;
224 =item import_results OPTION => VALUE, ...
226 Import batch results.
230 I<filehandle> - open filehandle of results file.
232 I<format> - an L<FS::pay_batch> module
234 I<gateway> - an L<FS::payment_gateway> object for a batch gateway. This
235 takes precedence over I<format>.
242 my $param = ref($_[0]) ? shift : { @_ };
243 my $fh = $param->{'filehandle'};
244 my $job = $param->{'job'};
245 $job->update_statustext(0) if $job;
247 my $format = $param->{'format'};
248 my $info = $import_info{$format}
249 or die "unknown format $format";
251 my $conf = new FS::Conf;
253 my $filetype = $info->{'filetype'}; # CSV, fixed, variable
254 my @fields = @{ $info->{'fields'}};
255 my $formatre = $info->{'formatre'}; # for fixed
256 my $parse = $info->{'parse'}; # for variable
258 my $begin_condition = $info->{'begin_condition'};
259 my $end_condition = $info->{'end_condition'};
260 my $end_hook = $info->{'end_hook'};
261 my $skip_condition = $info->{'skip_condition'};
262 my $hook = $info->{'hook'};
263 my $approved_condition = $info->{'approved'};
264 my $declined_condition = $info->{'declined'};
265 my $close_condition = $info->{'close_condition'};
267 my $csv = new Text::CSV_XS;
269 local $SIG{HUP} = 'IGNORE';
270 local $SIG{INT} = 'IGNORE';
271 local $SIG{QUIT} = 'IGNORE';
272 local $SIG{TERM} = 'IGNORE';
273 local $SIG{TSTP} = 'IGNORE';
274 local $SIG{PIPE} = 'IGNORE';
276 my $oldAutoCommit = $FS::UID::AutoCommit;
277 local $FS::UID::AutoCommit = 0;
280 my $reself = $self->select_for_update;
282 if ( $reself->status ne 'I'
283 and !$conf->exists('batch-manual_approval') ) {
284 $dbh->rollback if $oldAutoCommit;
285 return "batchnum ". $self->batchnum. "no longer in transit";
291 if ($filetype eq 'XML') {
292 eval "use XML::Simple";
294 my @xmlkeys = @{ $info->{'xmlkeys'} }; # for XML
295 my $xmlrow = $info->{'xmlrow'}; # also for XML
297 # Do everything differently.
298 my $data = XML::Simple::XMLin($fh, KeepRoot => 1);
300 # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
301 $rows = $rows->{$_} foreach( @$xmlrow );
302 if(!defined($rows)) {
303 $dbh->rollback if $oldAutoCommit;
304 return "can't find rows in XML file";
306 $rows = [ $rows ] if ref($rows) ne 'ARRAY';
307 foreach my $row (@$rows) {
308 push @all_values, [ @{$row}{@xmlkeys}, $row ];
312 while ( defined($line=<$fh>) ) {
314 next if $line =~ /^\s*$/; #skip blank lines
316 if ($filetype eq "CSV") {
317 $csv->parse($line) or do {
318 $dbh->rollback if $oldAutoCommit;
319 return "can't parse: ". $csv->error_input();
321 push @all_values, [ $csv->fields(), $line ];
322 }elsif ($filetype eq 'fixed'){
323 my @values = ( $line =~ /$formatre/ );
325 $dbh->rollback if $oldAutoCommit;
326 return "can't parse: ". $line;
329 push @all_values, \@values;
331 elsif ($filetype eq 'variable') {
332 my @values = ( eval { $parse->($self, $line) } );
334 $dbh->rollback if $oldAutoCommit;
338 push @all_values, \@values;
341 $dbh->rollback if $oldAutoCommit;
342 return "Unknown file type $filetype";
348 foreach (@all_values) {
351 $job->update_statustext(int(100 * $num/scalar(@all_values)));
356 my $line = pop @values;
357 foreach my $field ( @fields ) {
358 my $value = shift @values;
360 $hash{$field} = $value;
363 if ( defined($begin_condition) ) {
364 if ( &{$begin_condition}(\%hash, $line) ) {
365 undef $begin_condition;
372 if ( defined($end_condition) and &{$end_condition}(\%hash, $line) ) {
374 $error = &{$end_hook}(\%hash, $total, $line) if defined($end_hook);
376 $dbh->rollback if $oldAutoCommit;
382 if ( defined($skip_condition) and &{$skip_condition}(\%hash, $line) ) {
387 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
388 unless ( $cust_pay_batch ) {
389 return "unknown paybatchnum $hash{'paybatchnum'}\n";
391 my $custnum = $cust_pay_batch->custnum,
392 my $payby = $cust_pay_batch->payby,
394 &{$hook}(\%hash, $cust_pay_batch->hashref);
396 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
399 if ( &{$approved_condition}(\%hash) ) {
401 foreach ('paid', '_date', 'payinfo') {
402 $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
404 $error = $new_cust_pay_batch->approve($hash{'paybatch'} || $self->batchnum);
405 $total += $hash{'paid'};
407 } elsif ( &{$declined_condition}(\%hash) ) {
409 $error = $new_cust_pay_batch->decline;
414 $dbh->rollback if $oldAutoCommit;
418 # purge CVV when the batch is processed
419 if ( $payby =~ /^(CARD|DCRD)$/ ) {
420 my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
421 if ( ! grep { $_ eq cardtype($payinfo) }
422 $conf->config('cvv-save') ) {
423 $new_cust_pay_batch->cust_main->remove_cvv;
428 } # foreach (@all_values)
431 if ( defined($close_condition) ) {
432 # Allow the module to decide whether to close the batch.
433 # $close_condition can also die() to abort the whole import.
434 $close = eval { $close_condition->($self) };
441 my $error = $self->set_status('R');
443 $dbh->rollback if $oldAutoCommit;
448 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
456 sub process_import_results {
458 my $param = thaw(decode_base64(shift));
459 $param->{'job'} = $job;
460 warn Dumper($param) if $DEBUG;
461 my $gatewaynum = delete $param->{'gatewaynum'};
463 $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum)
464 or die "gatewaynum '$gatewaynum' not found\n";
465 delete $param->{'format'}; # to avoid confusion
468 my $file = $param->{'uploaded_files'} or die "no files provided\n";
469 $file =~ s/^(\w+):([\.\w]+)$/$2/;
470 my $dir = '%%%FREESIDE_CACHE%%%/cache.' . $FS::UID::datasrc;
471 open( $param->{'filehandle'},
474 or die "unable to open '$file'.\n";
477 if ( $param->{gateway} ) {
478 $error = FS::pay_batch->import_from_gateway(%$param);
480 my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
481 my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
482 $error = $batch->import_results($param);
485 die $error if $error;
488 =item import_from_gateway [ OPTIONS ]
490 Import results from a L<FS::payment_gateway>, using Business::BatchPayment,
491 and apply them. GATEWAY must use the Business::BatchPayment namespace.
493 This is a class method, since results can be applied to any batch.
494 The 'batch-reconsider' option determines whether an already-approved
495 or declined payment can have its status changed by a later import.
499 - gateway: the L<FS::payment_gateway>, required
500 - filehandle: a file name or handle to use as a data source.
501 - job: an L<FS::queue> object to update with progress messages.
505 sub import_from_gateway {
508 my $gateway = $opt{'gateway'};
509 my $conf = FS::Conf->new;
511 # unavoidable duplication with import_batch, for now
512 local $SIG{HUP} = 'IGNORE';
513 local $SIG{INT} = 'IGNORE';
514 local $SIG{QUIT} = 'IGNORE';
515 local $SIG{TERM} = 'IGNORE';
516 local $SIG{TSTP} = 'IGNORE';
517 local $SIG{PIPE} = 'IGNORE';
519 my $oldAutoCommit = $FS::UID::AutoCommit;
520 local $FS::UID::AutoCommit = 0;
523 my $job = delete($opt{'job'});
524 $job->update_statustext(0) if $job;
527 return "import_from_gateway requires a payment_gateway"
528 unless eval { $gateway->isa('FS::payment_gateway') };
531 'input' => $opt{'filehandle'}, # will do nothing if it's empty
532 # any other constructor options go here
536 my $mail_on_error = $conf->config('batch-errors_to');
537 if ( $mail_on_error ) {
538 # construct error trap
539 $proc_opt{'on_parse_error'} = sub {
540 my ($self, $line, $error) = @_;
541 push @item_errors, " '$line'\n$error";
545 my $processor = $gateway->batch_processor(%proc_opt);
547 my @batches = $processor->receive;
551 my $total_items = sum( map{$_->count} @batches);
553 # whether to allow items to change status
554 my $reconsider = $conf->exists('batch-reconsider');
556 # mutex all affected batches
557 my %pay_batch_for_update;
559 my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
561 BATCH: foreach my $batch (@batches) {
563 my %incoming_batch = (
568 ITEM: foreach my $item ($batch->elements) {
570 my $cust_pay_batch; # the new batch entry (with status)
571 my $pay_batch; # the freeside batch it belongs to
572 my $payby; # CARD or CHEK
575 # follow realtime gateway practice here
576 # though eventually this stuff should go into separate fields...
577 my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module .
578 ':' . $item->authorization . ':' . $item->order_number;
580 if ( $batch->incoming ) {
581 # This is a one-way batch.
582 # Locate the customer, find an open batch correct for them,
583 # create a payment. Don't bother creating a cust_pay_batch
586 if ( defined($item->customer_id)
587 and $item->customer_id =~ /^\d+$/
588 and $item->customer_id > 0 ) {
590 $cust_main = FS::cust_main->by_key($item->customer_id)
591 || qsearchs('cust_main',
592 { 'agent_custid' => $item->customer_id }
595 push @item_errors, "Unknown customer_id ".$item->customer_id;
600 push @item_errors, "Illegal customer_id '".$item->customer_id."'";
603 # it may also make sense to allow selecting the customer by
604 # invoice_number, but no modules currently work that way
606 $payby = $bop2payby{ $item->payment_type };
608 $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
610 # create a batch if necessary
611 $pay_batch = $incoming_batch{$payby}->{$agentnum} ||=
613 status => 'R', # pre-resolve it
615 agentnum => $agentnum,
617 title => $batch->batch_id,
619 if ( !$pay_batch->batchnum ) {
620 $error = $pay_batch->insert;
621 die $error if $error; # can't do anything if this fails
624 if ( !$item->approved ) {
625 $error ||= "payment rejected - ".$item->error_message;
627 if ( !defined($item->amount) or $item->amount <= 0 ) {
628 $error ||= "no amount in item $num";
632 if ( $item->check_number ) {
633 $payby = 'BILL'; # right?
634 $payinfo = $item->check_number;
635 } elsif ( $item->assigned_token ) {
636 $payinfo = $item->assigned_token;
639 my $cust_pay = FS::cust_pay->new(
641 custnum => $cust_main->custnum,
642 _date => $item->payment_date->epoch,
643 paid => sprintf('%.2f',$item->amount),
645 invnum => $item->invoice_number,
646 batchnum => $pay_batch->batchnum,
647 paybatch => $paybatch,
651 $error ||= $cust_pay->insert;
652 eval { $cust_main->apply_payments };
656 push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
660 # This is a request/reply batch.
661 # Locate the request (the 'tid' attribute is the paybatchnum).
662 my $paybatchnum = $item->tid;
663 $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
664 if (!$cust_pay_batch) {
665 push @item_errors, "paybatchnum $paybatchnum not found";
668 $payby = $cust_pay_batch->payby;
670 my $batchnum = $cust_pay_batch->batchnum;
671 if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
672 warn "batch ID ".$batch->batch_id.
673 " does not match batchnum ".$cust_pay_batch->batchnum."\n";
676 # lock the batch and check its status
677 $pay_batch = FS::pay_batch->by_key($batchnum);
678 $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
679 if ( $pay_batch->status ne 'I' and !$reconsider ) {
680 $error = "batch $batchnum no longer in transit";
683 if ( $cust_pay_batch->status ) {
684 my $new_status = $item->approved ? 'approved' : 'declined';
685 if ( lc( $cust_pay_batch->status ) eq $new_status ) {
686 # already imported with this status, so don't touch
689 elsif ( !$reconsider ) {
690 # then we're not allowed to change its status, so bail out
691 $error = "paybatchnum ".$item->tid.
692 " already resolved with status '". $cust_pay_batch->status . "'";
697 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
702 # update payinfo, if needed
703 if ( $item->assigned_token ) {
704 $new_payinfo = $item->assigned_token;
705 } elsif ( $payby eq 'CARD' ) {
706 $new_payinfo = $item->card_number if $item->card_number;
707 } else { #$payby eq 'CHEK'
708 $new_payinfo = $item->account_number . '@' . $item->routing_code
709 if $item->account_number;
711 $cust_pay_batch->set('payinfo', $new_payinfo) if $new_payinfo;
713 # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
714 # paid, if the batch says it's different from the amount requested
715 if ( defined $item->amount ) {
716 $cust_pay_batch->set('paid', $item->amount);
718 $cust_pay_batch->set('paid', $cust_pay_batch->amount);
721 # set payment date to when it was processed
722 $cust_pay_batch->_date($item->payment_date->epoch)
723 if $item->payment_date;
726 if ( $item->approved ) {
727 # follow Billing_Realtime format for paybatch
728 $error = $cust_pay_batch->approve($paybatch);
729 $total += $cust_pay_batch->paid;
732 $error = $cust_pay_batch->decline($item->error_message);
736 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
742 $job->update_statustext(int(100 * $num/( $total_items ) ),
743 'Importing batch items')
748 } #foreach $batch (input batch, not pay_batch)
750 # Format an error message
751 if ( @item_errors ) {
752 my $error_text = join("\n\n",
753 "Errors during batch import: ".scalar(@item_errors),
756 if ( $mail_on_error ) {
757 my $subject = "Batch import errors"; #?
758 my $body = "Import from gateway ".$gateway->label."\n".$error_text;
760 to => $mail_on_error,
761 from => $conf->config('invoice_from'),
767 $dbh->rollback if $oldAutoCommit;
772 # Auto-resolve (with brute-force error handling)
773 foreach my $pay_batch (values %pay_batch_for_update) {
774 my $error = $pay_batch->try_to_resolve;
777 $dbh->rollback if $oldAutoCommit;
782 $dbh->commit if $oldAutoCommit;
788 Resolve this batch if possible. A batch can be resolved if all of its
789 entries have status. If the system options 'batch-auto_resolve_days'
790 and 'batch-auto_resolve_status' are set, and the batch's download date is
791 at least (batch-auto_resolve_days) before the current time, then it can
792 be auto-resolved; entries with no status will be approved or declined
793 according to the batch-auto_resolve_status setting.
799 my $conf = FS::Conf->new;;
801 return if $self->status ne 'I';
803 my @unresolved = qsearch('cust_pay_batch',
805 batchnum => $self->batchnum,
811 my $days = $conf->config('batch-auto_resolve_days') || '';
812 # either 'approve' or 'decline'
813 my $action = $conf->config('batch-auto_resolve_status') || '';
817 time > ($self->download + 86400 * $days)
821 foreach my $cpb (@unresolved) {
822 if ( $action eq 'approve' ) {
823 # approve it for the full amount
824 $cpb->set('paid', $cpb->amount) unless ($cpb->paid || 0) > 0;
825 $error = $cpb->approve($self->batchnum);
827 elsif ( $action eq 'decline' ) {
828 $error = $cpb->decline('No response from processor');
830 return $error if $error;
834 $self->set_status('R');
837 =item prepare_for_export
839 Prepare the batch to be exported. This will:
840 - Set the status to "in transit".
841 - If batch-increment_expiration is set and this is a credit card batch,
842 increment expiration dates that are in the past.
843 - If this is the first download for this batch, adjust payment amounts to
844 not be greater than the customer's current balance. If the customer's
845 balance is zero, the entry will be removed.
847 Use this within a transaction.
851 sub prepare_for_export {
853 my $conf = FS::Conf->new;
854 my $curuser = $FS::CurrentUser::CurrentUser;
857 my $status = $self->status;
858 if ($status eq 'O') {
860 my $error = $self->set_status('I');
861 return "error updating pay_batch status: $error\n" if $error;
862 } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
865 die "No pending batch.\n";
868 my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
869 $self->cust_pay_batch;
871 # handle batch-increment_expiration option
872 if ( $self->payby eq 'CARD' ) {
873 my ($cmon, $cyear) = (localtime(time))[4,5];
874 foreach (@cust_pay_batch) {
875 my $etime = str2time($_->exp) or next;
876 my ($day, $mon, $year) = (localtime($etime))[3,4,5];
877 if( $conf->exists('batch-increment_expiration') ) {
878 $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
879 $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
881 my $error = $_->replace;
882 return $error if $error;
886 if ($first_download) { #remove or reduce entries if customer's balance changed
888 foreach my $cust_pay_batch (@cust_pay_batch) {
890 my $balance = $cust_pay_batch->cust_main->balance;
891 if ($balance <= 0) { # then don't charge this customer
892 my $error = $cust_pay_batch->delete;
893 return $error if $error;
894 } elsif ($balance < $cust_pay_batch->amount) {
895 # reduce the charge to the remaining balance
896 $cust_pay_batch->amount($balance);
897 my $error = $cust_pay_batch->replace;
898 return $error if $error;
900 # else $balance >= $cust_pay_batch->amount
902 } #if $first_download
907 =item export_batch [ format => FORMAT | gateway => GATEWAY ]
909 Export batch for processing. FORMAT is the name of an L<FS::pay_batch>
910 module, in which case the configuration options are in 'batchconfig-FORMAT'.
912 Alternatively, GATEWAY can be an L<FS::payment_gateway> object set to a
913 L<Business::BatchPayment> module.
921 my $conf = new FS::Conf;
924 my $gateway = $opt{'gateway'};
926 # welcome to the future
927 my $fh = IO::Scalar->new(\$batch);
928 $self->export_to_gateway($gateway, 'file' => $fh);
932 my $format = $opt{'format'} || $conf->config('batch-default_format')
933 or die "No batch format configured\n";
935 my $info = $export_info{$format} or die "Format not found: '$format'\n";
937 &{$info->{'init'}}($conf) if exists($info->{'init'});
939 my $oldAutoCommit = $FS::UID::AutoCommit;
940 local $FS::UID::AutoCommit = 0;
943 my $error = $self->prepare_for_export;
945 die $error if $error;
949 my @cust_pay_batch = $self->cust_pay_batch;
951 my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
953 my $h = $info->{'header'};
954 if (ref($h) eq 'CODE') {
955 $batch .= &$h($self, \@cust_pay_batch). $delim;
957 $batch .= $h. $delim;
960 foreach my $cust_pay_batch (@cust_pay_batch) {
962 $batchtotal += $cust_pay_batch->amount;
964 &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
968 my $f = $info->{'footer'};
969 if (ref($f) eq 'CODE') {
970 $batch .= &$f($self, $batchcount, $batchtotal). $delim;
972 $batch .= $f. $delim;
975 if ($info->{'autopost'}) {
976 my $error = &{$info->{'autopost'}}($self, $batch);
978 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
983 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
987 =item export_to_gateway GATEWAY OPTIONS
989 Given L<FS::payment_gateway> GATEWAY, export the items in this batch to
990 that gateway via Business::BatchPayment. OPTIONS may include:
992 - file: override the default transport and write to this file (name or handle)
996 sub export_to_gateway {
998 my ($self, $gateway, %opt) = @_;
1000 my $oldAutoCommit = $FS::UID::AutoCommit;
1001 local $FS::UID::AutoCommit = 0;
1004 my $error = $self->prepare_for_export;
1005 die $error if $error;
1008 'output' => $opt{'file'}, # will do nothing if it's empty
1009 # any other constructor options go here
1011 my $processor = $gateway->batch_processor(%proc_opt);
1013 my @items = map { $_->request_item } $self->cust_pay_batch;
1014 my $batch = Business::BatchPayment->create(Batch =>
1015 batch_id => $self->batchnum,
1018 $processor->submit($batch);
1020 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1024 sub manual_approve {
1028 my $paybatch = $opt{'paybatch'} || $self->batchnum;
1029 my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
1030 my $conf = FS::Conf->new;
1031 return 'manual batch approval disabled'
1032 if ( ! $conf->exists('batch-manual_approval') );
1033 return 'batch already resolved' if $self->status eq 'R';
1034 return 'batch not yet submitted' if $self->status eq 'O';
1036 local $SIG{HUP} = 'IGNORE';
1037 local $SIG{INT} = 'IGNORE';
1038 local $SIG{QUIT} = 'IGNORE';
1039 local $SIG{TERM} = 'IGNORE';
1040 local $SIG{TSTP} = 'IGNORE';
1041 local $SIG{PIPE} = 'IGNORE';
1043 my $oldAutoCommit = $FS::UID::AutoCommit;
1044 local $FS::UID::AutoCommit = 0;
1048 foreach my $cust_pay_batch (
1049 qsearch('cust_pay_batch', { batchnum => $self->batchnum,
1052 my $new_cust_pay_batch = new FS::cust_pay_batch {
1053 $cust_pay_batch->hash,
1054 'paid' => $cust_pay_batch->amount,
1056 'usernum' => $usernum,
1058 my $error = $new_cust_pay_batch->approve($paybatch);
1061 return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
1065 $self->set_status('R');
1071 # Set up configuration for gateways that have a Business::BatchPayment
1074 eval "use Class::MOP;";
1076 warn "Moose/Class::MOP not available.\n$@\nSkipping pay_batch upgrade.\n";
1079 my $conf = FS::Conf->new;
1080 for my $format (keys %export_info) {
1081 my $mod = "FS::pay_batch::$format";
1082 if ( $mod->can('_upgrade_gateway')
1083 and length( $conf->config("batchconfig-$format") ) ) {
1086 my ($module, %gw_options) = $mod->_upgrade_gateway;
1087 my $gateway = FS::payment_gateway->new({
1088 gateway_namespace => 'Business::BatchPayment',
1089 gateway_module => $module,
1091 my $error = $gateway->insert(%gw_options);
1093 warn "Failed to migrate '$format' to a Business::BatchPayment::$module gateway:\n$error\n";
1097 # test whether it loads
1098 my $processor = eval { $gateway->batch_processor };
1099 if ( !$processor ) {
1100 warn "Couldn't load Business::BatchPayment module for '$format'.\n";
1101 # if not, remove it so it doesn't hang around and break things
1105 # remove the batchconfig-*
1106 warn "Created Business::BatchPayment gateway '".$gateway->label.
1107 "' for '$format' batch processing.\n";
1108 $conf->delete("batchconfig-$format");
1110 # and if appropriate, make it the system default
1111 for my $payby (qw(CARD CHEK)) {
1112 if ( $conf->config("batch-fixed_format-$payby") eq $format ) {
1113 warn "Setting as default for $payby.\n";
1114 $conf->set("batch-gateway-$payby", $gateway->gatewaynum);
1115 $conf->delete("batch-fixed_format-$payby");
1119 } #if can('_upgrade_gateway') and batchconfig-$format
1129 status is somewhat redundant now that download and upload exist
1133 L<FS::Record>, schema.html from the base documentation.