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 I<no_close> - do not try to close batches
241 Supported format keys (defined in the specified FS::pay_batch module) are:
243 I<filetype> - required, can be CSV, fixed, variable, XML
245 I<fields> - required list of field names for each row/line
247 I<formatre> - regular expression for fixed filetype
249 I<parse> - required for variable filetype
251 I<xmlkeys> - required for XML filetype
253 I<xmlrow> - required for XML filetype
255 I<begin_condition> - sub, ignore all lines before this returns true
257 I<end_condition> - sub, stop processing lines when this returns true
259 I<end_hook> - sub, runs immediately after end_condition returns true
261 I<skip_condition> - sub, skip lines when this returns true
263 I<hook> - required, sub, runs before approved/declined conditions are checked
265 I<approved> - required, sub, returns true when approved
267 I<declined> - required, sub, returns true when declined
269 I<close_condition> - sub, decide whether or not to close the batch
276 my $param = ref($_[0]) ? shift : { @_ };
277 my $fh = $param->{'filehandle'};
278 my $job = $param->{'job'};
279 $job->update_statustext(0) if $job;
281 my $format = $param->{'format'};
282 my $info = $import_info{$format}
283 or die "unknown format $format";
285 my $conf = new FS::Conf;
287 my $filetype = $info->{'filetype'}; # CSV, fixed, variable
288 my @fields = @{ $info->{'fields'}};
289 my $formatre = $info->{'formatre'}; # for fixed
290 my $parse = $info->{'parse'}; # for variable
292 my $begin_condition = $info->{'begin_condition'};
293 my $end_condition = $info->{'end_condition'};
294 my $end_hook = $info->{'end_hook'};
295 my $skip_condition = $info->{'skip_condition'};
296 my $hook = $info->{'hook'};
297 my $approved_condition = $info->{'approved'};
298 my $declined_condition = $info->{'declined'};
299 my $close_condition = $info->{'close_condition'};
301 my %target_batches; # batches that had at least one payment updated
303 my $csv = new Text::CSV_XS;
305 local $SIG{HUP} = 'IGNORE';
306 local $SIG{INT} = 'IGNORE';
307 local $SIG{QUIT} = 'IGNORE';
308 local $SIG{TERM} = 'IGNORE';
309 local $SIG{TSTP} = 'IGNORE';
310 local $SIG{PIPE} = 'IGNORE';
312 my $oldAutoCommit = $FS::UID::AutoCommit;
313 local $FS::UID::AutoCommit = 0;
317 # if called on a specific pay_batch, check the status of that batch
319 my $reself = $self->select_for_update;
321 if ( $reself->status ne 'I'
322 and !$conf->exists('batch-manual_approval') ) {
323 $dbh->rollback if $oldAutoCommit;
324 return "batchnum ". $self->batchnum. "no longer in transit";
326 } # otherwise we can't enforce this constraint. sorry.
331 if ($filetype eq 'XML') {
332 eval "use XML::Simple";
334 my @xmlkeys = @{ $info->{'xmlkeys'} }; # for XML
335 my $xmlrow = $info->{'xmlrow'}; # also for XML
337 # Do everything differently.
338 my $data = XML::Simple::XMLin($fh, KeepRoot => 1);
340 # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
341 $rows = $rows->{$_} foreach( @$xmlrow );
342 if(!defined($rows)) {
343 $dbh->rollback if $oldAutoCommit;
344 return "can't find rows in XML file";
346 $rows = [ $rows ] if ref($rows) ne 'ARRAY';
347 foreach my $row (@$rows) {
348 push @all_values, [ @{$row}{@xmlkeys}, $row ];
352 while ( defined($line=<$fh>) ) {
354 next if $line =~ /^\s*$/; #skip blank lines
356 if ($filetype eq "CSV") {
357 $csv->parse($line) or do {
358 $dbh->rollback if $oldAutoCommit;
359 return "can't parse: ". $csv->error_input();
361 push @all_values, [ $csv->fields(), $line ];
362 }elsif ($filetype eq 'fixed'){
363 my @values = ( $line =~ /$formatre/ );
365 $dbh->rollback if $oldAutoCommit;
366 return "can't parse: ". $line;
369 push @all_values, \@values;
371 elsif ($filetype eq 'variable') {
373 my @values = ( eval { $parse->($self, $line) } );
375 $dbh->rollback if $oldAutoCommit;
379 push @all_values, \@values;
382 $dbh->rollback if $oldAutoCommit;
383 return "Unknown file type $filetype";
389 foreach (@all_values) {
392 $job->update_statustext(int(100 * $num/scalar(@all_values)));
397 my $line = pop @values;
398 foreach my $field ( @fields ) {
399 my $value = shift @values;
401 $hash{$field} = $value;
404 if ( defined($begin_condition) ) {
405 if ( &{$begin_condition}(\%hash, $line) ) {
406 undef $begin_condition;
413 if ( defined($end_condition) and &{$end_condition}(\%hash, $line) ) {
415 $error = &{$end_hook}(\%hash, $total, $line) if defined($end_hook);
417 $dbh->rollback if $oldAutoCommit;
423 if ( defined($skip_condition) and &{$skip_condition}(\%hash, $line) ) {
428 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
429 unless ( $cust_pay_batch ) {
430 return "unknown paybatchnum $hash{'paybatchnum'}\n";
432 # remember that we've touched this batch
433 $target_batches{ $cust_pay_batch->batchnum } = 1;
435 my $custnum = $cust_pay_batch->custnum,
436 my $payby = $cust_pay_batch->payby,
438 &{$hook}(\%hash, $cust_pay_batch->hashref);
440 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
443 if ( &{$approved_condition}(\%hash) ) {
445 foreach ('paid', '_date', 'payinfo') {
446 $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
448 $error = $new_cust_pay_batch->approve(%hash);
449 $total += $hash{'paid'};
451 } elsif ( &{$declined_condition}(\%hash) ) {
453 $error = $new_cust_pay_batch->decline($hash{'error_message'});;
458 $dbh->rollback if $oldAutoCommit;
462 # purge CVV when the batch is processed
463 if ( $payby =~ /^(CARD|DCRD)$/ ) {
464 my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
465 if ( ! grep { $_ eq cardtype($payinfo) }
466 $conf->config('cvv-save') ) {
467 $new_cust_pay_batch->cust_main->remove_cvv;
472 } # foreach (@all_values)
474 # decide whether to close batches that had payments posted
475 if ( !$param->{no_close} ) {
476 foreach my $batchnum (keys %target_batches) {
477 my $pay_batch = FS::pay_batch->by_key($batchnum);
479 if ( defined($close_condition) ) {
480 # Allow the module to decide whether to close the batch.
481 # $close_condition can also die() to abort the whole import.
482 $close = eval { $close_condition->($pay_batch) };
489 my $error = $pay_batch->set_status('R');
491 $dbh->rollback if $oldAutoCommit;
495 } # foreach $batchnum
496 } # if (!$param->{no_close})
498 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
506 sub process_import_results {
508 my $param = thaw(decode_base64(shift));
509 $param->{'job'} = $job;
510 warn Dumper($param) if $DEBUG;
511 my $gatewaynum = delete $param->{'gatewaynum'};
513 $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum)
514 or die "gatewaynum '$gatewaynum' not found\n";
515 delete $param->{'format'}; # to avoid confusion
518 my $file = $param->{'uploaded_files'} or die "no files provided\n";
519 $file =~ s/^(\w+):([\.\w]+)$/$2/;
520 my $dir = '%%%FREESIDE_CACHE%%%/cache.' . $FS::UID::datasrc;
521 open( $param->{'filehandle'},
524 or die "unable to open '$file'.\n";
527 if ( $param->{gateway} ) {
528 $error = FS::pay_batch->import_from_gateway(%$param);
530 my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
531 my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
532 $error = $batch->import_results($param);
535 die $error if $error;
538 =item import_from_gateway [ OPTIONS ]
540 Import results from a L<FS::payment_gateway>, using Business::BatchPayment,
541 and apply them. GATEWAY must use the Business::BatchPayment namespace.
543 This is a class method, since results can be applied to any batch.
544 The 'batch-reconsider' option determines whether an already-approved
545 or declined payment can have its status changed by a later import.
549 - gateway: the L<FS::payment_gateway>, required
550 - filehandle: a file name or handle to use as a data source.
551 - job: an L<FS::queue> object to update with progress messages.
555 sub import_from_gateway {
558 my $gateway = $opt{'gateway'};
559 my $conf = FS::Conf->new;
561 # unavoidable duplication with import_batch, for now
562 local $SIG{HUP} = 'IGNORE';
563 local $SIG{INT} = 'IGNORE';
564 local $SIG{QUIT} = 'IGNORE';
565 local $SIG{TERM} = 'IGNORE';
566 local $SIG{TSTP} = 'IGNORE';
567 local $SIG{PIPE} = 'IGNORE';
569 my $oldAutoCommit = $FS::UID::AutoCommit;
570 local $FS::UID::AutoCommit = 0;
573 my $job = delete($opt{'job'});
574 $job->update_statustext(0) if $job;
577 return "import_from_gateway requires a payment_gateway"
578 unless eval { $gateway->isa('FS::payment_gateway') };
581 'input' => $opt{'filehandle'}, # will do nothing if it's empty
582 # any other constructor options go here
586 my $mail_on_error = $conf->config('batch-errors_to');
587 if ( $mail_on_error ) {
588 # construct error trap
589 $proc_opt{'on_parse_error'} = sub {
590 my ($self, $line, $error) = @_;
591 push @item_errors, " '$line'\n$error";
595 my $processor = $gateway->batch_processor(%proc_opt);
597 my @processor_ids = map { $_->processor_id }
599 'table' => 'pay_batch',
600 'hashref' => { 'status' => 'I' },
601 'extra_sql' => q( AND processor_id != '' AND processor_id IS NOT NULL)
604 my @batches = $processor->receive(@processor_ids);
608 my $total_items = sum( map{$_->count} @batches);
610 # whether to allow items to change status
611 my $reconsider = $conf->exists('batch-reconsider');
613 # mutex all affected batches
614 my %pay_batch_for_update;
616 my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
618 BATCH: foreach my $batch (@batches) {
620 my %incoming_batch = (
625 ITEM: foreach my $item ($batch->elements) {
627 my $cust_pay_batch; # the new batch entry (with status)
628 my $pay_batch; # the freeside batch it belongs to
629 my $payby; # CARD or CHEK
632 my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module .
633 ':' . $item->authorization . ':' . $item->order_number;
635 if ( $batch->incoming ) {
636 # This is a one-way batch.
637 # Locate the customer, find an open batch correct for them,
638 # create a payment. Don't bother creating a cust_pay_batch
641 if ( defined($item->customer_id)
642 and $item->customer_id =~ /^\d+$/
643 and $item->customer_id > 0 ) {
645 $cust_main = FS::cust_main->by_key($item->customer_id)
646 || qsearchs('cust_main',
647 { 'agent_custid' => $item->customer_id }
650 push @item_errors, "Unknown customer_id ".$item->customer_id;
655 push @item_errors, "Illegal customer_id '".$item->customer_id."'";
658 # it may also make sense to allow selecting the customer by
659 # invoice_number, but no modules currently work that way
661 $payby = $bop2payby{ $item->payment_type };
663 $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
665 # create a batch if necessary
666 $pay_batch = $incoming_batch{$payby}->{$agentnum} ||=
668 status => 'R', # pre-resolve it
670 agentnum => $agentnum,
672 title => $batch->batch_id,
674 if ( !$pay_batch->batchnum ) {
675 $error = $pay_batch->insert;
676 die $error if $error; # can't do anything if this fails
679 if ( !$item->approved ) {
680 $error ||= "payment rejected - ".$item->error_message;
682 if ( !defined($item->amount) or $item->amount <= 0 ) {
683 $error ||= "no amount in item $num";
687 if ( $item->check_number ) {
688 $payby = 'BILL'; # right?
689 $payinfo = $item->check_number;
690 } elsif ( $item->assigned_token ) {
691 $payinfo = $item->assigned_token;
694 my $cust_pay = FS::cust_pay->new(
696 custnum => $cust_main->custnum,
697 _date => $item->payment_date->epoch,
698 paid => sprintf('%.2f',$item->amount),
700 invnum => $item->invoice_number,
701 batchnum => $pay_batch->batchnum,
703 gatewaynum => $gateway->gatewaynum,
704 processor => $gateway->gateway_module,
705 auth => $item->authorization,
706 order_number => $item->order_number,
709 $error ||= $cust_pay->insert;
710 eval { $cust_main->apply_payments };
714 push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
718 # This is a request/reply batch.
719 # Locate the request (the 'tid' attribute is the paybatchnum).
720 my $paybatchnum = $item->tid;
721 $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
722 if (!$cust_pay_batch) {
723 push @item_errors, "paybatchnum $paybatchnum not found";
726 $payby = $cust_pay_batch->payby;
728 my $batchnum = $cust_pay_batch->batchnum;
729 if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
730 warn "batch ID ".$batch->batch_id.
731 " does not match batchnum ".$cust_pay_batch->batchnum."\n";
734 # lock the batch and check its status
735 $pay_batch = FS::pay_batch->by_key($batchnum);
736 $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
737 if ( $pay_batch->status ne 'I' and !$reconsider ) {
738 $error = "batch $batchnum no longer in transit";
741 if ( $cust_pay_batch->status ) {
742 my $new_status = $item->approved ? 'approved' : 'declined';
743 if ( lc( $cust_pay_batch->status ) eq $new_status ) {
744 # already imported with this status, so don't touch
747 elsif ( !$reconsider ) {
748 # then we're not allowed to change its status, so bail out
749 $error = "paybatchnum ".$item->tid.
750 " already resolved with status '". $cust_pay_batch->status . "'";
755 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
760 # update payinfo, if needed
761 if ( $item->assigned_token ) {
762 $new_payinfo = $item->assigned_token;
763 } elsif ( $payby eq 'CARD' ) {
764 $new_payinfo = $item->card_number if $item->card_number;
765 } else { #$payby eq 'CHEK'
766 $new_payinfo = $item->account_number . '@' . $item->routing_code
767 if $item->account_number;
769 $cust_pay_batch->set('payinfo', $new_payinfo) if $new_payinfo;
771 # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
772 # paid, if the batch says it's different from the amount requested
773 if ( defined $item->amount ) {
774 $cust_pay_batch->set('paid', $item->amount);
776 $cust_pay_batch->set('paid', $cust_pay_batch->amount);
779 # set payment date to when it was processed
780 $cust_pay_batch->_date($item->payment_date->epoch)
781 if $item->payment_date;
784 if ( $item->approved ) {
785 # follow Billing_Realtime format for paybatch
786 $error = $cust_pay_batch->approve(
787 'gatewaynum' => $gateway->gatewaynum,
788 'processor' => $gateway->gateway_module,
789 'auth' => $item->authorization,
790 'order_number' => $item->order_number,
792 $total += $cust_pay_batch->paid;
795 $error = $cust_pay_batch->decline($item->error_message);
799 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
805 $job->update_statustext(int(100 * $num/( $total_items ) ),
806 'Importing batch items')
811 } #foreach $batch (input batch, not pay_batch)
813 # Format an error message
814 if ( @item_errors ) {
815 my $error_text = join("\n\n",
816 "Errors during batch import: ".scalar(@item_errors),
819 if ( $mail_on_error ) {
820 my $subject = "Batch import errors"; #?
821 my $body = "Import from gateway ".$gateway->label."\n".$error_text;
823 to => $mail_on_error,
824 from => $conf->invoice_from_full(),
830 $dbh->rollback if $oldAutoCommit;
835 # Auto-resolve (with brute-force error handling)
836 foreach my $pay_batch (values %pay_batch_for_update) {
837 my $error = $pay_batch->try_to_resolve;
840 $dbh->rollback if $oldAutoCommit;
845 $dbh->commit if $oldAutoCommit;
851 Resolve this batch if possible. A batch can be resolved if all of its
852 entries have status. If the system options 'batch-auto_resolve_days'
853 and 'batch-auto_resolve_status' are set, and the batch's download date is
854 at least (batch-auto_resolve_days) before the current time, then it can
855 be auto-resolved; entries with no status will be approved or declined
856 according to the batch-auto_resolve_status setting.
862 my $conf = FS::Conf->new;;
864 return if $self->status ne 'I';
866 my @unresolved = qsearch('cust_pay_batch',
868 batchnum => $self->batchnum,
873 if ( @unresolved and $conf->exists('batch-auto_resolve_days') ) {
874 my $days = $conf->config('batch-auto_resolve_days'); # can be zero
875 # either 'approve' or 'decline'
876 my $action = $conf->config('batch-auto_resolve_status') || '';
880 time > ($self->download + 86400 * $days)
884 foreach my $cpb (@unresolved) {
885 if ( $action eq 'approve' ) {
886 # approve it for the full amount
887 $cpb->set('paid', $cpb->amount) unless ($cpb->paid || 0) > 0;
888 $error = $cpb->approve($self->batchnum);
890 elsif ( $action eq 'decline' ) {
891 $error = $cpb->decline('No response from processor');
893 return $error if $error;
895 } elsif ( @unresolved ) {
896 # auto resolve is not enabled, and we're not ready to resolve
900 $self->set_status('R');
903 =item prepare_for_export
905 Prepare the batch to be exported. This will:
906 - Set the status to "in transit".
907 - If batch-increment_expiration is set and this is a credit card batch,
908 increment expiration dates that are in the past.
909 - If this is the first download for this batch, adjust payment amounts to
910 not be greater than the customer's current balance. If the customer's
911 balance is zero, the entry will be removed.
913 Use this within a transaction.
917 sub prepare_for_export {
919 my $conf = FS::Conf->new;
920 my $curuser = $FS::CurrentUser::CurrentUser;
923 my $status = $self->status;
924 if ($status eq 'O') {
926 my $error = $self->set_status('I');
927 return "error updating pay_batch status: $error\n" if $error;
928 } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
930 } elsif ($status eq 'R' &&
931 $curuser->access_right('Redownload resolved batches')) {
934 die "No pending batch.\n";
937 my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
938 $self->cust_pay_batch;
940 # handle batch-increment_expiration option
941 if ( $self->payby eq 'CARD' ) {
942 my ($cmon, $cyear) = (localtime(time))[4,5];
943 foreach (@cust_pay_batch) {
944 my $etime = str2time($_->exp) or next;
945 my ($day, $mon, $year) = (localtime($etime))[3,4,5];
946 if( $conf->exists('batch-increment_expiration') ) {
947 $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
948 $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
950 my $error = $_->replace;
951 return $error if $error;
955 if ($first_download) { #remove or reduce entries if customer's balance changed
957 foreach my $cust_pay_batch (@cust_pay_batch) {
959 my $balance = $cust_pay_batch->cust_main->balance;
960 if ($balance <= 0) { # then don't charge this customer
961 my $error = $cust_pay_batch->delete;
962 return $error if $error;
963 } elsif ($balance < $cust_pay_batch->amount) {
964 # reduce the charge to the remaining balance
965 $cust_pay_batch->amount($balance);
966 my $error = $cust_pay_batch->replace;
967 return $error if $error;
969 # else $balance >= $cust_pay_batch->amount
971 } #if $first_download
976 =item export_batch [ format => FORMAT | gateway => GATEWAY ]
978 Export batch for processing. FORMAT is the name of an L<FS::pay_batch>
979 module, in which case the configuration options are in 'batchconfig-FORMAT'.
981 Alternatively, GATEWAY can be an L<FS::payment_gateway> object set to a
982 L<Business::BatchPayment> module.
990 my $conf = new FS::Conf;
993 my $gateway = $opt{'gateway'};
995 # welcome to the future
996 my $fh = IO::Scalar->new(\$batch);
997 $self->export_to_gateway($gateway, 'file' => $fh);
1001 my $format = $opt{'format'} || $conf->config('batch-default_format')
1002 or die "No batch format configured\n";
1004 my $info = $export_info{$format} or die "Format not found: '$format'\n";
1006 &{$info->{'init'}}($conf, $self->agentnum) if exists($info->{'init'});
1008 my $oldAutoCommit = $FS::UID::AutoCommit;
1009 local $FS::UID::AutoCommit = 0;
1012 my $error = $self->prepare_for_export;
1014 die $error if $error;
1018 my @cust_pay_batch = $self->cust_pay_batch;
1020 my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
1022 my $h = $info->{'header'};
1023 if (ref($h) eq 'CODE') {
1024 $batch .= &$h($self, \@cust_pay_batch). $delim;
1026 $batch .= $h. $delim;
1029 foreach my $cust_pay_batch (@cust_pay_batch) {
1031 $batchtotal += $cust_pay_batch->amount;
1033 &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
1037 my $f = $info->{'footer'};
1038 if (ref($f) eq 'CODE') {
1039 $batch .= &$f($self, $batchcount, $batchtotal). $delim;
1041 $batch .= $f. $delim;
1044 if ($info->{'autopost'}) {
1045 my $error = &{$info->{'autopost'}}($self, $batch);
1047 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1052 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1056 =item export_to_gateway GATEWAY OPTIONS
1058 Given L<FS::payment_gateway> GATEWAY, export the items in this batch to
1059 that gateway via Business::BatchPayment. OPTIONS may include:
1061 - file: override the default transport and write to this file (name or handle)
1065 sub export_to_gateway {
1067 my ($self, $gateway, %opt) = @_;
1069 my $oldAutoCommit = $FS::UID::AutoCommit;
1070 local $FS::UID::AutoCommit = 0;
1073 my $error = $self->prepare_for_export;
1074 die $error if $error;
1077 'output' => $opt{'file'}, # will do nothing if it's empty
1078 # any other constructor options go here
1080 my $processor = $gateway->batch_processor(%proc_opt);
1082 my @items = map { $_->request_item } $self->cust_pay_batch;
1083 my $batch = Business::BatchPayment->create(Batch =>
1084 batch_id => $self->batchnum,
1087 $processor->submit($batch);
1089 if ($batch->processor_id) {
1090 $self->set('processor_id',$batch->processor_id);
1094 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1098 sub manual_approve {
1102 my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
1103 my $conf = FS::Conf->new;
1104 return 'manual batch approval disabled'
1105 if ( ! $conf->exists('batch-manual_approval') );
1106 return 'batch already resolved' if $self->status eq 'R';
1107 return 'batch not yet submitted' if $self->status eq 'O';
1109 local $SIG{HUP} = 'IGNORE';
1110 local $SIG{INT} = 'IGNORE';
1111 local $SIG{QUIT} = 'IGNORE';
1112 local $SIG{TERM} = 'IGNORE';
1113 local $SIG{TSTP} = 'IGNORE';
1114 local $SIG{PIPE} = 'IGNORE';
1116 my $oldAutoCommit = $FS::UID::AutoCommit;
1117 local $FS::UID::AutoCommit = 0;
1121 foreach my $cust_pay_batch (
1122 qsearch('cust_pay_batch', { batchnum => $self->batchnum,
1125 my $new_cust_pay_batch = new FS::cust_pay_batch {
1126 $cust_pay_batch->hash,
1127 'paid' => $cust_pay_batch->amount,
1129 'usernum' => $usernum,
1131 my $error = $new_cust_pay_batch->approve();
1132 # there are no approval options here (authorization, order_number, etc.)
1133 # because the transaction wasn't really approved
1136 return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
1140 $self->set_status('R');
1146 # Set up configuration for gateways that have a Business::BatchPayment
1149 eval "use Class::MOP;";
1151 warn "Moose/Class::MOP not available.\n$@\nSkipping pay_batch upgrade.\n";
1154 my $conf = FS::Conf->new;
1155 for my $format (keys %export_info) {
1156 my $mod = "FS::pay_batch::$format";
1157 if ( $mod->can('_upgrade_gateway')
1158 and $conf->exists("batchconfig-$format") ) {
1161 my ($module, %gw_options) = $mod->_upgrade_gateway;
1162 my $gateway = FS::payment_gateway->new({
1163 gateway_namespace => 'Business::BatchPayment',
1164 gateway_module => $module,
1166 my $error = $gateway->insert(%gw_options);
1168 warn "Failed to migrate '$format' to a Business::BatchPayment::$module gateway:\n$error\n";
1172 # test whether it loads
1173 my $processor = eval { $gateway->batch_processor };
1174 if ( !$processor ) {
1175 warn "Couldn't load Business::BatchPayment module for '$format'.\n";
1176 # if not, remove it so it doesn't hang around and break things
1180 # remove the batchconfig-*
1181 warn "Created Business::BatchPayment gateway '".$gateway->label.
1182 "' for '$format' batch processing.\n";
1183 $conf->delete("batchconfig-$format");
1185 # and if appropriate, make it the system default
1186 for my $payby (qw(CARD CHEK)) {
1187 if ( ($conf->config("batch-fixed_format-$payby") || '') eq $format ) {
1188 warn "Setting as default for $payby.\n";
1189 $conf->set("batch-gateway-$payby", $gateway->gatewaynum);
1190 $conf->delete("batch-fixed_format-$payby");
1194 } #if can('_upgrade_gateway') and batchconfig-$format
1204 status is somewhat redundant now that download and upload exist
1208 L<FS::Record>, schema.html from the base documentation.