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. Can be called as an instance method, if you want to
227 automatically adjust status on a specific batch, or a class method, if you
228 don't know which batch(es) the results apply to.
232 I<filehandle> - open filehandle of results file.
234 I<format> - an L<FS::pay_batch> module
236 I<gateway> - an L<FS::payment_gateway> object for a batch gateway. This
237 takes precedence over I<format>.
239 Supported format keys (defined in the specified FS::pay_batch module) are:
241 I<filetype> - required, can be CSV, fixed, variable, XML
243 I<fields> - required list of field names for each row/line
245 I<formatre> - regular expression for fixed filetype
247 I<parse> - required for variable filetype
249 I<xmlkeys> - required for XML filetype
251 I<xmlrow> - required for XML filetype
253 I<begin_condition> - sub, ignore all lines before this returns true
255 I<end_condition> - sub, stop processing lines when this returns true
257 I<end_hook> - sub, runs immediately after end_condition returns true
259 I<skip_condition> - sub, skip lines when this returns true
261 I<hook> - required, sub, runs before approved/declined conditions are checked
263 I<approved> - required, sub, returns true when approved
265 I<declined> - required, sub, returns true when declined
267 I<close_condition> - sub, decide whether or not to close the batch
274 my $param = ref($_[0]) ? shift : { @_ };
275 my $fh = $param->{'filehandle'};
276 my $job = $param->{'job'};
277 $job->update_statustext(0) if $job;
279 my $format = $param->{'format'};
280 my $info = $import_info{$format}
281 or die "unknown format $format";
283 my $conf = new FS::Conf;
285 my $filetype = $info->{'filetype'}; # CSV, fixed, variable
286 my @fields = @{ $info->{'fields'}};
287 my $formatre = $info->{'formatre'}; # for fixed
288 my $parse = $info->{'parse'}; # for variable
290 my $begin_condition = $info->{'begin_condition'};
291 my $end_condition = $info->{'end_condition'};
292 my $end_hook = $info->{'end_hook'};
293 my $skip_condition = $info->{'skip_condition'};
294 my $hook = $info->{'hook'};
295 my $approved_condition = $info->{'approved'};
296 my $declined_condition = $info->{'declined'};
297 my $close_condition = $info->{'close_condition'};
299 my %target_batches; # batches that had at least one payment updated
301 my $csv = new Text::CSV_XS;
303 local $SIG{HUP} = 'IGNORE';
304 local $SIG{INT} = 'IGNORE';
305 local $SIG{QUIT} = 'IGNORE';
306 local $SIG{TERM} = 'IGNORE';
307 local $SIG{TSTP} = 'IGNORE';
308 local $SIG{PIPE} = 'IGNORE';
310 my $oldAutoCommit = $FS::UID::AutoCommit;
311 local $FS::UID::AutoCommit = 0;
315 # if called on a specific pay_batch, check the status of that batch
317 my $reself = $self->select_for_update;
319 if ( $reself->status ne 'I'
320 and !$conf->exists('batch-manual_approval') ) {
321 $dbh->rollback if $oldAutoCommit;
322 return "batchnum ". $self->batchnum. "no longer in transit";
324 } # otherwise we can't enforce this constraint. sorry.
329 if ($filetype eq 'XML') {
330 eval "use XML::Simple";
332 my @xmlkeys = @{ $info->{'xmlkeys'} }; # for XML
333 my $xmlrow = $info->{'xmlrow'}; # also for XML
335 # Do everything differently.
336 my $data = XML::Simple::XMLin($fh, KeepRoot => 1);
338 # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
339 $rows = $rows->{$_} foreach( @$xmlrow );
340 if(!defined($rows)) {
341 $dbh->rollback if $oldAutoCommit;
342 return "can't find rows in XML file";
344 $rows = [ $rows ] if ref($rows) ne 'ARRAY';
345 foreach my $row (@$rows) {
346 push @all_values, [ @{$row}{@xmlkeys}, $row ];
350 while ( defined($line=<$fh>) ) {
352 next if $line =~ /^\s*$/; #skip blank lines
354 if ($filetype eq "CSV") {
355 $csv->parse($line) or do {
356 $dbh->rollback if $oldAutoCommit;
357 return "can't parse: ". $csv->error_input();
359 push @all_values, [ $csv->fields(), $line ];
360 }elsif ($filetype eq 'fixed'){
361 my @values = ( $line =~ /$formatre/ );
363 $dbh->rollback if $oldAutoCommit;
364 return "can't parse: ". $line;
367 push @all_values, \@values;
369 elsif ($filetype eq 'variable') {
371 my @values = ( eval { $parse->($self, $line) } );
373 $dbh->rollback if $oldAutoCommit;
377 push @all_values, \@values;
380 $dbh->rollback if $oldAutoCommit;
381 return "Unknown file type $filetype";
387 foreach (@all_values) {
390 $job->update_statustext(int(100 * $num/scalar(@all_values)));
395 my $line = pop @values;
396 foreach my $field ( @fields ) {
397 my $value = shift @values;
399 $hash{$field} = $value;
402 if ( defined($begin_condition) ) {
403 if ( &{$begin_condition}(\%hash, $line) ) {
404 undef $begin_condition;
411 if ( defined($end_condition) and &{$end_condition}(\%hash, $line) ) {
413 $error = &{$end_hook}(\%hash, $total, $line) if defined($end_hook);
415 $dbh->rollback if $oldAutoCommit;
421 if ( defined($skip_condition) and &{$skip_condition}(\%hash, $line) ) {
426 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
427 unless ( $cust_pay_batch ) {
428 return "unknown paybatchnum $hash{'paybatchnum'}\n";
430 # remember that we've touched this batch
431 $target_batches{ $cust_pay_batch->batchnum } = 1;
433 my $custnum = $cust_pay_batch->custnum,
434 my $payby = $cust_pay_batch->payby,
436 &{$hook}(\%hash, $cust_pay_batch->hashref);
438 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
441 if ( &{$approved_condition}(\%hash) ) {
443 foreach ('paid', '_date', 'payinfo') {
444 $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
446 $error = $new_cust_pay_batch->approve(%hash);
447 $total += $hash{'paid'};
449 } elsif ( &{$declined_condition}(\%hash) ) {
451 $error = $new_cust_pay_batch->decline($hash{'error_message'});;
456 $dbh->rollback if $oldAutoCommit;
460 # purge CVV when the batch is processed
461 if ( $payby =~ /^(CARD|DCRD)$/ ) {
462 my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
463 if ( ! grep { $_ eq cardtype($payinfo) }
464 $conf->config('cvv-save') ) {
465 $new_cust_pay_batch->cust_main->remove_cvv;
470 } # foreach (@all_values)
472 # decide whether to close batches that had payments posted
473 foreach my $batchnum (keys %target_batches) {
474 my $pay_batch = FS::pay_batch->by_key($batchnum);
476 if ( defined($close_condition) ) {
477 # Allow the module to decide whether to close the batch.
478 # $close_condition can also die() to abort the whole import.
479 $close = eval { $close_condition->($pay_batch) };
486 my $error = $pay_batch->set_status('R');
488 $dbh->rollback if $oldAutoCommit;
494 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
502 sub process_import_results {
504 my $param = thaw(decode_base64(shift));
505 $param->{'job'} = $job;
506 warn Dumper($param) if $DEBUG;
507 my $gatewaynum = delete $param->{'gatewaynum'};
509 $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum)
510 or die "gatewaynum '$gatewaynum' not found\n";
511 delete $param->{'format'}; # to avoid confusion
514 my $file = $param->{'uploaded_files'} or die "no files provided\n";
515 $file =~ s/^(\w+):([\.\w]+)$/$2/;
516 my $dir = '%%%FREESIDE_CACHE%%%/cache.' . $FS::UID::datasrc;
517 open( $param->{'filehandle'},
520 or die "unable to open '$file'.\n";
523 if ( $param->{gateway} ) {
524 $error = FS::pay_batch->import_from_gateway(%$param);
526 my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
527 my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
528 $error = $batch->import_results($param);
531 die $error if $error;
534 =item import_from_gateway [ OPTIONS ]
536 Import results from a L<FS::payment_gateway>, using Business::BatchPayment,
537 and apply them. GATEWAY must use the Business::BatchPayment namespace.
539 This is a class method, since results can be applied to any batch.
540 The 'batch-reconsider' option determines whether an already-approved
541 or declined payment can have its status changed by a later import.
545 - gateway: the L<FS::payment_gateway>, required
546 - filehandle: a file name or handle to use as a data source.
547 - job: an L<FS::queue> object to update with progress messages.
551 sub import_from_gateway {
554 my $gateway = $opt{'gateway'};
555 my $conf = FS::Conf->new;
557 # unavoidable duplication with import_batch, for now
558 local $SIG{HUP} = 'IGNORE';
559 local $SIG{INT} = 'IGNORE';
560 local $SIG{QUIT} = 'IGNORE';
561 local $SIG{TERM} = 'IGNORE';
562 local $SIG{TSTP} = 'IGNORE';
563 local $SIG{PIPE} = 'IGNORE';
565 my $oldAutoCommit = $FS::UID::AutoCommit;
566 local $FS::UID::AutoCommit = 0;
569 my $job = delete($opt{'job'});
570 $job->update_statustext(0) if $job;
573 return "import_from_gateway requires a payment_gateway"
574 unless eval { $gateway->isa('FS::payment_gateway') };
577 'input' => $opt{'filehandle'}, # will do nothing if it's empty
578 # any other constructor options go here
582 my $mail_on_error = $conf->config('batch-errors_to');
583 if ( $mail_on_error ) {
584 # construct error trap
585 $proc_opt{'on_parse_error'} = sub {
586 my ($self, $line, $error) = @_;
587 push @item_errors, " '$line'\n$error";
591 my $processor = $gateway->batch_processor(%proc_opt);
593 my @processor_ids = map { $_->processor_id }
595 'table' => 'pay_batch',
596 'hashref' => { 'status' => 'I' },
597 'extra_sql' => q( AND processor_id != '' AND processor_id IS NOT NULL)
600 my @batches = $processor->receive(@processor_ids);
604 my $total_items = sum( map{$_->count} @batches);
606 # whether to allow items to change status
607 my $reconsider = $conf->exists('batch-reconsider');
609 # mutex all affected batches
610 my %pay_batch_for_update;
612 my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
614 BATCH: foreach my $batch (@batches) {
616 my %incoming_batch = (
621 ITEM: foreach my $item ($batch->elements) {
623 my $cust_pay_batch; # the new batch entry (with status)
624 my $pay_batch; # the freeside batch it belongs to
625 my $payby; # CARD or CHEK
628 my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module .
629 ':' . $item->authorization . ':' . $item->order_number;
631 if ( $batch->incoming ) {
632 # This is a one-way batch.
633 # Locate the customer, find an open batch correct for them,
634 # create a payment. Don't bother creating a cust_pay_batch
637 if ( defined($item->customer_id)
638 and $item->customer_id =~ /^\d+$/
639 and $item->customer_id > 0 ) {
641 $cust_main = FS::cust_main->by_key($item->customer_id)
642 || qsearchs('cust_main',
643 { 'agent_custid' => $item->customer_id }
646 push @item_errors, "Unknown customer_id ".$item->customer_id;
651 push @item_errors, "Illegal customer_id '".$item->customer_id."'";
654 # it may also make sense to allow selecting the customer by
655 # invoice_number, but no modules currently work that way
657 $payby = $bop2payby{ $item->payment_type };
659 $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
661 # create a batch if necessary
662 $pay_batch = $incoming_batch{$payby}->{$agentnum} ||=
664 status => 'R', # pre-resolve it
666 agentnum => $agentnum,
668 title => $batch->batch_id,
670 if ( !$pay_batch->batchnum ) {
671 $error = $pay_batch->insert;
672 die $error if $error; # can't do anything if this fails
675 if ( !$item->approved ) {
676 $error ||= "payment rejected - ".$item->error_message;
678 if ( !defined($item->amount) or $item->amount <= 0 ) {
679 $error ||= "no amount in item $num";
683 if ( $item->check_number ) {
684 $payby = 'BILL'; # right?
685 $payinfo = $item->check_number;
686 } elsif ( $item->assigned_token ) {
687 $payinfo = $item->assigned_token;
690 my $cust_pay = FS::cust_pay->new(
692 custnum => $cust_main->custnum,
693 _date => $item->payment_date->epoch,
694 paid => sprintf('%.2f',$item->amount),
696 invnum => $item->invoice_number,
697 batchnum => $pay_batch->batchnum,
699 gatewaynum => $gateway->gatewaynum,
700 processor => $gateway->gateway_module,
701 auth => $item->authorization,
702 order_number => $item->order_number,
705 $error ||= $cust_pay->insert;
706 eval { $cust_main->apply_payments };
710 push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
714 # This is a request/reply batch.
715 # Locate the request (the 'tid' attribute is the paybatchnum).
716 my $paybatchnum = $item->tid;
717 $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
718 if (!$cust_pay_batch) {
719 push @item_errors, "paybatchnum $paybatchnum not found";
722 $payby = $cust_pay_batch->payby;
724 my $batchnum = $cust_pay_batch->batchnum;
725 if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
726 warn "batch ID ".$batch->batch_id.
727 " does not match batchnum ".$cust_pay_batch->batchnum."\n";
730 # lock the batch and check its status
731 $pay_batch = FS::pay_batch->by_key($batchnum);
732 $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
733 if ( $pay_batch->status ne 'I' and !$reconsider ) {
734 $error = "batch $batchnum no longer in transit";
737 if ( $cust_pay_batch->status ) {
738 my $new_status = $item->approved ? 'approved' : 'declined';
739 if ( lc( $cust_pay_batch->status ) eq $new_status ) {
740 # already imported with this status, so don't touch
743 elsif ( !$reconsider ) {
744 # then we're not allowed to change its status, so bail out
745 $error = "paybatchnum ".$item->tid.
746 " already resolved with status '". $cust_pay_batch->status . "'";
751 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
756 # update payinfo, if needed
757 if ( $item->assigned_token ) {
758 $new_payinfo = $item->assigned_token;
759 } elsif ( $payby eq 'CARD' ) {
760 $new_payinfo = $item->card_number if $item->card_number;
761 } else { #$payby eq 'CHEK'
762 $new_payinfo = $item->account_number . '@' . $item->routing_code
763 if $item->account_number;
765 $cust_pay_batch->set('payinfo', $new_payinfo) if $new_payinfo;
767 # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
768 # paid, if the batch says it's different from the amount requested
769 if ( defined $item->amount ) {
770 $cust_pay_batch->set('paid', $item->amount);
772 $cust_pay_batch->set('paid', $cust_pay_batch->amount);
775 # set payment date to when it was processed
776 $cust_pay_batch->_date($item->payment_date->epoch)
777 if $item->payment_date;
780 if ( $item->approved ) {
781 # follow Billing_Realtime format for paybatch
782 $error = $cust_pay_batch->approve(
783 'gatewaynum' => $gateway->gatewaynum,
784 'processor' => $gateway->gateway_module,
785 'auth' => $item->authorization,
786 'order_number' => $item->order_number,
788 $total += $cust_pay_batch->paid;
791 $error = $cust_pay_batch->decline($item->error_message);
795 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
801 $job->update_statustext(int(100 * $num/( $total_items ) ),
802 'Importing batch items')
807 } #foreach $batch (input batch, not pay_batch)
809 # Format an error message
810 if ( @item_errors ) {
811 my $error_text = join("\n\n",
812 "Errors during batch import: ".scalar(@item_errors),
815 if ( $mail_on_error ) {
816 my $subject = "Batch import errors"; #?
817 my $body = "Import from gateway ".$gateway->label."\n".$error_text;
819 to => $mail_on_error,
820 from => $conf->invoice_from_full(),
826 $dbh->rollback if $oldAutoCommit;
831 # Auto-resolve (with brute-force error handling)
832 foreach my $pay_batch (values %pay_batch_for_update) {
833 my $error = $pay_batch->try_to_resolve;
836 $dbh->rollback if $oldAutoCommit;
841 $dbh->commit if $oldAutoCommit;
847 Resolve this batch if possible. A batch can be resolved if all of its
848 entries have status. If the system options 'batch-auto_resolve_days'
849 and 'batch-auto_resolve_status' are set, and the batch's download date is
850 at least (batch-auto_resolve_days) before the current time, then it can
851 be auto-resolved; entries with no status will be approved or declined
852 according to the batch-auto_resolve_status setting.
858 my $conf = FS::Conf->new;;
860 return if $self->status ne 'I';
862 my @unresolved = qsearch('cust_pay_batch',
864 batchnum => $self->batchnum,
869 if ( @unresolved and $conf->exists('batch-auto_resolve_days') ) {
870 my $days = $conf->config('batch-auto_resolve_days'); # can be zero
871 # either 'approve' or 'decline'
872 my $action = $conf->config('batch-auto_resolve_status') || '';
876 time > ($self->download + 86400 * $days)
880 foreach my $cpb (@unresolved) {
881 if ( $action eq 'approve' ) {
882 # approve it for the full amount
883 $cpb->set('paid', $cpb->amount) unless ($cpb->paid || 0) > 0;
884 $error = $cpb->approve($self->batchnum);
886 elsif ( $action eq 'decline' ) {
887 $error = $cpb->decline('No response from processor');
889 return $error if $error;
891 } elsif ( @unresolved ) {
892 # auto resolve is not enabled, and we're not ready to resolve
896 $self->set_status('R');
899 =item prepare_for_export
901 Prepare the batch to be exported. This will:
902 - Set the status to "in transit".
903 - If batch-increment_expiration is set and this is a credit card batch,
904 increment expiration dates that are in the past.
905 - If this is the first download for this batch, adjust payment amounts to
906 not be greater than the customer's current balance. If the customer's
907 balance is zero, the entry will be removed.
909 Use this within a transaction.
913 sub prepare_for_export {
915 my $conf = FS::Conf->new;
916 my $curuser = $FS::CurrentUser::CurrentUser;
919 my $status = $self->status;
920 if ($status eq 'O') {
922 my $error = $self->set_status('I');
923 return "error updating pay_batch status: $error\n" if $error;
924 } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
926 } elsif ($status eq 'R' &&
927 $curuser->access_right('Redownload resolved batches')) {
930 die "No pending batch.\n";
933 my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
934 $self->cust_pay_batch;
936 # handle batch-increment_expiration option
937 if ( $self->payby eq 'CARD' ) {
938 my ($cmon, $cyear) = (localtime(time))[4,5];
939 foreach (@cust_pay_batch) {
940 my $etime = str2time($_->exp) or next;
941 my ($day, $mon, $year) = (localtime($etime))[3,4,5];
942 if( $conf->exists('batch-increment_expiration') ) {
943 $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
944 $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
946 my $error = $_->replace;
947 return $error if $error;
951 if ($first_download) { #remove or reduce entries if customer's balance changed
953 foreach my $cust_pay_batch (@cust_pay_batch) {
955 my $balance = $cust_pay_batch->cust_main->balance;
956 if ($balance <= 0) { # then don't charge this customer
957 my $error = $cust_pay_batch->delete;
958 return $error if $error;
959 } elsif ($balance < $cust_pay_batch->amount) {
960 # reduce the charge to the remaining balance
961 $cust_pay_batch->amount($balance);
962 my $error = $cust_pay_batch->replace;
963 return $error if $error;
965 # else $balance >= $cust_pay_batch->amount
967 } #if $first_download
972 =item export_batch [ format => FORMAT | gateway => GATEWAY ]
974 Export batch for processing. FORMAT is the name of an L<FS::pay_batch>
975 module, in which case the configuration options are in 'batchconfig-FORMAT'.
977 Alternatively, GATEWAY can be an L<FS::payment_gateway> object set to a
978 L<Business::BatchPayment> module.
986 my $conf = new FS::Conf;
989 my $gateway = $opt{'gateway'};
991 # welcome to the future
992 my $fh = IO::Scalar->new(\$batch);
993 $self->export_to_gateway($gateway, 'file' => $fh);
997 my $format = $opt{'format'} || $conf->config('batch-default_format')
998 or die "No batch format configured\n";
1000 my $info = $export_info{$format} or die "Format not found: '$format'\n";
1002 &{$info->{'init'}}($conf, $self->agentnum) if exists($info->{'init'});
1004 my $oldAutoCommit = $FS::UID::AutoCommit;
1005 local $FS::UID::AutoCommit = 0;
1008 my $error = $self->prepare_for_export;
1010 die $error if $error;
1014 my @cust_pay_batch = $self->cust_pay_batch;
1016 my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
1018 my $h = $info->{'header'};
1019 if (ref($h) eq 'CODE') {
1020 $batch .= &$h($self, \@cust_pay_batch). $delim;
1022 $batch .= $h. $delim;
1025 foreach my $cust_pay_batch (@cust_pay_batch) {
1027 $batchtotal += $cust_pay_batch->amount;
1029 &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
1033 my $f = $info->{'footer'};
1034 if (ref($f) eq 'CODE') {
1035 $batch .= &$f($self, $batchcount, $batchtotal). $delim;
1037 $batch .= $f. $delim;
1040 if ($info->{'autopost'}) {
1041 my $error = &{$info->{'autopost'}}($self, $batch);
1043 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1048 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1052 =item export_to_gateway GATEWAY OPTIONS
1054 Given L<FS::payment_gateway> GATEWAY, export the items in this batch to
1055 that gateway via Business::BatchPayment. OPTIONS may include:
1057 - file: override the default transport and write to this file (name or handle)
1061 sub export_to_gateway {
1063 my ($self, $gateway, %opt) = @_;
1065 my $oldAutoCommit = $FS::UID::AutoCommit;
1066 local $FS::UID::AutoCommit = 0;
1069 my $error = $self->prepare_for_export;
1070 die $error if $error;
1073 'output' => $opt{'file'}, # will do nothing if it's empty
1074 # any other constructor options go here
1076 my $processor = $gateway->batch_processor(%proc_opt);
1078 my @items = map { $_->request_item } $self->cust_pay_batch;
1079 my $batch = Business::BatchPayment->create(Batch =>
1080 batch_id => $self->batchnum,
1083 $processor->submit($batch);
1085 if ($batch->processor_id) {
1086 $self->set('processor_id',$batch->processor_id);
1090 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1094 sub manual_approve {
1098 my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
1099 my $conf = FS::Conf->new;
1100 return 'manual batch approval disabled'
1101 if ( ! $conf->exists('batch-manual_approval') );
1102 return 'batch already resolved' if $self->status eq 'R';
1103 return 'batch not yet submitted' if $self->status eq 'O';
1105 local $SIG{HUP} = 'IGNORE';
1106 local $SIG{INT} = 'IGNORE';
1107 local $SIG{QUIT} = 'IGNORE';
1108 local $SIG{TERM} = 'IGNORE';
1109 local $SIG{TSTP} = 'IGNORE';
1110 local $SIG{PIPE} = 'IGNORE';
1112 my $oldAutoCommit = $FS::UID::AutoCommit;
1113 local $FS::UID::AutoCommit = 0;
1117 foreach my $cust_pay_batch (
1118 qsearch('cust_pay_batch', { batchnum => $self->batchnum,
1121 my $new_cust_pay_batch = new FS::cust_pay_batch {
1122 $cust_pay_batch->hash,
1123 'paid' => $cust_pay_batch->amount,
1125 'usernum' => $usernum,
1127 my $error = $new_cust_pay_batch->approve();
1128 # there are no approval options here (authorization, order_number, etc.)
1129 # because the transaction wasn't really approved
1132 return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
1136 $self->set_status('R');
1142 # Set up configuration for gateways that have a Business::BatchPayment
1145 eval "use Class::MOP;";
1147 warn "Moose/Class::MOP not available.\n$@\nSkipping pay_batch upgrade.\n";
1150 my $conf = FS::Conf->new;
1151 for my $format (keys %export_info) {
1152 my $mod = "FS::pay_batch::$format";
1153 if ( $mod->can('_upgrade_gateway')
1154 and $conf->exists("batchconfig-$format") ) {
1157 my ($module, %gw_options) = $mod->_upgrade_gateway;
1158 my $gateway = FS::payment_gateway->new({
1159 gateway_namespace => 'Business::BatchPayment',
1160 gateway_module => $module,
1162 my $error = $gateway->insert(%gw_options);
1164 warn "Failed to migrate '$format' to a Business::BatchPayment::$module gateway:\n$error\n";
1168 # test whether it loads
1169 my $processor = eval { $gateway->batch_processor };
1170 if ( !$processor ) {
1171 warn "Couldn't load Business::BatchPayment module for '$format'.\n";
1172 # if not, remove it so it doesn't hang around and break things
1176 # remove the batchconfig-*
1177 warn "Created Business::BatchPayment gateway '".$gateway->label.
1178 "' for '$format' batch processing.\n";
1179 $conf->delete("batchconfig-$format");
1181 # and if appropriate, make it the system default
1182 for my $payby (qw(CARD CHEK)) {
1183 if ( ($conf->config("batch-fixed_format-$payby") || '') eq $format ) {
1184 warn "Setting as default for $payby.\n";
1185 $conf->set("batch-gateway-$payby", $gateway->gatewaynum);
1186 $conf->delete("batch-fixed_format-$payby");
1190 } #if can('_upgrade_gateway') and batchconfig-$format
1200 status is somewhat redundant now that download and upload exist
1204 L<FS::Record>, schema.html from the base documentation.