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. 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 Supported format keys (defined in the specified FS::pay_batch module) are:
227 I<filetype> - required, can be CSV, fixed, variable, XML
229 I<fields> - required list of field names for each row/line
231 I<formatre> - regular expression for fixed filetype
233 I<parse> - required for variable filetype
235 I<xmlkeys> - required for XML filetype
237 I<xmlrow> - required for XML filetype
239 I<begin_condition> - sub, ignore all lines before this returns true
241 I<end_condition> - sub, stop processing lines when this returns true
243 I<end_hook> - sub, runs immediately after end_condition returns true
245 I<skip_condition> - sub, skip lines when this returns true
247 I<hook> - required, sub, runs before approved/declined conditions are checked
249 I<approved> - required, sub, returns true when approved
251 I<declined> - required, sub, returns true when declined
253 I<close_condition> - sub, decide whether or not to close the batch
260 my $param = ref($_[0]) ? shift : { @_ };
261 my $fh = $param->{'filehandle'};
262 my $job = $param->{'job'};
263 $job->update_statustext(0) if $job;
265 my $format = $param->{'format'};
266 my $info = $import_info{$format}
267 or die "unknown format $format";
269 my $conf = new FS::Conf;
271 my $filetype = $info->{'filetype'}; # CSV, fixed, variable
272 my @fields = @{ $info->{'fields'}};
273 my $formatre = $info->{'formatre'}; # for fixed
274 my $parse = $info->{'parse'}; # for variable
276 my $begin_condition = $info->{'begin_condition'};
277 my $end_condition = $info->{'end_condition'};
278 my $end_hook = $info->{'end_hook'};
279 my $skip_condition = $info->{'skip_condition'};
280 my $hook = $info->{'hook'};
281 my $approved_condition = $info->{'approved'};
282 my $declined_condition = $info->{'declined'};
283 my $close_condition = $info->{'close_condition'};
285 my %target_batches; # batches that had at least one payment updated
287 my $csv = new Text::CSV_XS;
289 local $SIG{HUP} = 'IGNORE';
290 local $SIG{INT} = 'IGNORE';
291 local $SIG{QUIT} = 'IGNORE';
292 local $SIG{TERM} = 'IGNORE';
293 local $SIG{TSTP} = 'IGNORE';
294 local $SIG{PIPE} = 'IGNORE';
296 my $oldAutoCommit = $FS::UID::AutoCommit;
297 local $FS::UID::AutoCommit = 0;
301 # if called on a specific pay_batch, check the status of that batch
303 my $reself = $self->select_for_update;
305 if ( $reself->status ne 'I'
306 and !$conf->exists('batch-manual_approval') ) {
307 $dbh->rollback if $oldAutoCommit;
308 return "batchnum ". $self->batchnum. "no longer in transit";
310 } # otherwise we can't enforce this constraint. sorry.
315 if ($filetype eq 'XML') {
316 eval "use XML::Simple";
318 my @xmlkeys = @{ $info->{'xmlkeys'} }; # for XML
319 my $xmlrow = $info->{'xmlrow'}; # also for XML
321 # Do everything differently.
322 my $data = XML::Simple::XMLin($fh, KeepRoot => 1);
324 # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
325 $rows = $rows->{$_} foreach( @$xmlrow );
326 if(!defined($rows)) {
327 $dbh->rollback if $oldAutoCommit;
328 return "can't find rows in XML file";
330 $rows = [ $rows ] if ref($rows) ne 'ARRAY';
331 foreach my $row (@$rows) {
332 push @all_values, [ @{$row}{@xmlkeys}, $row ];
336 while ( defined($line=<$fh>) ) {
338 next if $line =~ /^\s*$/; #skip blank lines
340 if ($filetype eq "CSV") {
341 $csv->parse($line) or do {
342 $dbh->rollback if $oldAutoCommit;
343 return "can't parse: ". $csv->error_input();
345 push @all_values, [ $csv->fields(), $line ];
346 }elsif ($filetype eq 'fixed'){
347 my @values = ( $line =~ /$formatre/ );
349 $dbh->rollback if $oldAutoCommit;
350 return "can't parse: ". $line;
353 push @all_values, \@values;
355 elsif ($filetype eq 'variable') {
357 my @values = ( eval { $parse->($self, $line) } );
359 $dbh->rollback if $oldAutoCommit;
363 push @all_values, \@values;
366 $dbh->rollback if $oldAutoCommit;
367 return "Unknown file type $filetype";
373 foreach (@all_values) {
376 $job->update_statustext(int(100 * $num/scalar(@all_values)));
381 my $line = pop @values;
382 foreach my $field ( @fields ) {
383 my $value = shift @values;
385 $hash{$field} = $value;
388 if ( defined($begin_condition) ) {
389 if ( &{$begin_condition}(\%hash, $line) ) {
390 undef $begin_condition;
397 if ( defined($end_condition) and &{$end_condition}(\%hash, $line) ) {
399 $error = &{$end_hook}(\%hash, $total, $line) if defined($end_hook);
401 $dbh->rollback if $oldAutoCommit;
407 if ( defined($skip_condition) and &{$skip_condition}(\%hash, $line) ) {
412 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
413 unless ( $cust_pay_batch ) {
414 return "unknown paybatchnum $hash{'paybatchnum'}\n";
416 # remember that we've touched this batch
417 $target_batches{ $cust_pay_batch->batchnum } = 1;
419 my $custnum = $cust_pay_batch->custnum,
420 my $payby = $cust_pay_batch->payby,
422 &{$hook}(\%hash, $cust_pay_batch->hashref);
424 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
427 if ( &{$approved_condition}(\%hash) ) {
429 foreach ('paid', '_date', 'payinfo') {
430 $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
432 $error = $new_cust_pay_batch->approve(%hash);
433 $total += $hash{'paid'};
435 } elsif ( &{$declined_condition}(\%hash) ) {
437 $error = $new_cust_pay_batch->decline($hash{'error_message'});;
442 $dbh->rollback if $oldAutoCommit;
446 # purge CVV when the batch is processed
447 if ( $payby =~ /^(CARD|DCRD)$/ ) {
448 my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
449 if ( ! grep { $_ eq cardtype($payinfo) }
450 $conf->config('cvv-save') ) {
451 $new_cust_pay_batch->cust_main->remove_cvv;
456 } # foreach (@all_values)
458 # decide whether to close batches that had payments posted
459 foreach my $batchnum (keys %target_batches) {
460 my $pay_batch = FS::pay_batch->by_key($batchnum);
462 if ( defined($close_condition) ) {
463 # Allow the module to decide whether to close the batch.
464 # $close_condition can also die() to abort the whole import.
465 $close = eval { $close_condition->($pay_batch) };
472 my $error = $pay_batch->set_status('R');
474 $dbh->rollback if $oldAutoCommit;
480 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
486 sub process_import_results {
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 @processor_ids = map { $_->processor_id }
579 'table' => 'pay_batch',
580 'hashref' => { 'status' => 'I' },
581 'extra_sql' => q( AND processor_id != '' AND processor_id IS NOT NULL)
584 my @batches = $processor->receive(@processor_ids);
588 my $total_items = sum( map{$_->count} @batches);
590 # whether to allow items to change status
591 my $reconsider = $conf->exists('batch-reconsider');
593 # mutex all affected batches
594 my %pay_batch_for_update;
596 my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
598 BATCH: foreach my $batch (@batches) {
600 my %incoming_batch = (
605 ITEM: foreach my $item ($batch->elements) {
607 my $cust_pay_batch; # the new batch entry (with status)
608 my $pay_batch; # the freeside batch it belongs to
609 my $payby; # CARD or CHEK
612 my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module .
613 ':' . $item->authorization . ':' . $item->order_number;
615 if ( $batch->incoming ) {
616 # This is a one-way batch.
617 # Locate the customer, find an open batch correct for them,
618 # create a payment. Don't bother creating a cust_pay_batch
621 if ( defined($item->customer_id)
622 and $item->customer_id =~ /^\d+$/
623 and $item->customer_id > 0 ) {
625 $cust_main = FS::cust_main->by_key($item->customer_id)
626 || qsearchs('cust_main',
627 { 'agent_custid' => $item->customer_id }
630 push @item_errors, "Unknown customer_id ".$item->customer_id;
635 push @item_errors, "Illegal customer_id '".$item->customer_id."'";
638 # it may also make sense to allow selecting the customer by
639 # invoice_number, but no modules currently work that way
641 $payby = $bop2payby{ $item->payment_type };
643 $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
645 # create a batch if necessary
646 $pay_batch = $incoming_batch{$payby}->{$agentnum} ||=
648 status => 'R', # pre-resolve it
650 agentnum => $agentnum,
652 title => $batch->batch_id,
654 if ( !$pay_batch->batchnum ) {
655 $error = $pay_batch->insert;
656 die $error if $error; # can't do anything if this fails
659 if ( !$item->approved ) {
660 $error ||= "payment rejected - ".$item->error_message;
662 if ( !defined($item->amount) or $item->amount <= 0 ) {
663 $error ||= "no amount in item $num";
667 if ( $item->check_number ) {
668 $payby = 'BILL'; # right?
669 $payinfo = $item->check_number;
670 } elsif ( $item->assigned_token ) {
671 $payinfo = $item->assigned_token;
674 my $cust_pay = FS::cust_pay->new(
676 custnum => $cust_main->custnum,
677 _date => $item->payment_date->epoch,
678 paid => sprintf('%.2f',$item->amount),
680 invnum => $item->invoice_number,
681 batchnum => $pay_batch->batchnum,
683 gatewaynum => $gateway->gatewaynum,
684 processor => $gateway->gateway_module,
685 auth => $item->authorization,
686 order_number => $item->order_number,
689 $error ||= $cust_pay->insert;
690 eval { $cust_main->apply_payments };
694 push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
698 # This is a request/reply batch.
699 # Locate the request (the 'tid' attribute is the paybatchnum).
700 my $paybatchnum = $item->tid;
701 $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
702 if (!$cust_pay_batch) {
703 push @item_errors, "paybatchnum $paybatchnum not found";
706 $payby = $cust_pay_batch->payby;
708 my $batchnum = $cust_pay_batch->batchnum;
709 if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
710 warn "batch ID ".$batch->batch_id.
711 " does not match batchnum ".$cust_pay_batch->batchnum."\n";
714 # lock the batch and check its status
715 $pay_batch = FS::pay_batch->by_key($batchnum);
716 $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
717 if ( $pay_batch->status ne 'I' and !$reconsider ) {
718 $error = "batch $batchnum no longer in transit";
721 if ( $cust_pay_batch->status ) {
722 my $new_status = $item->approved ? 'approved' : 'declined';
723 if ( lc( $cust_pay_batch->status ) eq $new_status ) {
724 # already imported with this status, so don't touch
727 elsif ( !$reconsider ) {
728 # then we're not allowed to change its status, so bail out
729 $error = "paybatchnum ".$item->tid.
730 " already resolved with status '". $cust_pay_batch->status . "'";
735 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
740 # update payinfo, if needed
741 if ( $item->assigned_token ) {
742 $new_payinfo = $item->assigned_token;
743 } elsif ( $payby eq 'CARD' ) {
744 $new_payinfo = $item->card_number if $item->card_number;
745 } else { #$payby eq 'CHEK'
746 $new_payinfo = $item->account_number . '@' . $item->routing_code
747 if $item->account_number;
749 $cust_pay_batch->set('payinfo', $new_payinfo) if $new_payinfo;
751 # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
752 # paid, if the batch says it's different from the amount requested
753 if ( defined $item->amount ) {
754 $cust_pay_batch->set('paid', $item->amount);
756 $cust_pay_batch->set('paid', $cust_pay_batch->amount);
759 # set payment date to when it was processed
760 $cust_pay_batch->_date($item->payment_date->epoch)
761 if $item->payment_date;
764 if ( $item->approved ) {
765 # follow Billing_Realtime format for paybatch
766 $error = $cust_pay_batch->approve(
767 'gatewaynum' => $gateway->gatewaynum,
768 'processor' => $gateway->gateway_module,
769 'auth' => $item->authorization,
770 'order_number' => $item->order_number,
772 $total += $cust_pay_batch->paid;
775 $error = $cust_pay_batch->decline($item->error_message,
776 $item->failure_status);
780 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
786 $job->update_statustext(int(100 * $num/( $total_items ) ),
787 'Importing batch items')
792 } #foreach $batch (input batch, not pay_batch)
794 # Format an error message
795 if ( @item_errors ) {
796 my $error_text = join("\n\n",
797 "Errors during batch import: ".scalar(@item_errors),
800 if ( $mail_on_error ) {
801 my $subject = "Batch import errors"; #?
802 my $body = "Import from gateway ".$gateway->label."\n".$error_text;
804 to => $mail_on_error,
805 from => $conf->invoice_from_full(),
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.
894 Use this within a transaction.
898 sub prepare_for_export {
900 my $conf = FS::Conf->new;
901 my $curuser = $FS::CurrentUser::CurrentUser;
904 my $status = $self->status;
905 if ($status eq 'O') {
907 my $error = $self->set_status('I');
908 return "error updating pay_batch status: $error\n" if $error;
909 } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
911 } elsif ($status eq 'R' &&
912 $curuser->access_right('Redownload resolved batches')) {
915 die "No pending batch.\n";
918 my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
919 $self->cust_pay_batch;
921 # handle batch-increment_expiration option
922 if ( $self->payby eq 'CARD' ) {
923 my ($cmon, $cyear) = (localtime(time))[4,5];
924 foreach (@cust_pay_batch) {
925 my $etime = str2time($_->exp) or next;
926 my ($day, $mon, $year) = (localtime($etime))[3,4,5];
927 if( $conf->exists('batch-increment_expiration') ) {
928 $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
929 $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
931 my $error = $_->replace;
932 return $error if $error;
936 if ($first_download) { #remove or reduce entries if customer's balance changed
938 foreach my $cust_pay_batch (@cust_pay_batch) {
940 my $balance = $cust_pay_batch->cust_main->balance;
941 if ($balance <= 0) { # then don't charge this customer
942 my $error = $cust_pay_batch->delete;
943 return $error if $error;
944 } elsif ($balance < $cust_pay_batch->amount) {
945 # reduce the charge to the remaining balance
946 $cust_pay_batch->amount($balance);
947 my $error = $cust_pay_batch->replace;
948 return $error if $error;
950 # else $balance >= $cust_pay_batch->amount
952 } #if $first_download
957 =item export_batch [ format => FORMAT | gateway => GATEWAY ]
959 Export batch for processing. FORMAT is the name of an L<FS::pay_batch>
960 module, in which case the configuration options are in 'batchconfig-FORMAT'.
962 Alternatively, GATEWAY can be an L<FS::payment_gateway> object set to a
963 L<Business::BatchPayment> module.
971 my $conf = new FS::Conf;
974 my $gateway = $opt{'gateway'};
976 # welcome to the future
977 my $fh = IO::Scalar->new(\$batch);
978 $self->export_to_gateway($gateway, 'file' => $fh);
982 my $format = $opt{'format'} || $conf->config('batch-default_format')
983 or die "No batch format configured\n";
985 my $info = $export_info{$format} or die "Format not found: '$format'\n";
987 &{$info->{'init'}}($conf, $self->agentnum) if exists($info->{'init'});
989 my $oldAutoCommit = $FS::UID::AutoCommit;
990 local $FS::UID::AutoCommit = 0;
993 my $error = $self->prepare_for_export;
995 die $error if $error;
999 my @cust_pay_batch = $self->cust_pay_batch;
1001 my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
1003 my $h = $info->{'header'};
1004 if (ref($h) eq 'CODE') {
1005 $batch .= &$h($self, \@cust_pay_batch). $delim;
1007 $batch .= $h. $delim;
1010 foreach my $cust_pay_batch (@cust_pay_batch) {
1012 $batchtotal += $cust_pay_batch->amount;
1014 &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
1018 my $f = $info->{'footer'};
1019 if (ref($f) eq 'CODE') {
1020 $batch .= &$f($self, $batchcount, $batchtotal). $delim;
1022 $batch .= $f. $delim;
1025 if ($info->{'autopost'}) {
1026 my $error = &{$info->{'autopost'}}($self, $batch);
1028 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1033 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1037 =item export_to_gateway GATEWAY OPTIONS
1039 Given L<FS::payment_gateway> GATEWAY, export the items in this batch to
1040 that gateway via Business::BatchPayment. OPTIONS may include:
1042 - file: override the default transport and write to this file (name or handle)
1046 sub export_to_gateway {
1048 my ($self, $gateway, %opt) = @_;
1050 my $oldAutoCommit = $FS::UID::AutoCommit;
1051 local $FS::UID::AutoCommit = 0;
1054 my $error = $self->prepare_for_export;
1055 die $error if $error;
1058 'output' => $opt{'file'}, # will do nothing if it's empty
1059 # any other constructor options go here
1061 my $processor = $gateway->batch_processor(%proc_opt);
1063 my @items = map { $_->request_item } $self->cust_pay_batch;
1064 my $batch = Business::BatchPayment->create(Batch =>
1065 batch_id => $self->batchnum,
1068 $processor->submit($batch);
1070 if ($batch->processor_id) {
1071 $self->set('processor_id',$batch->processor_id);
1075 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1079 sub manual_approve {
1083 my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
1084 my $conf = FS::Conf->new;
1085 return 'manual batch approval disabled'
1086 if ( ! $conf->exists('batch-manual_approval') );
1087 return 'batch already resolved' if $self->status eq 'R';
1088 return 'batch not yet submitted' if $self->status eq 'O';
1090 local $SIG{HUP} = 'IGNORE';
1091 local $SIG{INT} = 'IGNORE';
1092 local $SIG{QUIT} = 'IGNORE';
1093 local $SIG{TERM} = 'IGNORE';
1094 local $SIG{TSTP} = 'IGNORE';
1095 local $SIG{PIPE} = 'IGNORE';
1097 my $oldAutoCommit = $FS::UID::AutoCommit;
1098 local $FS::UID::AutoCommit = 0;
1102 foreach my $cust_pay_batch (
1103 qsearch('cust_pay_batch', { batchnum => $self->batchnum,
1106 my $new_cust_pay_batch = new FS::cust_pay_batch {
1107 $cust_pay_batch->hash,
1108 'paid' => $cust_pay_batch->amount,
1110 'usernum' => $usernum,
1112 my $error = $new_cust_pay_batch->approve();
1113 # there are no approval options here (authorization, order_number, etc.)
1114 # because the transaction wasn't really approved
1117 return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
1121 $self->set_status('R');
1127 # Set up configuration for gateways that have a Business::BatchPayment
1130 eval "use Class::MOP;";
1132 warn "Moose/Class::MOP not available.\n$@\nSkipping pay_batch upgrade.\n";
1135 my $conf = FS::Conf->new;
1136 for my $format (keys %export_info) {
1137 my $mod = "FS::pay_batch::$format";
1138 if ( $mod->can('_upgrade_gateway')
1139 and $conf->exists("batchconfig-$format") ) {
1142 my ($module, %gw_options) = $mod->_upgrade_gateway;
1143 my $gateway = FS::payment_gateway->new({
1144 gateway_namespace => 'Business::BatchPayment',
1145 gateway_module => $module,
1147 my $error = $gateway->insert(%gw_options);
1149 warn "Failed to migrate '$format' to a Business::BatchPayment::$module gateway:\n$error\n";
1153 # test whether it loads
1154 my $processor = eval { $gateway->batch_processor };
1155 if ( !$processor ) {
1156 warn "Couldn't load Business::BatchPayment module for '$format'.\n";
1157 # if not, remove it so it doesn't hang around and break things
1161 # remove the batchconfig-*
1162 warn "Created Business::BatchPayment gateway '".$gateway->label.
1163 "' for '$format' batch processing.\n";
1164 $conf->delete("batchconfig-$format");
1166 # and if appropriate, make it the system default
1167 for my $payby (qw(CARD CHEK)) {
1168 if ( ($conf->config("batch-fixed_format-$payby") || '') eq $format ) {
1169 warn "Setting as default for $payby.\n";
1170 $conf->set("batch-gateway-$payby", $gateway->gatewaynum);
1171 $conf->delete("batch-fixed_format-$payby");
1175 } #if can('_upgrade_gateway') and batchconfig-$format
1185 status is somewhat redundant now that download and upload exist
1189 L<FS::Record>, schema.html from the base documentation.