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>.
223 Supported format keys (defined in the specified FS::pay_batch module) are:
225 I<filetype> - required, can be CSV, fixed, variable, XML
227 I<fields> - required list of field names for each row/line
229 I<formatre> - regular expression for fixed filetype
231 I<parse> - required for variable filetype
233 I<xmlkeys> - required for XML filetype
235 I<xmlrow> - required for XML filetype
237 I<begin_condition> - sub, ignore all lines before this returns true
239 I<end_condition> - sub, stop processing lines when this returns true
241 I<end_hook> - sub, runs immediately after end_condition returns true
243 I<skip_condition> - sub, skip lines when this returns true
245 I<hook> - required, sub, runs before approved/declined conditions are checked
247 I<approved> - required, sub, returns true when approved
249 I<declined> - required, sub, returns true when declined
251 I<close_condition> - sub, decide whether or not to close the batch
258 my $param = ref($_[0]) ? shift : { @_ };
259 my $fh = $param->{'filehandle'};
260 my $job = $param->{'job'};
261 $job->update_statustext(0) if $job;
263 my $format = $param->{'format'};
264 my $info = $import_info{$format}
265 or die "unknown format $format";
267 my $conf = new FS::Conf;
269 my $filetype = $info->{'filetype'}; # CSV, fixed, variable
270 my @fields = @{ $info->{'fields'}};
271 my $formatre = $info->{'formatre'}; # for fixed
272 my $parse = $info->{'parse'}; # for variable
274 my $begin_condition = $info->{'begin_condition'};
275 my $end_condition = $info->{'end_condition'};
276 my $end_hook = $info->{'end_hook'};
277 my $skip_condition = $info->{'skip_condition'};
278 my $hook = $info->{'hook'};
279 my $approved_condition = $info->{'approved'};
280 my $declined_condition = $info->{'declined'};
281 my $close_condition = $info->{'close_condition'};
283 my $csv = new Text::CSV_XS;
285 local $SIG{HUP} = 'IGNORE';
286 local $SIG{INT} = 'IGNORE';
287 local $SIG{QUIT} = 'IGNORE';
288 local $SIG{TERM} = 'IGNORE';
289 local $SIG{TSTP} = 'IGNORE';
290 local $SIG{PIPE} = 'IGNORE';
292 my $oldAutoCommit = $FS::UID::AutoCommit;
293 local $FS::UID::AutoCommit = 0;
296 my $reself = $self->select_for_update;
298 if ( $reself->status ne 'I'
299 and !$conf->exists('batch-manual_approval') ) {
300 $dbh->rollback if $oldAutoCommit;
301 return "batchnum ". $self->batchnum. "no longer in transit";
307 if ($filetype eq 'XML') {
308 eval "use XML::Simple";
310 my @xmlkeys = @{ $info->{'xmlkeys'} }; # for XML
311 my $xmlrow = $info->{'xmlrow'}; # also for XML
313 # Do everything differently.
314 my $data = XML::Simple::XMLin($fh, KeepRoot => 1);
316 # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
317 $rows = $rows->{$_} foreach( @$xmlrow );
318 if(!defined($rows)) {
319 $dbh->rollback if $oldAutoCommit;
320 return "can't find rows in XML file";
322 $rows = [ $rows ] if ref($rows) ne 'ARRAY';
323 foreach my $row (@$rows) {
324 push @all_values, [ @{$row}{@xmlkeys}, $row ];
328 while ( defined($line=<$fh>) ) {
330 next if $line =~ /^\s*$/; #skip blank lines
332 if ($filetype eq "CSV") {
333 $csv->parse($line) or do {
334 $dbh->rollback if $oldAutoCommit;
335 return "can't parse: ". $csv->error_input();
337 push @all_values, [ $csv->fields(), $line ];
338 }elsif ($filetype eq 'fixed'){
339 my @values = ( $line =~ /$formatre/ );
341 $dbh->rollback if $oldAutoCommit;
342 return "can't parse: ". $line;
345 push @all_values, \@values;
347 elsif ($filetype eq 'variable') {
348 my @values = ( eval { $parse->($self, $line) } );
350 $dbh->rollback if $oldAutoCommit;
354 push @all_values, \@values;
357 $dbh->rollback if $oldAutoCommit;
358 return "Unknown file type $filetype";
364 foreach (@all_values) {
367 $job->update_statustext(int(100 * $num/scalar(@all_values)));
372 my $line = pop @values;
373 foreach my $field ( @fields ) {
374 my $value = shift @values;
376 $hash{$field} = $value;
379 if ( defined($begin_condition) ) {
380 if ( &{$begin_condition}(\%hash, $line) ) {
381 undef $begin_condition;
388 if ( defined($end_condition) and &{$end_condition}(\%hash, $line) ) {
390 $error = &{$end_hook}(\%hash, $total, $line) if defined($end_hook);
392 $dbh->rollback if $oldAutoCommit;
398 if ( defined($skip_condition) and &{$skip_condition}(\%hash, $line) ) {
403 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
404 unless ( $cust_pay_batch ) {
405 return "unknown paybatchnum $hash{'paybatchnum'}\n";
407 my $custnum = $cust_pay_batch->custnum,
408 my $payby = $cust_pay_batch->payby,
410 &{$hook}(\%hash, $cust_pay_batch->hashref);
412 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
415 if ( &{$approved_condition}(\%hash) ) {
417 foreach ('paid', '_date', 'payinfo') {
418 $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
420 $error = $new_cust_pay_batch->approve(%hash);
421 $total += $hash{'paid'};
423 } elsif ( &{$declined_condition}(\%hash) ) {
425 $error = $new_cust_pay_batch->decline($hash{'error_message'});;
430 $dbh->rollback if $oldAutoCommit;
434 # purge CVV when the batch is processed
435 if ( $payby =~ /^(CARD|DCRD)$/ ) {
436 my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
437 if ( ! grep { $_ eq cardtype($payinfo) }
438 $conf->config('cvv-save') ) {
439 $new_cust_pay_batch->cust_main->remove_cvv;
444 } # foreach (@all_values)
447 if ( defined($close_condition) ) {
448 # Allow the module to decide whether to close the batch.
449 # $close_condition can also die() to abort the whole import.
450 $close = eval { $close_condition->($self) };
457 my $error = $self->set_status('R');
459 $dbh->rollback if $oldAutoCommit;
464 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
470 sub process_import_results {
473 $param->{'job'} = $job;
474 warn Dumper($param) if $DEBUG;
475 my $gatewaynum = delete $param->{'gatewaynum'};
477 $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum)
478 or die "gatewaynum '$gatewaynum' not found\n";
479 delete $param->{'format'}; # to avoid confusion
482 my $file = $param->{'uploaded_files'} or die "no files provided\n";
483 $file =~ s/^(\w+):([\.\w]+)$/$2/;
484 my $dir = '%%%FREESIDE_CACHE%%%/cache.' . $FS::UID::datasrc;
485 open( $param->{'filehandle'},
488 or die "unable to open '$file'.\n";
491 if ( $param->{gateway} ) {
492 $error = FS::pay_batch->import_from_gateway(%$param);
494 my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
495 my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
496 $error = $batch->import_results($param);
499 die $error if $error;
502 =item import_from_gateway [ OPTIONS ]
504 Import results from a L<FS::payment_gateway>, using Business::BatchPayment,
505 and apply them. GATEWAY must use the Business::BatchPayment namespace.
507 This is a class method, since results can be applied to any batch.
508 The 'batch-reconsider' option determines whether an already-approved
509 or declined payment can have its status changed by a later import.
513 - gateway: the L<FS::payment_gateway>, required
514 - filehandle: a file name or handle to use as a data source.
515 - job: an L<FS::queue> object to update with progress messages.
519 sub import_from_gateway {
522 my $gateway = $opt{'gateway'};
523 my $conf = FS::Conf->new;
525 # unavoidable duplication with import_batch, for now
526 local $SIG{HUP} = 'IGNORE';
527 local $SIG{INT} = 'IGNORE';
528 local $SIG{QUIT} = 'IGNORE';
529 local $SIG{TERM} = 'IGNORE';
530 local $SIG{TSTP} = 'IGNORE';
531 local $SIG{PIPE} = 'IGNORE';
533 my $oldAutoCommit = $FS::UID::AutoCommit;
534 local $FS::UID::AutoCommit = 0;
537 my $job = delete($opt{'job'});
538 $job->update_statustext(0) if $job;
541 return "import_from_gateway requires a payment_gateway"
542 unless eval { $gateway->isa('FS::payment_gateway') };
545 'input' => $opt{'filehandle'}, # will do nothing if it's empty
546 # any other constructor options go here
550 my $mail_on_error = $conf->config('batch-errors_to');
551 if ( $mail_on_error ) {
552 # construct error trap
553 $proc_opt{'on_parse_error'} = sub {
554 my ($self, $line, $error) = @_;
555 push @item_errors, " '$line'\n$error";
559 my $processor = $gateway->batch_processor(%proc_opt);
561 my @processor_ids = map { $_->processor_id }
563 'table' => 'pay_batch',
564 'hashref' => { 'status' => 'I' },
565 'extra_sql' => q( AND processor_id != '' AND processor_id IS NOT NULL)
568 my @batches = $processor->receive(@processor_ids);
572 my $total_items = sum( map{$_->count} @batches);
574 # whether to allow items to change status
575 my $reconsider = $conf->exists('batch-reconsider');
577 # mutex all affected batches
578 my %pay_batch_for_update;
580 my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
582 BATCH: foreach my $batch (@batches) {
584 my %incoming_batch = (
589 ITEM: foreach my $item ($batch->elements) {
591 my $cust_pay_batch; # the new batch entry (with status)
592 my $pay_batch; # the freeside batch it belongs to
593 my $payby; # CARD or CHEK
596 my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module .
597 ':' . $item->authorization . ':' . $item->order_number;
599 if ( $batch->incoming ) {
600 # This is a one-way batch.
601 # Locate the customer, find an open batch correct for them,
602 # create a payment. Don't bother creating a cust_pay_batch
605 if ( defined($item->customer_id)
606 and $item->customer_id =~ /^\d+$/
607 and $item->customer_id > 0 ) {
609 $cust_main = FS::cust_main->by_key($item->customer_id)
610 || qsearchs('cust_main',
611 { 'agent_custid' => $item->customer_id }
614 push @item_errors, "Unknown customer_id ".$item->customer_id;
619 push @item_errors, "Illegal customer_id '".$item->customer_id."'";
622 # it may also make sense to allow selecting the customer by
623 # invoice_number, but no modules currently work that way
625 $payby = $bop2payby{ $item->payment_type };
627 $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
629 # create a batch if necessary
630 $pay_batch = $incoming_batch{$payby}->{$agentnum} ||=
632 status => 'R', # pre-resolve it
634 agentnum => $agentnum,
636 title => $batch->batch_id,
638 if ( !$pay_batch->batchnum ) {
639 $error = $pay_batch->insert;
640 die $error if $error; # can't do anything if this fails
643 if ( !$item->approved ) {
644 $error ||= "payment rejected - ".$item->error_message;
646 if ( !defined($item->amount) or $item->amount <= 0 ) {
647 $error ||= "no amount in item $num";
651 if ( $item->check_number ) {
652 $payby = 'BILL'; # right?
653 $payinfo = $item->check_number;
654 } elsif ( $item->assigned_token ) {
655 $payinfo = $item->assigned_token;
658 my $cust_pay = FS::cust_pay->new(
660 custnum => $cust_main->custnum,
661 _date => $item->payment_date->epoch,
662 paid => sprintf('%.2f',$item->amount),
664 invnum => $item->invoice_number,
665 batchnum => $pay_batch->batchnum,
667 gatewaynum => $gateway->gatewaynum,
668 processor => $gateway->gateway_module,
669 auth => $item->authorization,
670 order_number => $item->order_number,
673 $error ||= $cust_pay->insert;
674 eval { $cust_main->apply_payments };
678 push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
682 # This is a request/reply batch.
683 # Locate the request (the 'tid' attribute is the paybatchnum).
684 my $paybatchnum = $item->tid;
685 $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
686 if (!$cust_pay_batch) {
687 push @item_errors, "paybatchnum $paybatchnum not found";
690 $payby = $cust_pay_batch->payby;
692 my $batchnum = $cust_pay_batch->batchnum;
693 if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
694 warn "batch ID ".$batch->batch_id.
695 " does not match batchnum ".$cust_pay_batch->batchnum."\n";
698 # lock the batch and check its status
699 $pay_batch = FS::pay_batch->by_key($batchnum);
700 $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
701 if ( $pay_batch->status ne 'I' and !$reconsider ) {
702 $error = "batch $batchnum no longer in transit";
705 if ( $cust_pay_batch->status ) {
706 my $new_status = $item->approved ? 'approved' : 'declined';
707 if ( lc( $cust_pay_batch->status ) eq $new_status ) {
708 # already imported with this status, so don't touch
711 elsif ( !$reconsider ) {
712 # then we're not allowed to change its status, so bail out
713 $error = "paybatchnum ".$item->tid.
714 " already resolved with status '". $cust_pay_batch->status . "'";
719 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
724 # update payinfo, if needed
725 if ( $item->assigned_token ) {
726 $new_payinfo = $item->assigned_token;
727 } elsif ( $payby eq 'CARD' ) {
728 $new_payinfo = $item->card_number if $item->card_number;
729 } else { #$payby eq 'CHEK'
730 $new_payinfo = $item->account_number . '@' . $item->routing_code
731 if $item->account_number;
733 $cust_pay_batch->set('payinfo', $new_payinfo) if $new_payinfo;
735 # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
736 # paid, if the batch says it's different from the amount requested
737 if ( defined $item->amount ) {
738 $cust_pay_batch->set('paid', $item->amount);
740 $cust_pay_batch->set('paid', $cust_pay_batch->amount);
743 # set payment date to when it was processed
744 $cust_pay_batch->_date($item->payment_date->epoch)
745 if $item->payment_date;
748 if ( $item->approved ) {
749 # follow Billing_Realtime format for paybatch
750 $error = $cust_pay_batch->approve(
751 'gatewaynum' => $gateway->gatewaynum,
752 'processor' => $gateway->gateway_module,
753 'auth' => $item->authorization,
754 'order_number' => $item->order_number,
756 $total += $cust_pay_batch->paid;
759 $error = $cust_pay_batch->decline($item->error_message,
760 $item->failure_status);
764 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
770 $job->update_statustext(int(100 * $num/( $total_items ) ),
771 'Importing batch items')
776 } #foreach $batch (input batch, not pay_batch)
778 # Format an error message
779 if ( @item_errors ) {
780 my $error_text = join("\n\n",
781 "Errors during batch import: ".scalar(@item_errors),
784 if ( $mail_on_error ) {
785 my $subject = "Batch import errors"; #?
786 my $body = "Import from gateway ".$gateway->label."\n".$error_text;
788 to => $mail_on_error,
789 from => $conf->invoice_from_full(),
795 $dbh->rollback if $oldAutoCommit;
800 # Auto-resolve (with brute-force error handling)
801 foreach my $pay_batch (values %pay_batch_for_update) {
802 my $error = $pay_batch->try_to_resolve;
805 $dbh->rollback if $oldAutoCommit;
810 $dbh->commit if $oldAutoCommit;
816 Resolve this batch if possible. A batch can be resolved if all of its
817 entries have status. If the system options 'batch-auto_resolve_days'
818 and 'batch-auto_resolve_status' are set, and the batch's download date is
819 at least (batch-auto_resolve_days) before the current time, then it can
820 be auto-resolved; entries with no status will be approved or declined
821 according to the batch-auto_resolve_status setting.
827 my $conf = FS::Conf->new;;
829 return if $self->status ne 'I';
831 my @unresolved = qsearch('cust_pay_batch',
833 batchnum => $self->batchnum,
838 if ( @unresolved and $conf->exists('batch-auto_resolve_days') ) {
839 my $days = $conf->config('batch-auto_resolve_days'); # can be zero
840 # either 'approve' or 'decline'
841 my $action = $conf->config('batch-auto_resolve_status') || '';
845 time > ($self->download + 86400 * $days)
849 foreach my $cpb (@unresolved) {
850 if ( $action eq 'approve' ) {
851 # approve it for the full amount
852 $cpb->set('paid', $cpb->amount) unless ($cpb->paid || 0) > 0;
853 $error = $cpb->approve($self->batchnum);
855 elsif ( $action eq 'decline' ) {
856 $error = $cpb->decline('No response from processor');
858 return $error if $error;
860 } elsif ( @unresolved ) {
861 # auto resolve is not enabled, and we're not ready to resolve
865 $self->set_status('R');
868 =item prepare_for_export
870 Prepare the batch to be exported. This will:
871 - Set the status to "in transit".
872 - If batch-increment_expiration is set and this is a credit card batch,
873 increment expiration dates that are in the past.
874 - If this is the first download for this batch, adjust payment amounts to
875 not be greater than the customer's current balance. If the customer's
876 balance is zero, the entry will be removed.
878 Use this within a transaction.
882 sub prepare_for_export {
884 my $conf = FS::Conf->new;
885 my $curuser = $FS::CurrentUser::CurrentUser;
888 my $status = $self->status;
889 if ($status eq 'O') {
891 my $error = $self->set_status('I');
892 return "error updating pay_batch status: $error\n" if $error;
893 } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
895 } elsif ($status eq 'R' &&
896 $curuser->access_right('Redownload resolved batches')) {
899 die "No pending batch.\n";
902 my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
903 $self->cust_pay_batch;
905 # handle batch-increment_expiration option
906 if ( $self->payby eq 'CARD' ) {
907 my ($cmon, $cyear) = (localtime(time))[4,5];
908 foreach (@cust_pay_batch) {
909 my $etime = str2time($_->exp) or next;
910 my ($day, $mon, $year) = (localtime($etime))[3,4,5];
911 if( $conf->exists('batch-increment_expiration') ) {
912 $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
913 $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
915 my $error = $_->replace;
916 return $error if $error;
920 if ($first_download) { #remove or reduce entries if customer's balance changed
922 foreach my $cust_pay_batch (@cust_pay_batch) {
924 my $balance = $cust_pay_batch->cust_main->balance;
925 if ($balance <= 0) { # then don't charge this customer
926 my $error = $cust_pay_batch->delete;
927 return $error if $error;
928 } elsif ($balance < $cust_pay_batch->amount) {
929 # reduce the charge to the remaining balance
930 $cust_pay_batch->amount($balance);
931 my $error = $cust_pay_batch->replace;
932 return $error if $error;
934 # else $balance >= $cust_pay_batch->amount
936 } #if $first_download
941 =item export_batch [ format => FORMAT | gateway => GATEWAY ]
943 Export batch for processing. FORMAT is the name of an L<FS::pay_batch>
944 module, in which case the configuration options are in 'batchconfig-FORMAT'.
946 Alternatively, GATEWAY can be an L<FS::payment_gateway> object set to a
947 L<Business::BatchPayment> module.
955 my $conf = new FS::Conf;
958 my $gateway = $opt{'gateway'};
960 # welcome to the future
961 my $fh = IO::Scalar->new(\$batch);
962 $self->export_to_gateway($gateway, 'file' => $fh);
966 my $format = $opt{'format'} || $conf->config('batch-default_format')
967 or die "No batch format configured\n";
969 my $info = $export_info{$format} or die "Format not found: '$format'\n";
971 &{$info->{'init'}}($conf, $self->agentnum) if exists($info->{'init'});
973 my $oldAutoCommit = $FS::UID::AutoCommit;
974 local $FS::UID::AutoCommit = 0;
977 my $error = $self->prepare_for_export;
979 die $error if $error;
983 my @cust_pay_batch = $self->cust_pay_batch;
985 my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
987 my $h = $info->{'header'};
988 if (ref($h) eq 'CODE') {
989 $batch .= &$h($self, \@cust_pay_batch). $delim;
991 $batch .= $h. $delim;
994 foreach my $cust_pay_batch (@cust_pay_batch) {
996 $batchtotal += $cust_pay_batch->amount;
998 &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
1002 my $f = $info->{'footer'};
1003 if (ref($f) eq 'CODE') {
1004 $batch .= &$f($self, $batchcount, $batchtotal). $delim;
1006 $batch .= $f. $delim;
1009 if ($info->{'autopost'}) {
1010 my $error = &{$info->{'autopost'}}($self, $batch);
1012 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1017 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1021 =item export_to_gateway GATEWAY OPTIONS
1023 Given L<FS::payment_gateway> GATEWAY, export the items in this batch to
1024 that gateway via Business::BatchPayment. OPTIONS may include:
1026 - file: override the default transport and write to this file (name or handle)
1030 sub export_to_gateway {
1032 my ($self, $gateway, %opt) = @_;
1034 my $oldAutoCommit = $FS::UID::AutoCommit;
1035 local $FS::UID::AutoCommit = 0;
1038 my $error = $self->prepare_for_export;
1039 die $error if $error;
1042 'output' => $opt{'file'}, # will do nothing if it's empty
1043 # any other constructor options go here
1045 my $processor = $gateway->batch_processor(%proc_opt);
1047 my @items = map { $_->request_item } $self->cust_pay_batch;
1048 my $batch = Business::BatchPayment->create(Batch =>
1049 batch_id => $self->batchnum,
1052 $processor->submit($batch);
1054 if ($batch->processor_id) {
1055 $self->set('processor_id',$batch->processor_id);
1059 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1063 sub manual_approve {
1067 my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
1068 my $conf = FS::Conf->new;
1069 return 'manual batch approval disabled'
1070 if ( ! $conf->exists('batch-manual_approval') );
1071 return 'batch already resolved' if $self->status eq 'R';
1072 return 'batch not yet submitted' if $self->status eq 'O';
1074 local $SIG{HUP} = 'IGNORE';
1075 local $SIG{INT} = 'IGNORE';
1076 local $SIG{QUIT} = 'IGNORE';
1077 local $SIG{TERM} = 'IGNORE';
1078 local $SIG{TSTP} = 'IGNORE';
1079 local $SIG{PIPE} = 'IGNORE';
1081 my $oldAutoCommit = $FS::UID::AutoCommit;
1082 local $FS::UID::AutoCommit = 0;
1086 foreach my $cust_pay_batch (
1087 qsearch('cust_pay_batch', { batchnum => $self->batchnum,
1090 my $new_cust_pay_batch = new FS::cust_pay_batch {
1091 $cust_pay_batch->hash,
1092 'paid' => $cust_pay_batch->amount,
1094 'usernum' => $usernum,
1096 my $error = $new_cust_pay_batch->approve();
1097 # there are no approval options here (authorization, order_number, etc.)
1098 # because the transaction wasn't really approved
1101 return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
1105 $self->set_status('R');
1111 # Set up configuration for gateways that have a Business::BatchPayment
1114 eval "use Class::MOP;";
1116 warn "Moose/Class::MOP not available.\n$@\nSkipping pay_batch upgrade.\n";
1119 my $conf = FS::Conf->new;
1120 for my $format (keys %export_info) {
1121 my $mod = "FS::pay_batch::$format";
1122 if ( $mod->can('_upgrade_gateway')
1123 and $conf->exists("batchconfig-$format") ) {
1126 my ($module, %gw_options) = $mod->_upgrade_gateway;
1127 my $gateway = FS::payment_gateway->new({
1128 gateway_namespace => 'Business::BatchPayment',
1129 gateway_module => $module,
1131 my $error = $gateway->insert(%gw_options);
1133 warn "Failed to migrate '$format' to a Business::BatchPayment::$module gateway:\n$error\n";
1137 # test whether it loads
1138 my $processor = eval { $gateway->batch_processor };
1139 if ( !$processor ) {
1140 warn "Couldn't load Business::BatchPayment module for '$format'.\n";
1141 # if not, remove it so it doesn't hang around and break things
1145 # remove the batchconfig-*
1146 warn "Created Business::BatchPayment gateway '".$gateway->label.
1147 "' for '$format' batch processing.\n";
1148 $conf->delete("batchconfig-$format");
1150 # and if appropriate, make it the system default
1151 for my $payby (qw(CARD CHEK)) {
1152 if ( ($conf->config("batch-fixed_format-$payby") || '') eq $format ) {
1153 warn "Setting as default for $payby.\n";
1154 $conf->set("batch-gateway-$payby", $gateway->gatewaynum);
1155 $conf->delete("batch-fixed_format-$payby");
1159 } #if can('_upgrade_gateway') and batchconfig-$format
1169 status is somewhat redundant now that download and upload exist
1173 L<FS::Record>, schema.html from the base documentation.