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::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. Can be called as an instance method, if you want to
213 automatically adjust status on a specific batch, or a class method, if you
214 don't know which batch(es) the results apply to.
218 I<filehandle> - open filehandle of results file.
220 I<format> - an L<FS::pay_batch> module
222 I<gateway> - an L<FS::payment_gateway> object for a batch gateway. This
223 takes precedence over I<format>.
225 I<no_close> - do not try to close batches
227 Supported format keys (defined in the specified FS::pay_batch module) are:
229 I<filetype> - required, can be CSV, fixed, variable, XML
231 I<fields> - required list of field names for each row/line
233 I<formatre> - regular expression for fixed filetype
235 I<parse> - required for variable filetype
237 I<xmlkeys> - required for XML filetype
239 I<xmlrow> - required for XML filetype
241 I<begin_condition> - sub, ignore all lines before this returns true
243 I<end_condition> - sub, stop processing lines when this returns true
245 I<end_hook> - sub, runs immediately after end_condition returns true
247 I<skip_condition> - sub, skip lines when this returns true
249 I<hook> - required, sub, runs before approved/declined conditions are checked
251 I<approved> - required, sub, returns true when approved
253 I<declined> - required, sub, returns true when declined
255 I<close_condition> - sub, decide whether or not to close the batch
262 my $param = ref($_[0]) ? shift : { @_ };
263 my $fh = $param->{'filehandle'};
264 my $job = $param->{'job'};
265 $job->update_statustext(0) if $job;
267 my $format = $param->{'format'};
268 my $info = $import_info{$format}
269 or die "unknown format $format";
271 my $conf = new FS::Conf;
273 my $filetype = $info->{'filetype'}; # CSV, fixed, variable
274 my @fields = @{ $info->{'fields'}};
275 my $formatre = $info->{'formatre'}; # for fixed
276 my $parse = $info->{'parse'}; # for variable
278 my $begin_condition = $info->{'begin_condition'};
279 my $end_condition = $info->{'end_condition'};
280 my $end_hook = $info->{'end_hook'};
281 my $skip_condition = $info->{'skip_condition'};
282 my $hook = $info->{'hook'};
283 my $approved_condition = $info->{'approved'};
284 my $declined_condition = $info->{'declined'};
285 my $close_condition = $info->{'close_condition'};
287 my %target_batches; # batches that had at least one payment updated
289 my $csv = new Text::CSV_XS;
291 local $SIG{HUP} = 'IGNORE';
292 local $SIG{INT} = 'IGNORE';
293 local $SIG{QUIT} = 'IGNORE';
294 local $SIG{TERM} = 'IGNORE';
295 local $SIG{TSTP} = 'IGNORE';
296 local $SIG{PIPE} = 'IGNORE';
298 my $oldAutoCommit = $FS::UID::AutoCommit;
299 local $FS::UID::AutoCommit = 0;
303 # if called on a specific pay_batch, check the status of that batch
305 my $reself = $self->select_for_update;
307 if ( $reself->status ne 'I'
308 and !$conf->exists('batch-manual_approval') ) {
309 $dbh->rollback if $oldAutoCommit;
310 return "batchnum ". $self->batchnum. "no longer in transit";
312 } # otherwise we can't enforce this constraint. sorry.
317 if ($filetype eq 'XML') {
318 eval "use XML::Simple";
320 my @xmlkeys = @{ $info->{'xmlkeys'} }; # for XML
321 my $xmlrow = $info->{'xmlrow'}; # also for XML
323 # Do everything differently.
324 my $data = XML::Simple::XMLin($fh, KeepRoot => 1);
326 # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
327 $rows = $rows->{$_} foreach( @$xmlrow );
328 if(!defined($rows)) {
329 $dbh->rollback if $oldAutoCommit;
330 return "can't find rows in XML file";
332 $rows = [ $rows ] if ref($rows) ne 'ARRAY';
333 foreach my $row (@$rows) {
334 push @all_values, [ @{$row}{@xmlkeys}, $row ];
338 while ( defined($line=<$fh>) ) {
340 next if $line =~ /^\s*$/; #skip blank lines
342 if ($filetype eq "CSV") {
343 $csv->parse($line) or do {
344 $dbh->rollback if $oldAutoCommit;
345 return "can't parse: ". $csv->error_input();
347 push @all_values, [ $csv->fields(), $line ];
348 }elsif ($filetype eq 'fixed'){
349 my @values = ( $line =~ /$formatre/ );
351 $dbh->rollback if $oldAutoCommit;
352 return "can't parse: ". $line;
355 push @all_values, \@values;
357 elsif ($filetype eq 'variable') {
359 my @values = ( eval { $parse->($self, $line) } );
361 $dbh->rollback if $oldAutoCommit;
365 push @all_values, \@values;
368 $dbh->rollback if $oldAutoCommit;
369 return "Unknown file type $filetype";
375 foreach (@all_values) {
378 $job->update_statustext(int(100 * $num/scalar(@all_values)));
383 my $line = pop @values;
384 foreach my $field ( @fields ) {
385 my $value = shift @values;
387 $hash{$field} = $value;
390 if ( defined($begin_condition) ) {
391 if ( &{$begin_condition}(\%hash, $line) ) {
392 undef $begin_condition;
399 if ( defined($end_condition) and &{$end_condition}(\%hash, $line) ) {
401 $error = &{$end_hook}(\%hash, $total, $line) if defined($end_hook);
403 $dbh->rollback if $oldAutoCommit;
409 if ( defined($skip_condition) and &{$skip_condition}(\%hash, $line) ) {
414 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
415 unless ( $cust_pay_batch ) {
416 return "unknown paybatchnum $hash{'paybatchnum'}\n";
418 # remember that we've touched this batch
419 $target_batches{ $cust_pay_batch->batchnum } = 1;
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)
460 # decide whether to close batches that had payments posted
461 if ( !$param->{no_close} ) {
462 foreach my $batchnum (keys %target_batches) {
463 my $pay_batch = FS::pay_batch->by_key($batchnum);
465 if ( defined($close_condition) ) {
466 # Allow the module to decide whether to close the batch.
467 # $close_condition can also die() to abort the whole import.
468 $close = eval { $close_condition->($pay_batch) };
475 my $error = $pay_batch->set_status('R');
477 $dbh->rollback if $oldAutoCommit;
481 } # foreach $batchnum
482 } # if (!$param->{no_close})
484 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
490 sub process_import_results {
493 $param->{'job'} = $job;
494 warn Dumper($param) if $DEBUG;
495 my $gatewaynum = delete $param->{'gatewaynum'};
497 $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum)
498 or die "gatewaynum '$gatewaynum' not found\n";
499 delete $param->{'format'}; # to avoid confusion
502 my $file = $param->{'uploaded_files'} or die "no files provided\n";
503 $file =~ s/^(\w+):([\.\w]+)$/$2/;
504 my $dir = '%%%FREESIDE_CACHE%%%/cache.' . $FS::UID::datasrc;
505 open( $param->{'filehandle'},
508 or die "unable to open '$file'.\n";
511 if ( $param->{gateway} ) {
512 $error = FS::pay_batch->import_from_gateway(%$param);
514 my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
515 my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
516 $error = $batch->import_results($param);
519 die $error if $error;
522 =item import_from_gateway [ OPTIONS ]
524 Import results from a L<FS::payment_gateway>, using Business::BatchPayment,
525 and apply them. GATEWAY must use the Business::BatchPayment namespace.
527 This is a class method, since results can be applied to any batch.
528 The 'batch-reconsider' option determines whether an already-approved
529 or declined payment can have its status changed by a later import.
533 - gateway: the L<FS::payment_gateway>, required
534 - filehandle: a file name or handle to use as a data source.
535 - job: an L<FS::queue> object to update with progress messages.
539 sub import_from_gateway {
542 my $gateway = $opt{'gateway'};
543 my $conf = FS::Conf->new;
545 # unavoidable duplication with import_batch, for now
546 local $SIG{HUP} = 'IGNORE';
547 local $SIG{INT} = 'IGNORE';
548 local $SIG{QUIT} = 'IGNORE';
549 local $SIG{TERM} = 'IGNORE';
550 local $SIG{TSTP} = 'IGNORE';
551 local $SIG{PIPE} = 'IGNORE';
553 my $oldAutoCommit = $FS::UID::AutoCommit;
554 local $FS::UID::AutoCommit = 0;
557 my $job = delete($opt{'job'});
558 $job->update_statustext(0) if $job;
561 return "import_from_gateway requires a payment_gateway"
562 unless eval { $gateway->isa('FS::payment_gateway') };
565 'input' => $opt{'filehandle'}, # will do nothing if it's empty
566 # any other constructor options go here
570 my $errors_not_fatal = $conf->config('batch-errors_not_fatal');
571 if ( $errors_not_fatal ) {
572 # construct error trap
573 $proc_opt{'on_parse_error'} = sub {
574 my ($self, $line, $error) = @_;
575 push @item_errors, " '$line'\n$error";
579 my $processor = $gateway->batch_processor(%proc_opt);
581 my @processor_ids = map { $_->processor_id }
583 'table' => 'pay_batch',
584 'hashref' => { 'status' => 'I' },
585 'extra_sql' => q( AND processor_id != '' AND processor_id IS NOT NULL)
588 my @batches = $processor->receive(@processor_ids);
592 my $total_items = sum( map{$_->count} @batches);
594 # whether to allow items to change status
595 my $reconsider = $conf->exists('batch-reconsider');
597 # mutex all affected batches
598 my %pay_batch_for_update;
600 my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
602 BATCH: foreach my $batch (@batches) {
604 my %incoming_batch = (
609 ITEM: foreach my $item ($batch->elements) {
611 my $cust_pay_batch; # the new batch entry (with status)
612 my $pay_batch; # the freeside batch it belongs to
613 my $payby; # CARD or CHEK
616 my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module .
617 ':' . ($item->authorization || '') .
618 ':' . ($item->order_number || '');
620 if ( $batch->incoming ) {
621 # This is a one-way batch.
622 # Locate the customer, find an open batch correct for them,
623 # create a payment. Don't bother creating a cust_pay_batch
626 if ( defined($item->customer_id)
627 and $item->customer_id =~ /^\d+$/
628 and $item->customer_id > 0 ) {
630 $cust_main = FS::cust_main->by_key($item->customer_id)
631 || qsearchs('cust_main',
632 { 'agent_custid' => $item->customer_id }
635 push @item_errors, "Unknown customer_id ".$item->customer_id;
640 push @item_errors, "Illegal customer_id '".$item->customer_id."'";
643 # it may also make sense to allow selecting the customer by
644 # invoice_number, but no modules currently work that way
646 $payby = $bop2payby{ $item->payment_type };
648 $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
650 # create a batch if necessary
651 $pay_batch = $incoming_batch{$payby}->{$agentnum} ||=
653 status => 'R', # pre-resolve it
655 agentnum => $agentnum,
657 title => $batch->batch_id,
659 if ( !$pay_batch->batchnum ) {
660 $error = $pay_batch->insert;
661 die $error if $error; # can't do anything if this fails
664 if ( !$item->approved ) {
665 $error ||= "payment rejected - ".$item->error_message;
667 if ( !defined($item->amount) or $item->amount <= 0 ) {
668 $error ||= "no amount in item $num";
672 if ( $item->check_number ) {
673 $payby = 'BILL'; # right?
674 $payinfo = $item->check_number;
675 } elsif ( $item->assigned_token ) {
676 $payinfo = $item->assigned_token;
679 my $cust_pay = FS::cust_pay->new(
681 custnum => $cust_main->custnum,
682 _date => $item->payment_date->epoch,
683 paid => sprintf('%.2f',$item->amount),
685 invnum => $item->invoice_number,
686 batchnum => $pay_batch->batchnum,
688 gatewaynum => $gateway->gatewaynum,
689 processor => $gateway->gateway_module,
690 auth => $item->authorization,
691 order_number => $item->order_number,
694 $error ||= $cust_pay->insert;
695 eval { $cust_main->apply_payments };
699 push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
703 # This is a request/reply batch.
704 # Locate the request (the 'tid' attribute is the paybatchnum).
705 my $paybatchnum = $item->tid;
706 $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
707 if (!$cust_pay_batch) {
708 push @item_errors, "paybatchnum $paybatchnum not found";
711 $payby = $cust_pay_batch->payby;
713 my $batchnum = $cust_pay_batch->batchnum;
714 if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
715 warn "batch ID ".$batch->batch_id.
716 " does not match batchnum ".$cust_pay_batch->batchnum."\n";
719 # lock the batch and check its status
720 $pay_batch = FS::pay_batch->by_key($batchnum);
721 $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
722 if ( $pay_batch->status ne 'I' and !$reconsider ) {
723 $error = "batch $batchnum no longer in transit";
726 if ( $cust_pay_batch->status ) {
727 my $new_status = $item->approved ? 'approved' : 'declined';
728 if ( lc( $cust_pay_batch->status ) eq $new_status ) {
729 # already imported with this status, so don't touch
732 elsif ( !$reconsider ) {
733 # then we're not allowed to change its status, so bail out
734 $error = "paybatchnum ".$item->tid.
735 " already resolved with status '". $cust_pay_batch->status . "'";
740 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
745 # update payinfo, if needed
746 if ( $item->assigned_token ) {
747 $new_payinfo = $item->assigned_token;
748 } elsif ( $payby eq 'CARD' ) {
749 $new_payinfo = $item->card_number if $item->card_number;
750 } else { #$payby eq 'CHEK'
751 $new_payinfo = $item->account_number . '@' . $item->routing_code
752 if $item->account_number;
754 $cust_pay_batch->set('payinfo', $new_payinfo) if $new_payinfo;
756 # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
757 # paid, if the batch says it's different from the amount requested
758 if ( defined $item->amount ) {
759 $cust_pay_batch->set('paid', $item->amount);
761 $cust_pay_batch->set('paid', $cust_pay_batch->amount);
764 # set payment date to when it was processed
765 $cust_pay_batch->_date($item->payment_date->epoch)
766 if $item->payment_date;
769 if ( $item->approved ) {
770 # follow Billing_Realtime format for paybatch
771 $error = $cust_pay_batch->approve(
772 'gatewaynum' => $gateway->gatewaynum,
773 'processor' => $gateway->gateway_module,
774 'auth' => $item->authorization,
775 'order_number' => $item->order_number,
777 $total += $cust_pay_batch->paid;
780 $error = $cust_pay_batch->decline($item->error_message,
781 $item->failure_status);
785 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
791 $job->update_statustext(int(100 * $num/( $total_items ) ),
792 'Importing batch items')
797 } #foreach $batch (input batch, not pay_batch)
799 # Format an error message
800 if ( @item_errors ) {
801 my $error_text = join("\n\n",
802 "Errors during batch import: ".scalar(@item_errors),
805 if ( $errors_not_fatal ) {
806 my $message = "Import from gateway ".$gateway->label." errors: ".$error_text;
807 my $log = FS::Log->new('FS::pay_batch::import_from_gateway');
808 $log->error($message);
811 $dbh->rollback if $oldAutoCommit;
816 # Auto-resolve (with brute-force error handling)
817 foreach my $pay_batch (values %pay_batch_for_update) {
818 my $error = $pay_batch->try_to_resolve;
821 $dbh->rollback if $oldAutoCommit;
826 $dbh->commit if $oldAutoCommit;
832 Resolve this batch if possible. A batch can be resolved if all of its
833 entries have status. If the system options 'batch-auto_resolve_days'
834 and 'batch-auto_resolve_status' are set, and the batch's download date is
835 at least (batch-auto_resolve_days) before the current time, then it can
836 be auto-resolved; entries with no status will be approved or declined
837 according to the batch-auto_resolve_status setting.
843 my $conf = FS::Conf->new;;
845 return if $self->status ne 'I';
847 my @unresolved = qsearch('cust_pay_batch',
849 batchnum => $self->batchnum,
854 if ( @unresolved and $conf->exists('batch-auto_resolve_days') ) {
855 my $days = $conf->config('batch-auto_resolve_days'); # can be zero
856 # either 'approve' or 'decline'
857 my $action = $conf->config('batch-auto_resolve_status') || '';
861 time > ($self->download + 86400 * $days)
865 foreach my $cpb (@unresolved) {
866 if ( $action eq 'approve' ) {
867 # approve it for the full amount
868 $cpb->set('paid', $cpb->amount) unless ($cpb->paid || 0) > 0;
869 $error = $cpb->approve($self->batchnum);
871 elsif ( $action eq 'decline' ) {
872 $error = $cpb->decline('No response from processor');
874 return $error if $error;
876 } elsif ( @unresolved ) {
877 # auto resolve is not enabled, and we're not ready to resolve
881 $self->set_status('R');
884 =item prepare_for_export
886 Prepare the batch to be exported. This will:
887 - Set the status to "in transit".
888 - If batch-increment_expiration is set and this is a credit card batch,
889 increment expiration dates that are in the past.
890 - If this is the first download for this batch, adjust payment amounts to
891 not be greater than the customer's current balance. If the customer's
892 balance is zero, the entry will be removed (caution: all cust_pay_batch
893 entries might be removed!)
895 Use this within a transaction.
899 sub prepare_for_export {
901 my $conf = FS::Conf->new;
902 my $curuser = $FS::CurrentUser::CurrentUser;
905 my $status = $self->status;
906 if ($status eq 'O') {
908 } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
910 } elsif ($status eq 'R' &&
911 $curuser->access_right('Redownload resolved batches')) {
914 die "No pending batch.\n";
917 my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
918 $self->cust_pay_batch;
920 # handle batch-increment_expiration option
921 if ( $self->payby eq 'CARD' ) {
922 my ($cmon, $cyear) = (localtime(time))[4,5];
923 foreach (@cust_pay_batch) {
924 my $etime = str2time($_->exp) or next;
925 my ($day, $mon, $year) = (localtime($etime))[3,4,5];
926 if( $conf->exists('batch-increment_expiration') ) {
927 $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
928 $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
930 my $error = $_->replace;
931 return $error if $error;
935 if ($first_download) { #remove or reduce entries if customer's balance changed
937 foreach my $cust_pay_batch (@cust_pay_batch) {
939 my $balance = $cust_pay_batch->cust_main->balance;
940 if ($balance <= 0) { # then don't charge this customer
941 my $error = $cust_pay_batch->unbatch_and_delete;
942 return $error if $error;
943 } elsif ($balance < $cust_pay_batch->amount) {
944 # reduce the charge to the remaining balance
945 $cust_pay_batch->amount($balance);
946 my $error = $cust_pay_batch->replace;
947 return $error if $error;
949 # else $balance >= $cust_pay_batch->amount
952 #need to do this after unbatch_and_delete
953 my $error = $self->set_status('I');
954 return "error updating pay_batch status: $error\n" if $error;
956 } #if $first_download
961 =item export_batch [ format => FORMAT | gateway => GATEWAY ]
963 Export batch for processing. FORMAT is the name of an L<FS::pay_batch>
964 module, in which case the configuration options are in 'batchconfig-FORMAT'.
966 Alternatively, GATEWAY can be an L<FS::payment_gateway> object set to a
967 L<Business::BatchPayment> module.
969 Returns the text of the batch. If batch contains no cust_pay_batch entries
970 (or has them all removed by L</prepare_for_export>) then the batch will be
971 resolved and a blank string will be returned. All other errors are fatal.
979 my $conf = new FS::Conf;
982 my $gateway = $opt{'gateway'};
984 # welcome to the future
985 my $fh = IO::Scalar->new(\$batch);
986 $self->export_to_gateway($gateway, 'file' => $fh);
990 my $format = $opt{'format'} || $conf->config('batch-default_format')
991 or die "No batch format configured\n";
993 my $info = $export_info{$format} or die "Format not found: '$format'\n";
995 &{$info->{'init'}}($conf, $self->agentnum) if exists($info->{'init'});
997 my $oldAutoCommit = $FS::UID::AutoCommit;
998 local $FS::UID::AutoCommit = 0;
1001 my $error = $self->prepare_for_export;
1003 die $error if $error;
1007 my @cust_pay_batch = $self->cust_pay_batch;
1008 unless (@cust_pay_batch) {
1009 # if it's empty, just resolve the batch
1010 $self->set_status('R');
1011 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1015 my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
1017 my $h = $info->{'header'};
1018 if (ref($h) eq 'CODE') {
1019 $batch .= &$h($self, \@cust_pay_batch). $delim;
1021 $batch .= $h. $delim;
1024 foreach my $cust_pay_batch (@cust_pay_batch) {
1026 $batchtotal += $cust_pay_batch->amount;
1028 &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
1032 my $f = $info->{'footer'};
1033 if (ref($f) eq 'CODE') {
1034 $batch .= &$f($self, $batchcount, $batchtotal). $delim;
1036 $batch .= $f. $delim;
1039 if ($info->{'autopost'}) {
1040 my $error = &{$info->{'autopost'}}($self, $batch);
1042 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1047 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1051 =item export_to_gateway GATEWAY OPTIONS
1053 Given L<FS::payment_gateway> GATEWAY, export the items in this batch to
1054 that gateway via Business::BatchPayment. OPTIONS may include:
1056 - file: override the default transport and write to this file (name or handle)
1058 If batch contains no cust_pay_batch entries (or has them all removed by
1059 L</prepare_for_export>) then nothing will be transported (or written to
1060 the override file) and the batch will be resolved.
1064 sub export_to_gateway {
1066 my ($self, $gateway, %opt) = @_;
1068 my $oldAutoCommit = $FS::UID::AutoCommit;
1069 local $FS::UID::AutoCommit = 0;
1072 my $error = $self->prepare_for_export;
1073 die $error if $error;
1076 'output' => $opt{'file'}, # will do nothing if it's empty
1077 # any other constructor options go here
1079 my $processor = $gateway->batch_processor(%proc_opt);
1081 my @items = map { $_->request_item } $self->cust_pay_batch;
1083 # if it's empty, just resolve the batch
1084 $self->set_status('R');
1085 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1089 my $batch = Business::BatchPayment->create(Batch =>
1090 batch_id => $self->batchnum,
1093 $processor->submit($batch);
1095 if ($batch->processor_id) {
1096 $self->set('processor_id',$batch->processor_id);
1100 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1104 sub manual_approve {
1108 my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
1109 my $conf = FS::Conf->new;
1110 return 'manual batch approval disabled'
1111 if ( ! $conf->exists('batch-manual_approval') );
1112 return 'batch already resolved' if $self->status eq 'R';
1113 return 'batch not yet submitted' if $self->status eq 'O';
1115 local $SIG{HUP} = 'IGNORE';
1116 local $SIG{INT} = 'IGNORE';
1117 local $SIG{QUIT} = 'IGNORE';
1118 local $SIG{TERM} = 'IGNORE';
1119 local $SIG{TSTP} = 'IGNORE';
1120 local $SIG{PIPE} = 'IGNORE';
1122 my $oldAutoCommit = $FS::UID::AutoCommit;
1123 local $FS::UID::AutoCommit = 0;
1127 foreach my $cust_pay_batch (
1128 qsearch('cust_pay_batch', { batchnum => $self->batchnum,
1131 my $new_cust_pay_batch = new FS::cust_pay_batch {
1132 $cust_pay_batch->hash,
1133 'paid' => $cust_pay_batch->amount,
1135 'usernum' => $usernum,
1137 my $error = $new_cust_pay_batch->approve();
1138 # there are no approval options here (authorization, order_number, etc.)
1139 # because the transaction wasn't really approved
1142 return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
1146 $self->set_status('R');
1152 # Set up configuration for gateways that have a Business::BatchPayment
1155 eval "use Class::MOP;";
1157 warn "Moose/Class::MOP not available.\n$@\nSkipping pay_batch upgrade.\n";
1160 my $conf = FS::Conf->new;
1161 for my $format (keys %export_info) {
1162 my $mod = "FS::pay_batch::$format";
1163 if ( $mod->can('_upgrade_gateway')
1164 and $conf->exists("batchconfig-$format") ) {
1167 my ($module, %gw_options) = $mod->_upgrade_gateway;
1168 my $gateway = FS::payment_gateway->new({
1169 gateway_namespace => 'Business::BatchPayment',
1170 gateway_module => $module,
1172 my $error = $gateway->insert(%gw_options);
1174 warn "Failed to migrate '$format' to a Business::BatchPayment::$module gateway:\n$error\n";
1178 # test whether it loads
1179 my $processor = eval { $gateway->batch_processor };
1180 if ( !$processor ) {
1181 warn "Couldn't load Business::BatchPayment module for '$format'.\n";
1182 # if not, remove it so it doesn't hang around and break things
1186 # remove the batchconfig-*
1187 warn "Created Business::BatchPayment gateway '".$gateway->label.
1188 "' for '$format' batch processing.\n";
1189 $conf->delete("batchconfig-$format");
1191 # and if appropriate, make it the system default
1192 for my $payby (qw(CARD CHEK)) {
1193 if ( ($conf->config("batch-fixed_format-$payby") || '') eq $format ) {
1194 warn "Setting as default for $payby.\n";
1195 $conf->set("batch-gateway-$payby", $gateway->gatewaynum);
1196 $conf->delete("batch-fixed_format-$payby");
1200 } #if can('_upgrade_gateway') and batchconfig-$format
1210 status is somewhat redundant now that download and upload exist
1214 L<FS::Record>, schema.html from the base documentation.