2 use base qw( FS::Record );
5 use vars qw( $DEBUG %import_info %export_info $conf );
6 use Scalar::Util qw(blessed);
8 use List::Util qw(sum);
11 use Date::Parse qw(str2time);
12 use Business::CreditCard qw(cardtype);
13 use FS::Misc qw(send_email); # for error notification
14 use FS::Record qw( dbh qsearch qsearchs );
20 FS::pay_batch - Object methods for pay_batch records
26 $record = new FS::pay_batch \%hash;
27 $record = new FS::pay_batch { 'column' => 'value' };
29 $error = $record->insert;
31 $error = $new_record->replace($old_record);
33 $error = $record->delete;
35 $error = $record->check;
39 An FS::pay_batch object represents an payment batch. FS::pay_batch inherits
40 from FS::Record. The following fields are currently supported:
44 =item batchnum - primary key
46 =item agentnum - optional agent number for agent batches
48 =item payby - CARD or CHEK
50 =item status - O (Open), I (In-transit), or R (Resolved)
52 =item download - time when the batch was first downloaded
54 =item upload - time when the batch was first uploaded
56 =item title - unique batch identifier
58 For incoming batches, the combination of 'title', 'payby', and 'agentnum'
69 Creates a new batch. To add the batch to the database, see L<"insert">.
71 Note that this stores the hash reference, not a distinct copy of the hash it
72 points to. You can ask the object for a copy with the I<hash> method.
76 # the new method can be inherited from FS::Record, if a table method is defined
78 sub table { 'pay_batch'; }
82 Adds this record to the database. If there is an error, returns the error,
83 otherwise returns false.
87 # the insert method can be inherited from FS::Record
91 Delete this record from the database.
95 # the delete method can be inherited from FS::Record
97 =item replace OLD_RECORD
99 Replaces the OLD_RECORD with this one in the database. If there is an error,
100 returns the error, otherwise returns false.
104 # the replace method can be inherited from FS::Record
108 Checks all fields to make sure this is a valid batch. If there is
109 an error, returns the error, otherwise returns false. Called by the insert
114 # the check method should currently be supplied - FS::Record contains some
115 # data checking routines
121 $self->ut_numbern('batchnum')
122 || $self->ut_enum('payby', [ 'CARD', 'CHEK' ])
123 || $self->ut_enum('status', [ 'O', 'I', 'R' ])
124 || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
125 || $self->ut_alphan('title')
127 return $error if $error;
129 if ( $self->title ) {
131 grep { !$self->batchnum or $_->batchnum != $self->batchnum }
132 qsearch('pay_batch', {
133 payby => $self->payby,
134 agentnum => $self->agentnum,
135 title => $self->title,
137 return "Batch already exists as batchnum ".$existing[0]->batchnum
146 Returns the L<FS::agent> object for this batch.
150 Returns all L<FS::cust_pay_batch> objects for this batch.
166 $self->status(shift);
167 $self->download(time)
168 if $self->status eq 'I' && ! $self->download;
170 if $self->status eq 'R' && ! $self->upload;
174 # further false laziness
176 %import_info = %export_info = ();
177 foreach my $INC (@INC) {
178 warn "globbing $INC/FS/pay_batch/*.pm\n" if $DEBUG;
179 foreach my $file ( glob("$INC/FS/pay_batch/*.pm")) {
180 warn "attempting to load batch format from $file\n" if $DEBUG;
181 $file =~ /\/(\w+)\.pm$/;
184 my ($import, $export, $name) =
185 eval "use FS::pay_batch::$mod;
186 ( \\%FS::pay_batch::$mod\::import_info,
187 \\%FS::pay_batch::$mod\::export_info,
188 \$FS::pay_batch::$mod\::name)";
189 $name ||= $mod; # in case it's not defined
191 # in FS::cdr this is a die, not a warn. That's probably a bug.
192 warn "error using FS::pay_batch::$mod (skipping): $@\n";
195 if(!keys(%$import)) {
196 warn "no \%import_info found in FS::pay_batch::$mod (skipping)\n";
199 $import_info{$name} = $import;
201 if(!keys(%$export)) {
202 warn "no \%export_info found in FS::pay_batch::$mod (skipping)\n";
205 $export_info{$name} = $export;
210 =item import_results OPTION => VALUE, ...
212 Import batch results.
216 I<filehandle> - open filehandle of results file.
218 I<format> - an L<FS::pay_batch> module
220 I<gateway> - an L<FS::payment_gateway> object for a batch gateway. This
221 takes precedence over I<format>.
228 my $param = ref($_[0]) ? shift : { @_ };
229 my $fh = $param->{'filehandle'};
230 my $job = $param->{'job'};
231 $job->update_statustext(0) if $job;
233 my $format = $param->{'format'};
234 my $info = $import_info{$format}
235 or die "unknown format $format";
237 my $conf = new FS::Conf;
239 my $filetype = $info->{'filetype'}; # CSV, fixed, variable
240 my @fields = @{ $info->{'fields'}};
241 my $formatre = $info->{'formatre'}; # for fixed
242 my $parse = $info->{'parse'}; # for variable
244 my $begin_condition = $info->{'begin_condition'};
245 my $end_condition = $info->{'end_condition'};
246 my $end_hook = $info->{'end_hook'};
247 my $skip_condition = $info->{'skip_condition'};
248 my $hook = $info->{'hook'};
249 my $approved_condition = $info->{'approved'};
250 my $declined_condition = $info->{'declined'};
251 my $close_condition = $info->{'close_condition'};
253 my $csv = new Text::CSV_XS;
255 local $SIG{HUP} = 'IGNORE';
256 local $SIG{INT} = 'IGNORE';
257 local $SIG{QUIT} = 'IGNORE';
258 local $SIG{TERM} = 'IGNORE';
259 local $SIG{TSTP} = 'IGNORE';
260 local $SIG{PIPE} = 'IGNORE';
262 my $oldAutoCommit = $FS::UID::AutoCommit;
263 local $FS::UID::AutoCommit = 0;
266 my $reself = $self->select_for_update;
268 if ( $reself->status ne 'I'
269 and !$conf->exists('batch-manual_approval') ) {
270 $dbh->rollback if $oldAutoCommit;
271 return "batchnum ". $self->batchnum. "no longer in transit";
277 if ($filetype eq 'XML') {
278 eval "use XML::Simple";
280 my @xmlkeys = @{ $info->{'xmlkeys'} }; # for XML
281 my $xmlrow = $info->{'xmlrow'}; # also for XML
283 # Do everything differently.
284 my $data = XML::Simple::XMLin($fh, KeepRoot => 1);
286 # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
287 $rows = $rows->{$_} foreach( @$xmlrow );
288 if(!defined($rows)) {
289 $dbh->rollback if $oldAutoCommit;
290 return "can't find rows in XML file";
292 $rows = [ $rows ] if ref($rows) ne 'ARRAY';
293 foreach my $row (@$rows) {
294 push @all_values, [ @{$row}{@xmlkeys}, $row ];
298 while ( defined($line=<$fh>) ) {
300 next if $line =~ /^\s*$/; #skip blank lines
302 if ($filetype eq "CSV") {
303 $csv->parse($line) or do {
304 $dbh->rollback if $oldAutoCommit;
305 return "can't parse: ". $csv->error_input();
307 push @all_values, [ $csv->fields(), $line ];
308 }elsif ($filetype eq 'fixed'){
309 my @values = ( $line =~ /$formatre/ );
311 $dbh->rollback if $oldAutoCommit;
312 return "can't parse: ". $line;
315 push @all_values, \@values;
317 elsif ($filetype eq 'variable') {
318 my @values = ( eval { $parse->($self, $line) } );
320 $dbh->rollback if $oldAutoCommit;
324 push @all_values, \@values;
327 $dbh->rollback if $oldAutoCommit;
328 return "Unknown file type $filetype";
334 foreach (@all_values) {
337 $job->update_statustext(int(100 * $num/scalar(@all_values)));
342 my $line = pop @values;
343 foreach my $field ( @fields ) {
344 my $value = shift @values;
346 $hash{$field} = $value;
349 if ( defined($begin_condition) ) {
350 if ( &{$begin_condition}(\%hash, $line) ) {
351 undef $begin_condition;
358 if ( defined($end_condition) and &{$end_condition}(\%hash, $line) ) {
360 $error = &{$end_hook}(\%hash, $total, $line) if defined($end_hook);
362 $dbh->rollback if $oldAutoCommit;
368 if ( defined($skip_condition) and &{$skip_condition}(\%hash, $line) ) {
373 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
374 unless ( $cust_pay_batch ) {
375 return "unknown paybatchnum $hash{'paybatchnum'}\n";
377 my $custnum = $cust_pay_batch->custnum,
378 my $payby = $cust_pay_batch->payby,
380 &{$hook}(\%hash, $cust_pay_batch->hashref);
382 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
385 if ( &{$approved_condition}(\%hash) ) {
387 foreach ('paid', '_date', 'payinfo') {
388 $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
390 $error = $new_cust_pay_batch->approve(%hash);
391 $total += $hash{'paid'};
393 } elsif ( &{$declined_condition}(\%hash) ) {
395 $error = $new_cust_pay_batch->decline($hash{'error_message'});;
400 $dbh->rollback if $oldAutoCommit;
404 # purge CVV when the batch is processed
405 if ( $payby =~ /^(CARD|DCRD)$/ ) {
406 my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
407 if ( ! grep { $_ eq cardtype($payinfo) }
408 $conf->config('cvv-save') ) {
409 $new_cust_pay_batch->cust_main->remove_cvv;
414 } # foreach (@all_values)
417 if ( defined($close_condition) ) {
418 # Allow the module to decide whether to close the batch.
419 # $close_condition can also die() to abort the whole import.
420 $close = eval { $close_condition->($self) };
427 my $error = $self->set_status('R');
429 $dbh->rollback if $oldAutoCommit;
434 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
442 sub process_import_results {
444 my $param = thaw(decode_base64(shift));
445 $param->{'job'} = $job;
446 warn Dumper($param) if $DEBUG;
447 my $gatewaynum = delete $param->{'gatewaynum'};
449 $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum)
450 or die "gatewaynum '$gatewaynum' not found\n";
451 delete $param->{'format'}; # to avoid confusion
454 my $file = $param->{'uploaded_files'} or die "no files provided\n";
455 $file =~ s/^(\w+):([\.\w]+)$/$2/;
456 my $dir = '%%%FREESIDE_CACHE%%%/cache.' . $FS::UID::datasrc;
457 open( $param->{'filehandle'},
460 or die "unable to open '$file'.\n";
463 if ( $param->{gateway} ) {
464 $error = FS::pay_batch->import_from_gateway(%$param);
466 my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
467 my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
468 $error = $batch->import_results($param);
471 die $error if $error;
474 =item import_from_gateway [ OPTIONS ]
476 Import results from a L<FS::payment_gateway>, using Business::BatchPayment,
477 and apply them. GATEWAY must use the Business::BatchPayment namespace.
479 This is a class method, since results can be applied to any batch.
480 The 'batch-reconsider' option determines whether an already-approved
481 or declined payment can have its status changed by a later import.
485 - gateway: the L<FS::payment_gateway>, required
486 - filehandle: a file name or handle to use as a data source.
487 - job: an L<FS::queue> object to update with progress messages.
491 sub import_from_gateway {
494 my $gateway = $opt{'gateway'};
495 my $conf = FS::Conf->new;
497 # unavoidable duplication with import_batch, for now
498 local $SIG{HUP} = 'IGNORE';
499 local $SIG{INT} = 'IGNORE';
500 local $SIG{QUIT} = 'IGNORE';
501 local $SIG{TERM} = 'IGNORE';
502 local $SIG{TSTP} = 'IGNORE';
503 local $SIG{PIPE} = 'IGNORE';
505 my $oldAutoCommit = $FS::UID::AutoCommit;
506 local $FS::UID::AutoCommit = 0;
509 my $job = delete($opt{'job'});
510 $job->update_statustext(0) if $job;
513 return "import_from_gateway requires a payment_gateway"
514 unless eval { $gateway->isa('FS::payment_gateway') };
517 'input' => $opt{'filehandle'}, # will do nothing if it's empty
518 # any other constructor options go here
522 my $mail_on_error = $conf->config('batch-errors_to');
523 if ( $mail_on_error ) {
524 # construct error trap
525 $proc_opt{'on_parse_error'} = sub {
526 my ($self, $line, $error) = @_;
527 push @item_errors, " '$line'\n$error";
531 my $processor = $gateway->batch_processor(%proc_opt);
533 my @batches = $processor->receive;
537 my $total_items = sum( map{$_->count} @batches);
539 # whether to allow items to change status
540 my $reconsider = $conf->exists('batch-reconsider');
542 # mutex all affected batches
543 my %pay_batch_for_update;
545 my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
547 BATCH: foreach my $batch (@batches) {
549 my %incoming_batch = (
554 ITEM: foreach my $item ($batch->elements) {
556 my $cust_pay_batch; # the new batch entry (with status)
557 my $pay_batch; # the freeside batch it belongs to
558 my $payby; # CARD or CHEK
561 my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module .
562 ':' . $item->authorization . ':' . $item->order_number;
564 if ( $batch->incoming ) {
565 # This is a one-way batch.
566 # Locate the customer, find an open batch correct for them,
567 # create a payment. Don't bother creating a cust_pay_batch
570 if ( defined($item->customer_id)
571 and $item->customer_id =~ /^\d+$/
572 and $item->customer_id > 0 ) {
574 $cust_main = FS::cust_main->by_key($item->customer_id)
575 || qsearchs('cust_main',
576 { 'agent_custid' => $item->customer_id }
579 push @item_errors, "Unknown customer_id ".$item->customer_id;
584 push @item_errors, "Illegal customer_id '".$item->customer_id."'";
587 # it may also make sense to allow selecting the customer by
588 # invoice_number, but no modules currently work that way
590 $payby = $bop2payby{ $item->payment_type };
592 $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
594 # create a batch if necessary
595 $pay_batch = $incoming_batch{$payby}->{$agentnum} ||=
597 status => 'R', # pre-resolve it
599 agentnum => $agentnum,
601 title => $batch->batch_id,
603 if ( !$pay_batch->batchnum ) {
604 $error = $pay_batch->insert;
605 die $error if $error; # can't do anything if this fails
608 if ( !$item->approved ) {
609 $error ||= "payment rejected - ".$item->error_message;
611 if ( !defined($item->amount) or $item->amount <= 0 ) {
612 $error ||= "no amount in item $num";
616 if ( $item->check_number ) {
617 $payby = 'BILL'; # right?
618 $payinfo = $item->check_number;
619 } elsif ( $item->assigned_token ) {
620 $payinfo = $item->assigned_token;
623 my $cust_pay = FS::cust_pay->new(
625 custnum => $cust_main->custnum,
626 _date => $item->payment_date->epoch,
627 paid => sprintf('%.2f',$item->amount),
629 invnum => $item->invoice_number,
630 batchnum => $pay_batch->batchnum,
632 gatewaynum => $gateway->gatewaynum,
633 processor => $gateway->gateway_module,
634 auth => $item->authorization,
635 order_number => $item->order_number,
638 $error ||= $cust_pay->insert;
639 eval { $cust_main->apply_payments };
643 push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
647 # This is a request/reply batch.
648 # Locate the request (the 'tid' attribute is the paybatchnum).
649 my $paybatchnum = $item->tid;
650 $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
651 if (!$cust_pay_batch) {
652 push @item_errors, "paybatchnum $paybatchnum not found";
655 $payby = $cust_pay_batch->payby;
657 my $batchnum = $cust_pay_batch->batchnum;
658 if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
659 warn "batch ID ".$batch->batch_id.
660 " does not match batchnum ".$cust_pay_batch->batchnum."\n";
663 # lock the batch and check its status
664 $pay_batch = FS::pay_batch->by_key($batchnum);
665 $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
666 if ( $pay_batch->status ne 'I' and !$reconsider ) {
667 $error = "batch $batchnum no longer in transit";
670 if ( $cust_pay_batch->status ) {
671 my $new_status = $item->approved ? 'approved' : 'declined';
672 if ( lc( $cust_pay_batch->status ) eq $new_status ) {
673 # already imported with this status, so don't touch
676 elsif ( !$reconsider ) {
677 # then we're not allowed to change its status, so bail out
678 $error = "paybatchnum ".$item->tid.
679 " already resolved with status '". $cust_pay_batch->status . "'";
684 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
689 # update payinfo, if needed
690 if ( $item->assigned_token ) {
691 $new_payinfo = $item->assigned_token;
692 } elsif ( $payby eq 'CARD' ) {
693 $new_payinfo = $item->card_number if $item->card_number;
694 } else { #$payby eq 'CHEK'
695 $new_payinfo = $item->account_number . '@' . $item->routing_code
696 if $item->account_number;
698 $cust_pay_batch->set('payinfo', $new_payinfo) if $new_payinfo;
700 # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
701 # paid, if the batch says it's different from the amount requested
702 if ( defined $item->amount ) {
703 $cust_pay_batch->set('paid', $item->amount);
705 $cust_pay_batch->set('paid', $cust_pay_batch->amount);
708 # set payment date to when it was processed
709 $cust_pay_batch->_date($item->payment_date->epoch)
710 if $item->payment_date;
713 if ( $item->approved ) {
714 # follow Billing_Realtime format for paybatch
715 $error = $cust_pay_batch->approve(
716 'gatewaynum' => $gateway->gatewaynum,
717 'processor' => $gateway->gateway_module,
718 'auth' => $item->authorization,
719 'order_number' => $item->order_number,
721 $total += $cust_pay_batch->paid;
724 $error = $cust_pay_batch->decline($item->error_message,
725 $item->failure_status);
729 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
735 $job->update_statustext(int(100 * $num/( $total_items ) ),
736 'Importing batch items')
741 } #foreach $batch (input batch, not pay_batch)
743 # Format an error message
744 if ( @item_errors ) {
745 my $error_text = join("\n\n",
746 "Errors during batch import: ".scalar(@item_errors),
749 if ( $mail_on_error ) {
750 my $subject = "Batch import errors"; #?
751 my $body = "Import from gateway ".$gateway->label."\n".$error_text;
753 to => $mail_on_error,
754 from => $conf->config('invoice_from'),
760 $dbh->rollback if $oldAutoCommit;
765 # Auto-resolve (with brute-force error handling)
766 foreach my $pay_batch (values %pay_batch_for_update) {
767 my $error = $pay_batch->try_to_resolve;
770 $dbh->rollback if $oldAutoCommit;
775 $dbh->commit if $oldAutoCommit;
781 Resolve this batch if possible. A batch can be resolved if all of its
782 entries have status. If the system options 'batch-auto_resolve_days'
783 and 'batch-auto_resolve_status' are set, and the batch's download date is
784 at least (batch-auto_resolve_days) before the current time, then it can
785 be auto-resolved; entries with no status will be approved or declined
786 according to the batch-auto_resolve_status setting.
792 my $conf = FS::Conf->new;;
794 return if $self->status ne 'I';
796 my @unresolved = qsearch('cust_pay_batch',
798 batchnum => $self->batchnum,
803 if ( @unresolved and $conf->exists('batch-auto_resolve_days') ) {
804 my $days = $conf->config('batch-auto_resolve_days'); # can be zero
805 # either 'approve' or 'decline'
806 my $action = $conf->config('batch-auto_resolve_status') || '';
810 time > ($self->download + 86400 * $days)
814 foreach my $cpb (@unresolved) {
815 if ( $action eq 'approve' ) {
816 # approve it for the full amount
817 $cpb->set('paid', $cpb->amount) unless ($cpb->paid || 0) > 0;
818 $error = $cpb->approve($self->batchnum);
820 elsif ( $action eq 'decline' ) {
821 $error = $cpb->decline('No response from processor');
823 return $error if $error;
825 } elsif ( @unresolved ) {
826 # auto resolve is not enabled, and we're not ready to resolve
830 $self->set_status('R');
833 =item prepare_for_export
835 Prepare the batch to be exported. This will:
836 - Set the status to "in transit".
837 - If batch-increment_expiration is set and this is a credit card batch,
838 increment expiration dates that are in the past.
839 - If this is the first download for this batch, adjust payment amounts to
840 not be greater than the customer's current balance. If the customer's
841 balance is zero, the entry will be removed.
843 Use this within a transaction.
847 sub prepare_for_export {
849 my $conf = FS::Conf->new;
850 my $curuser = $FS::CurrentUser::CurrentUser;
853 my $status = $self->status;
854 if ($status eq 'O') {
856 my $error = $self->set_status('I');
857 return "error updating pay_batch status: $error\n" if $error;
858 } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
860 } elsif ($status eq 'R' &&
861 $curuser->access_right('Redownload resolved batches')) {
864 die "No pending batch.\n";
867 my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
868 $self->cust_pay_batch;
870 # handle batch-increment_expiration option
871 if ( $self->payby eq 'CARD' ) {
872 my ($cmon, $cyear) = (localtime(time))[4,5];
873 foreach (@cust_pay_batch) {
874 my $etime = str2time($_->exp) or next;
875 my ($day, $mon, $year) = (localtime($etime))[3,4,5];
876 if( $conf->exists('batch-increment_expiration') ) {
877 $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
878 $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
880 my $error = $_->replace;
881 return $error if $error;
885 if ($first_download) { #remove or reduce entries if customer's balance changed
887 foreach my $cust_pay_batch (@cust_pay_batch) {
889 my $balance = $cust_pay_batch->cust_main->balance;
890 if ($balance <= 0) { # then don't charge this customer
891 my $error = $cust_pay_batch->delete;
892 return $error if $error;
893 } elsif ($balance < $cust_pay_batch->amount) {
894 # reduce the charge to the remaining balance
895 $cust_pay_batch->amount($balance);
896 my $error = $cust_pay_batch->replace;
897 return $error if $error;
899 # else $balance >= $cust_pay_batch->amount
901 } #if $first_download
906 =item export_batch [ format => FORMAT | gateway => GATEWAY ]
908 Export batch for processing. FORMAT is the name of an L<FS::pay_batch>
909 module, in which case the configuration options are in 'batchconfig-FORMAT'.
911 Alternatively, GATEWAY can be an L<FS::payment_gateway> object set to a
912 L<Business::BatchPayment> module.
920 my $conf = new FS::Conf;
923 my $gateway = $opt{'gateway'};
925 # welcome to the future
926 my $fh = IO::Scalar->new(\$batch);
927 $self->export_to_gateway($gateway, 'file' => $fh);
931 my $format = $opt{'format'} || $conf->config('batch-default_format')
932 or die "No batch format configured\n";
934 my $info = $export_info{$format} or die "Format not found: '$format'\n";
936 &{$info->{'init'}}($conf, $self->agentnum) if exists($info->{'init'});
938 my $oldAutoCommit = $FS::UID::AutoCommit;
939 local $FS::UID::AutoCommit = 0;
942 my $error = $self->prepare_for_export;
944 die $error if $error;
948 my @cust_pay_batch = $self->cust_pay_batch;
950 my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
952 my $h = $info->{'header'};
953 if (ref($h) eq 'CODE') {
954 $batch .= &$h($self, \@cust_pay_batch). $delim;
956 $batch .= $h. $delim;
959 foreach my $cust_pay_batch (@cust_pay_batch) {
961 $batchtotal += $cust_pay_batch->amount;
963 &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
967 my $f = $info->{'footer'};
968 if (ref($f) eq 'CODE') {
969 $batch .= &$f($self, $batchcount, $batchtotal). $delim;
971 $batch .= $f. $delim;
974 if ($info->{'autopost'}) {
975 my $error = &{$info->{'autopost'}}($self, $batch);
977 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
982 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
986 =item export_to_gateway GATEWAY OPTIONS
988 Given L<FS::payment_gateway> GATEWAY, export the items in this batch to
989 that gateway via Business::BatchPayment. OPTIONS may include:
991 - file: override the default transport and write to this file (name or handle)
995 sub export_to_gateway {
997 my ($self, $gateway, %opt) = @_;
999 my $oldAutoCommit = $FS::UID::AutoCommit;
1000 local $FS::UID::AutoCommit = 0;
1003 my $error = $self->prepare_for_export;
1004 die $error if $error;
1007 'output' => $opt{'file'}, # will do nothing if it's empty
1008 # any other constructor options go here
1010 my $processor = $gateway->batch_processor(%proc_opt);
1012 my @items = map { $_->request_item } $self->cust_pay_batch;
1013 my $batch = Business::BatchPayment->create(Batch =>
1014 batch_id => $self->batchnum,
1017 $processor->submit($batch);
1019 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1023 sub manual_approve {
1027 my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
1028 my $conf = FS::Conf->new;
1029 return 'manual batch approval disabled'
1030 if ( ! $conf->exists('batch-manual_approval') );
1031 return 'batch already resolved' if $self->status eq 'R';
1032 return 'batch not yet submitted' if $self->status eq 'O';
1034 local $SIG{HUP} = 'IGNORE';
1035 local $SIG{INT} = 'IGNORE';
1036 local $SIG{QUIT} = 'IGNORE';
1037 local $SIG{TERM} = 'IGNORE';
1038 local $SIG{TSTP} = 'IGNORE';
1039 local $SIG{PIPE} = 'IGNORE';
1041 my $oldAutoCommit = $FS::UID::AutoCommit;
1042 local $FS::UID::AutoCommit = 0;
1046 foreach my $cust_pay_batch (
1047 qsearch('cust_pay_batch', { batchnum => $self->batchnum,
1050 my $new_cust_pay_batch = new FS::cust_pay_batch {
1051 $cust_pay_batch->hash,
1052 'paid' => $cust_pay_batch->amount,
1054 'usernum' => $usernum,
1056 my $error = $new_cust_pay_batch->approve();
1057 # there are no approval options here (authorization, order_number, etc.)
1058 # because the transaction wasn't really approved
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 $conf->exists("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.