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>.
237 Supported format keys (defined in the specified FS::pay_batch module) are:
239 I<filetype> - required, can be CSV, fixed, variable, XML
241 I<fields> - required list of field names for each row/line
243 I<formatre> - regular expression for fixed filetype
245 I<parse> - required for variable filetype
247 I<xmlkeys> - required for XML filetype
249 I<xmlrow> - required for XML filetype
251 I<begin_condition> - sub, ignore all lines before this returns true
253 I<end_condition> - sub, stop processing lines when this returns true
255 I<end_hook> - sub, runs immediately after end_condition returns true
257 I<skip_condition> - sub, skip lines when this returns true
259 I<hook> - required, sub, runs before approved/declined conditions are checked
261 I<approved> - required, sub, returns true when approved
263 I<declined> - required, sub, returns true when declined
265 I<close_condition> - sub, decide whether or not to close the batch
272 my $param = ref($_[0]) ? shift : { @_ };
273 my $fh = $param->{'filehandle'};
274 my $job = $param->{'job'};
275 $job->update_statustext(0) if $job;
277 my $format = $param->{'format'};
278 my $info = $import_info{$format}
279 or die "unknown format $format";
281 my $conf = new FS::Conf;
283 my $filetype = $info->{'filetype'}; # CSV, fixed, variable
284 my @fields = @{ $info->{'fields'}};
285 my $formatre = $info->{'formatre'}; # for fixed
286 my $parse = $info->{'parse'}; # for variable
288 my $begin_condition = $info->{'begin_condition'};
289 my $end_condition = $info->{'end_condition'};
290 my $end_hook = $info->{'end_hook'};
291 my $skip_condition = $info->{'skip_condition'};
292 my $hook = $info->{'hook'};
293 my $approved_condition = $info->{'approved'};
294 my $declined_condition = $info->{'declined'};
295 my $close_condition = $info->{'close_condition'};
297 my $csv = new Text::CSV_XS;
299 local $SIG{HUP} = 'IGNORE';
300 local $SIG{INT} = 'IGNORE';
301 local $SIG{QUIT} = 'IGNORE';
302 local $SIG{TERM} = 'IGNORE';
303 local $SIG{TSTP} = 'IGNORE';
304 local $SIG{PIPE} = 'IGNORE';
306 my $oldAutoCommit = $FS::UID::AutoCommit;
307 local $FS::UID::AutoCommit = 0;
310 my $reself = $self->select_for_update;
312 if ( $reself->status ne 'I'
313 and !$conf->exists('batch-manual_approval') ) {
314 $dbh->rollback if $oldAutoCommit;
315 return "batchnum ". $self->batchnum. "no longer in transit";
321 if ($filetype eq 'XML') {
322 eval "use XML::Simple";
324 my @xmlkeys = @{ $info->{'xmlkeys'} }; # for XML
325 my $xmlrow = $info->{'xmlrow'}; # also for XML
327 # Do everything differently.
328 my $data = XML::Simple::XMLin($fh, KeepRoot => 1);
330 # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
331 $rows = $rows->{$_} foreach( @$xmlrow );
332 if(!defined($rows)) {
333 $dbh->rollback if $oldAutoCommit;
334 return "can't find rows in XML file";
336 $rows = [ $rows ] if ref($rows) ne 'ARRAY';
337 foreach my $row (@$rows) {
338 push @all_values, [ @{$row}{@xmlkeys}, $row ];
342 while ( defined($line=<$fh>) ) {
344 next if $line =~ /^\s*$/; #skip blank lines
346 if ($filetype eq "CSV") {
347 $csv->parse($line) or do {
348 $dbh->rollback if $oldAutoCommit;
349 return "can't parse: ". $csv->error_input();
351 push @all_values, [ $csv->fields(), $line ];
352 }elsif ($filetype eq 'fixed'){
353 my @values = ( $line =~ /$formatre/ );
355 $dbh->rollback if $oldAutoCommit;
356 return "can't parse: ". $line;
359 push @all_values, \@values;
361 elsif ($filetype eq 'variable') {
362 my @values = ( eval { $parse->($self, $line) } );
364 $dbh->rollback if $oldAutoCommit;
368 push @all_values, \@values;
371 $dbh->rollback if $oldAutoCommit;
372 return "Unknown file type $filetype";
378 foreach (@all_values) {
381 $job->update_statustext(int(100 * $num/scalar(@all_values)));
386 my $line = pop @values;
387 foreach my $field ( @fields ) {
388 my $value = shift @values;
390 $hash{$field} = $value;
393 if ( defined($begin_condition) ) {
394 if ( &{$begin_condition}(\%hash, $line) ) {
395 undef $begin_condition;
402 if ( defined($end_condition) and &{$end_condition}(\%hash, $line) ) {
404 $error = &{$end_hook}(\%hash, $total, $line) if defined($end_hook);
406 $dbh->rollback if $oldAutoCommit;
412 if ( defined($skip_condition) and &{$skip_condition}(\%hash, $line) ) {
417 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
418 unless ( $cust_pay_batch ) {
419 return "unknown paybatchnum $hash{'paybatchnum'}\n";
421 my $custnum = $cust_pay_batch->custnum,
422 my $payby = $cust_pay_batch->payby,
424 &{$hook}(\%hash, $cust_pay_batch->hashref);
426 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
429 if ( &{$approved_condition}(\%hash) ) {
431 foreach ('paid', '_date', 'payinfo') {
432 $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
434 $error = $new_cust_pay_batch->approve(%hash);
435 $total += $hash{'paid'};
437 } elsif ( &{$declined_condition}(\%hash) ) {
439 $error = $new_cust_pay_batch->decline($hash{'error_message'});;
444 $dbh->rollback if $oldAutoCommit;
448 # purge CVV when the batch is processed
449 if ( $payby =~ /^(CARD|DCRD)$/ ) {
450 my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
451 if ( ! grep { $_ eq cardtype($payinfo) }
452 $conf->config('cvv-save') ) {
453 $new_cust_pay_batch->cust_main->remove_cvv;
458 } # foreach (@all_values)
461 if ( defined($close_condition) ) {
462 # Allow the module to decide whether to close the batch.
463 # $close_condition can also die() to abort the whole import.
464 $close = eval { $close_condition->($self) };
471 my $error = $self->set_status('R');
473 $dbh->rollback if $oldAutoCommit;
478 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
486 sub process_import_results {
488 my $param = thaw(decode_base64(shift));
489 $param->{'job'} = $job;
490 warn Dumper($param) if $DEBUG;
491 my $gatewaynum = delete $param->{'gatewaynum'};
493 $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum)
494 or die "gatewaynum '$gatewaynum' not found\n";
495 delete $param->{'format'}; # to avoid confusion
498 my $file = $param->{'uploaded_files'} or die "no files provided\n";
499 $file =~ s/^(\w+):([\.\w]+)$/$2/;
500 my $dir = '%%%FREESIDE_CACHE%%%/cache.' . $FS::UID::datasrc;
501 open( $param->{'filehandle'},
504 or die "unable to open '$file'.\n";
507 if ( $param->{gateway} ) {
508 $error = FS::pay_batch->import_from_gateway(%$param);
510 my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
511 my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
512 $error = $batch->import_results($param);
515 die $error if $error;
518 =item import_from_gateway [ OPTIONS ]
520 Import results from a L<FS::payment_gateway>, using Business::BatchPayment,
521 and apply them. GATEWAY must use the Business::BatchPayment namespace.
523 This is a class method, since results can be applied to any batch.
524 The 'batch-reconsider' option determines whether an already-approved
525 or declined payment can have its status changed by a later import.
529 - gateway: the L<FS::payment_gateway>, required
530 - filehandle: a file name or handle to use as a data source.
531 - job: an L<FS::queue> object to update with progress messages.
535 sub import_from_gateway {
538 my $gateway = $opt{'gateway'};
539 my $conf = FS::Conf->new;
541 # unavoidable duplication with import_batch, for now
542 local $SIG{HUP} = 'IGNORE';
543 local $SIG{INT} = 'IGNORE';
544 local $SIG{QUIT} = 'IGNORE';
545 local $SIG{TERM} = 'IGNORE';
546 local $SIG{TSTP} = 'IGNORE';
547 local $SIG{PIPE} = 'IGNORE';
549 my $oldAutoCommit = $FS::UID::AutoCommit;
550 local $FS::UID::AutoCommit = 0;
553 my $job = delete($opt{'job'});
554 $job->update_statustext(0) if $job;
557 return "import_from_gateway requires a payment_gateway"
558 unless eval { $gateway->isa('FS::payment_gateway') };
561 'input' => $opt{'filehandle'}, # will do nothing if it's empty
562 # any other constructor options go here
566 my $mail_on_error = $conf->config('batch-errors_to');
567 if ( $mail_on_error ) {
568 # construct error trap
569 $proc_opt{'on_parse_error'} = sub {
570 my ($self, $line, $error) = @_;
571 push @item_errors, " '$line'\n$error";
575 my $processor = $gateway->batch_processor(%proc_opt);
577 my @batches = $processor->receive;
581 my $total_items = sum( map{$_->count} @batches);
583 # whether to allow items to change status
584 my $reconsider = $conf->exists('batch-reconsider');
586 # mutex all affected batches
587 my %pay_batch_for_update;
589 my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
591 BATCH: foreach my $batch (@batches) {
593 my %incoming_batch = (
598 ITEM: foreach my $item ($batch->elements) {
600 my $cust_pay_batch; # the new batch entry (with status)
601 my $pay_batch; # the freeside batch it belongs to
602 my $payby; # CARD or CHEK
605 my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module .
606 ':' . $item->authorization . ':' . $item->order_number;
608 if ( $batch->incoming ) {
609 # This is a one-way batch.
610 # Locate the customer, find an open batch correct for them,
611 # create a payment. Don't bother creating a cust_pay_batch
614 if ( defined($item->customer_id)
615 and $item->customer_id =~ /^\d+$/
616 and $item->customer_id > 0 ) {
618 $cust_main = FS::cust_main->by_key($item->customer_id)
619 || qsearchs('cust_main',
620 { 'agent_custid' => $item->customer_id }
623 push @item_errors, "Unknown customer_id ".$item->customer_id;
628 push @item_errors, "Illegal customer_id '".$item->customer_id."'";
631 # it may also make sense to allow selecting the customer by
632 # invoice_number, but no modules currently work that way
634 $payby = $bop2payby{ $item->payment_type };
636 $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
638 # create a batch if necessary
639 $pay_batch = $incoming_batch{$payby}->{$agentnum} ||=
641 status => 'R', # pre-resolve it
643 agentnum => $agentnum,
645 title => $batch->batch_id,
647 if ( !$pay_batch->batchnum ) {
648 $error = $pay_batch->insert;
649 die $error if $error; # can't do anything if this fails
652 if ( !$item->approved ) {
653 $error ||= "payment rejected - ".$item->error_message;
655 if ( !defined($item->amount) or $item->amount <= 0 ) {
656 $error ||= "no amount in item $num";
660 if ( $item->check_number ) {
661 $payby = 'BILL'; # right?
662 $payinfo = $item->check_number;
663 } elsif ( $item->assigned_token ) {
664 $payinfo = $item->assigned_token;
667 my $cust_pay = FS::cust_pay->new(
669 custnum => $cust_main->custnum,
670 _date => $item->payment_date->epoch,
671 paid => sprintf('%.2f',$item->amount),
673 invnum => $item->invoice_number,
674 batchnum => $pay_batch->batchnum,
676 gatewaynum => $gateway->gatewaynum,
677 processor => $gateway->gateway_module,
678 auth => $item->authorization,
679 order_number => $item->order_number,
682 $error ||= $cust_pay->insert;
683 eval { $cust_main->apply_payments };
687 push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
691 # This is a request/reply batch.
692 # Locate the request (the 'tid' attribute is the paybatchnum).
693 my $paybatchnum = $item->tid;
694 $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
695 if (!$cust_pay_batch) {
696 push @item_errors, "paybatchnum $paybatchnum not found";
699 $payby = $cust_pay_batch->payby;
701 my $batchnum = $cust_pay_batch->batchnum;
702 if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
703 warn "batch ID ".$batch->batch_id.
704 " does not match batchnum ".$cust_pay_batch->batchnum."\n";
707 # lock the batch and check its status
708 $pay_batch = FS::pay_batch->by_key($batchnum);
709 $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
710 if ( $pay_batch->status ne 'I' and !$reconsider ) {
711 $error = "batch $batchnum no longer in transit";
714 if ( $cust_pay_batch->status ) {
715 my $new_status = $item->approved ? 'approved' : 'declined';
716 if ( lc( $cust_pay_batch->status ) eq $new_status ) {
717 # already imported with this status, so don't touch
720 elsif ( !$reconsider ) {
721 # then we're not allowed to change its status, so bail out
722 $error = "paybatchnum ".$item->tid.
723 " already resolved with status '". $cust_pay_batch->status . "'";
728 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
733 # update payinfo, if needed
734 if ( $item->assigned_token ) {
735 $new_payinfo = $item->assigned_token;
736 } elsif ( $payby eq 'CARD' ) {
737 $new_payinfo = $item->card_number if $item->card_number;
738 } else { #$payby eq 'CHEK'
739 $new_payinfo = $item->account_number . '@' . $item->routing_code
740 if $item->account_number;
742 $cust_pay_batch->set('payinfo', $new_payinfo) if $new_payinfo;
744 # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
745 # paid, if the batch says it's different from the amount requested
746 if ( defined $item->amount ) {
747 $cust_pay_batch->set('paid', $item->amount);
749 $cust_pay_batch->set('paid', $cust_pay_batch->amount);
752 # set payment date to when it was processed
753 $cust_pay_batch->_date($item->payment_date->epoch)
754 if $item->payment_date;
757 if ( $item->approved ) {
758 # follow Billing_Realtime format for paybatch
759 $error = $cust_pay_batch->approve(
760 'gatewaynum' => $gateway->gatewaynum,
761 'processor' => $gateway->gateway_module,
762 'auth' => $item->authorization,
763 'order_number' => $item->order_number,
765 $total += $cust_pay_batch->paid;
768 $error = $cust_pay_batch->decline($item->error_message);
772 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
778 $job->update_statustext(int(100 * $num/( $total_items ) ),
779 'Importing batch items')
784 } #foreach $batch (input batch, not pay_batch)
786 # Format an error message
787 if ( @item_errors ) {
788 my $error_text = join("\n\n",
789 "Errors during batch import: ".scalar(@item_errors),
792 if ( $mail_on_error ) {
793 my $subject = "Batch import errors"; #?
794 my $body = "Import from gateway ".$gateway->label."\n".$error_text;
796 to => $mail_on_error,
797 from => $conf->invoice_from_full(),
803 $dbh->rollback if $oldAutoCommit;
808 # Auto-resolve (with brute-force error handling)
809 foreach my $pay_batch (values %pay_batch_for_update) {
810 my $error = $pay_batch->try_to_resolve;
813 $dbh->rollback if $oldAutoCommit;
818 $dbh->commit if $oldAutoCommit;
824 Resolve this batch if possible. A batch can be resolved if all of its
825 entries have status. If the system options 'batch-auto_resolve_days'
826 and 'batch-auto_resolve_status' are set, and the batch's download date is
827 at least (batch-auto_resolve_days) before the current time, then it can
828 be auto-resolved; entries with no status will be approved or declined
829 according to the batch-auto_resolve_status setting.
835 my $conf = FS::Conf->new;;
837 return if $self->status ne 'I';
839 my @unresolved = qsearch('cust_pay_batch',
841 batchnum => $self->batchnum,
846 if ( @unresolved and $conf->exists('batch-auto_resolve_days') ) {
847 my $days = $conf->config('batch-auto_resolve_days'); # can be zero
848 # either 'approve' or 'decline'
849 my $action = $conf->config('batch-auto_resolve_status') || '';
853 time > ($self->download + 86400 * $days)
857 foreach my $cpb (@unresolved) {
858 if ( $action eq 'approve' ) {
859 # approve it for the full amount
860 $cpb->set('paid', $cpb->amount) unless ($cpb->paid || 0) > 0;
861 $error = $cpb->approve($self->batchnum);
863 elsif ( $action eq 'decline' ) {
864 $error = $cpb->decline('No response from processor');
866 return $error if $error;
868 } elsif ( @unresolved ) {
869 # auto resolve is not enabled, and we're not ready to resolve
873 $self->set_status('R');
876 =item prepare_for_export
878 Prepare the batch to be exported. This will:
879 - Set the status to "in transit".
880 - If batch-increment_expiration is set and this is a credit card batch,
881 increment expiration dates that are in the past.
882 - If this is the first download for this batch, adjust payment amounts to
883 not be greater than the customer's current balance. If the customer's
884 balance is zero, the entry will be removed.
886 Use this within a transaction.
890 sub prepare_for_export {
892 my $conf = FS::Conf->new;
893 my $curuser = $FS::CurrentUser::CurrentUser;
896 my $status = $self->status;
897 if ($status eq 'O') {
899 my $error = $self->set_status('I');
900 return "error updating pay_batch status: $error\n" if $error;
901 } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
903 } elsif ($status eq 'R' &&
904 $curuser->access_right('Redownload resolved batches')) {
907 die "No pending batch.\n";
910 my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
911 $self->cust_pay_batch;
913 # handle batch-increment_expiration option
914 if ( $self->payby eq 'CARD' ) {
915 my ($cmon, $cyear) = (localtime(time))[4,5];
916 foreach (@cust_pay_batch) {
917 my $etime = str2time($_->exp) or next;
918 my ($day, $mon, $year) = (localtime($etime))[3,4,5];
919 if( $conf->exists('batch-increment_expiration') ) {
920 $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
921 $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
923 my $error = $_->replace;
924 return $error if $error;
928 if ($first_download) { #remove or reduce entries if customer's balance changed
930 foreach my $cust_pay_batch (@cust_pay_batch) {
932 my $balance = $cust_pay_batch->cust_main->balance;
933 if ($balance <= 0) { # then don't charge this customer
934 my $error = $cust_pay_batch->delete;
935 return $error if $error;
936 } elsif ($balance < $cust_pay_batch->amount) {
937 # reduce the charge to the remaining balance
938 $cust_pay_batch->amount($balance);
939 my $error = $cust_pay_batch->replace;
940 return $error if $error;
942 # else $balance >= $cust_pay_batch->amount
944 } #if $first_download
949 =item export_batch [ format => FORMAT | gateway => GATEWAY ]
951 Export batch for processing. FORMAT is the name of an L<FS::pay_batch>
952 module, in which case the configuration options are in 'batchconfig-FORMAT'.
954 Alternatively, GATEWAY can be an L<FS::payment_gateway> object set to a
955 L<Business::BatchPayment> module.
963 my $conf = new FS::Conf;
966 my $gateway = $opt{'gateway'};
968 # welcome to the future
969 my $fh = IO::Scalar->new(\$batch);
970 $self->export_to_gateway($gateway, 'file' => $fh);
974 my $format = $opt{'format'} || $conf->config('batch-default_format')
975 or die "No batch format configured\n";
977 my $info = $export_info{$format} or die "Format not found: '$format'\n";
979 &{$info->{'init'}}($conf, $self->agentnum) if exists($info->{'init'});
981 my $oldAutoCommit = $FS::UID::AutoCommit;
982 local $FS::UID::AutoCommit = 0;
985 my $error = $self->prepare_for_export;
987 die $error if $error;
991 my @cust_pay_batch = $self->cust_pay_batch;
993 my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
995 my $h = $info->{'header'};
996 if (ref($h) eq 'CODE') {
997 $batch .= &$h($self, \@cust_pay_batch). $delim;
999 $batch .= $h. $delim;
1002 foreach my $cust_pay_batch (@cust_pay_batch) {
1004 $batchtotal += $cust_pay_batch->amount;
1006 &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
1010 my $f = $info->{'footer'};
1011 if (ref($f) eq 'CODE') {
1012 $batch .= &$f($self, $batchcount, $batchtotal). $delim;
1014 $batch .= $f. $delim;
1017 if ($info->{'autopost'}) {
1018 my $error = &{$info->{'autopost'}}($self, $batch);
1020 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1025 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1029 =item export_to_gateway GATEWAY OPTIONS
1031 Given L<FS::payment_gateway> GATEWAY, export the items in this batch to
1032 that gateway via Business::BatchPayment. OPTIONS may include:
1034 - file: override the default transport and write to this file (name or handle)
1038 sub export_to_gateway {
1040 my ($self, $gateway, %opt) = @_;
1042 my $oldAutoCommit = $FS::UID::AutoCommit;
1043 local $FS::UID::AutoCommit = 0;
1046 my $error = $self->prepare_for_export;
1047 die $error if $error;
1050 'output' => $opt{'file'}, # will do nothing if it's empty
1051 # any other constructor options go here
1053 my $processor = $gateway->batch_processor(%proc_opt);
1055 my @items = map { $_->request_item } $self->cust_pay_batch;
1056 my $batch = Business::BatchPayment->create(Batch =>
1057 batch_id => $self->batchnum,
1060 $processor->submit($batch);
1062 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1066 sub manual_approve {
1070 my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
1071 my $conf = FS::Conf->new;
1072 return 'manual batch approval disabled'
1073 if ( ! $conf->exists('batch-manual_approval') );
1074 return 'batch already resolved' if $self->status eq 'R';
1075 return 'batch not yet submitted' if $self->status eq 'O';
1077 local $SIG{HUP} = 'IGNORE';
1078 local $SIG{INT} = 'IGNORE';
1079 local $SIG{QUIT} = 'IGNORE';
1080 local $SIG{TERM} = 'IGNORE';
1081 local $SIG{TSTP} = 'IGNORE';
1082 local $SIG{PIPE} = 'IGNORE';
1084 my $oldAutoCommit = $FS::UID::AutoCommit;
1085 local $FS::UID::AutoCommit = 0;
1089 foreach my $cust_pay_batch (
1090 qsearch('cust_pay_batch', { batchnum => $self->batchnum,
1093 my $new_cust_pay_batch = new FS::cust_pay_batch {
1094 $cust_pay_batch->hash,
1095 'paid' => $cust_pay_batch->amount,
1097 'usernum' => $usernum,
1099 my $error = $new_cust_pay_batch->approve();
1100 # there are no approval options here (authorization, order_number, etc.)
1101 # because the transaction wasn't really approved
1104 return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
1108 $self->set_status('R');
1114 # Set up configuration for gateways that have a Business::BatchPayment
1117 eval "use Class::MOP;";
1119 warn "Moose/Class::MOP not available.\n$@\nSkipping pay_batch upgrade.\n";
1122 my $conf = FS::Conf->new;
1123 for my $format (keys %export_info) {
1124 my $mod = "FS::pay_batch::$format";
1125 if ( $mod->can('_upgrade_gateway')
1126 and $conf->exists("batchconfig-$format") ) {
1129 my ($module, %gw_options) = $mod->_upgrade_gateway;
1130 my $gateway = FS::payment_gateway->new({
1131 gateway_namespace => 'Business::BatchPayment',
1132 gateway_module => $module,
1134 my $error = $gateway->insert(%gw_options);
1136 warn "Failed to migrate '$format' to a Business::BatchPayment::$module gateway:\n$error\n";
1140 # test whether it loads
1141 my $processor = eval { $gateway->batch_processor };
1142 if ( !$processor ) {
1143 warn "Couldn't load Business::BatchPayment module for '$format'.\n";
1144 # if not, remove it so it doesn't hang around and break things
1148 # remove the batchconfig-*
1149 warn "Created Business::BatchPayment gateway '".$gateway->label.
1150 "' for '$format' batch processing.\n";
1151 $conf->delete("batchconfig-$format");
1153 # and if appropriate, make it the system default
1154 for my $payby (qw(CARD CHEK)) {
1155 if ( ($conf->config("batch-fixed_format-$payby") || '') eq $format ) {
1156 warn "Setting as default for $payby.\n";
1157 $conf->set("batch-gateway-$payby", $gateway->gatewaynum);
1158 $conf->delete("batch-fixed_format-$payby");
1162 } #if can('_upgrade_gateway') and batchconfig-$format
1172 status is somewhat redundant now that download and upload exist
1176 L<FS::Record>, schema.html from the base documentation.