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);
19 @ISA = qw(FS::Record);
23 FS::pay_batch - Object methods for pay_batch records
29 $record = new FS::pay_batch \%hash;
30 $record = new FS::pay_batch { 'column' => 'value' };
32 $error = $record->insert;
34 $error = $new_record->replace($old_record);
36 $error = $record->delete;
38 $error = $record->check;
42 An FS::pay_batch object represents an payment batch. FS::pay_batch inherits
43 from FS::Record. The following fields are currently supported:
47 =item batchnum - primary key
49 =item agentnum - optional agent number for agent batches
51 =item payby - CARD or CHEK
53 =item status - O (Open), I (In-transit), or R (Resolved)
55 =item download - time when the batch was first downloaded
57 =item upload - time when the batch was first uploaded
59 =item title - unique batch identifier
63 =item type - batch type payents (DEBIT), or refunds (CREDIT)
65 For incoming batches, the combination of 'title', 'payby', and 'agentnum'
76 Creates a new batch. To add the batch to the database, see L<"insert">.
78 Note that this stores the hash reference, not a distinct copy of the hash it
79 points to. You can ask the object for a copy with the I<hash> method.
83 # the new method can be inherited from FS::Record, if a table method is defined
85 sub table { 'pay_batch'; }
89 Adds this record to the database. If there is an error, returns the error,
90 otherwise returns false.
94 # the insert method can be inherited from FS::Record
98 Delete this record from the database.
102 # the delete method can be inherited from FS::Record
104 =item replace OLD_RECORD
106 Replaces the OLD_RECORD with this one in the database. If there is an error,
107 returns the error, otherwise returns false.
111 # the replace method can be inherited from FS::Record
115 Checks all fields to make sure this is a valid batch. If there is
116 an error, returns the error, otherwise returns false. Called by the insert
121 # the check method should currently be supplied - FS::Record contains some
122 # data checking routines
128 $self->ut_numbern('batchnum')
129 || $self->ut_enum('payby', [ 'CARD', 'CHEK' ])
130 || $self->ut_enum('status', [ 'O', 'I', 'R' ])
131 || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
132 || $self->ut_alphan('title')
134 return $error if $error;
136 if ( $self->title ) {
138 grep { !$self->batchnum or $_->batchnum != $self->batchnum }
139 qsearch('pay_batch', {
140 payby => $self->payby,
141 agentnum => $self->agentnum,
142 title => $self->title,
144 return "Batch already exists as batchnum ".$existing[0]->batchnum
153 Returns the L<FS::agent> object for this batch.
158 qsearchs('agent', { 'agentnum' => $_[0]->agentnum });
163 Returns all L<FS::cust_pay_batch> objects for this batch.
168 qsearch('cust_pay_batch', { 'batchnum' => $_[0]->batchnum });
185 $self->status(shift);
186 $self->download(time)
187 if $self->status eq 'I' && ! $self->download;
189 if $self->status eq 'R' && ! $self->upload;
193 # further false laziness
195 %import_info = %export_info = ();
196 foreach my $INC (@INC) {
197 warn "globbing $INC/FS/pay_batch/*.pm\n" if $DEBUG;
198 foreach my $file ( glob("$INC/FS/pay_batch/*.pm")) {
199 warn "attempting to load batch format from $file\n" if $DEBUG;
200 $file =~ /\/(\w+)\.pm$/;
203 my ($import, $export, $name) =
204 eval "use FS::pay_batch::$mod;
205 ( \\%FS::pay_batch::$mod\::import_info,
206 \\%FS::pay_batch::$mod\::export_info,
207 \$FS::pay_batch::$mod\::name)";
208 $name ||= $mod; # in case it's not defined
210 # in FS::cdr this is a die, not a warn. That's probably a bug.
211 warn "error using FS::pay_batch::$mod (skipping): $@\n";
214 if(!keys(%$import)) {
215 warn "no \%import_info found in FS::pay_batch::$mod (skipping)\n";
218 $import_info{$name} = $import;
220 if(!keys(%$export)) {
221 warn "no \%export_info found in FS::pay_batch::$mod (skipping)\n";
224 $export_info{$name} = $export;
229 =item import_results OPTION => VALUE, ...
231 Import batch results. Can be called as an instance method, if you want to
232 automatically adjust status on a specific batch, or a class method, if you
233 don't know which batch(es) the results apply to.
237 I<filehandle> - open filehandle of results file.
239 I<format> - an L<FS::pay_batch> module
241 I<gateway> - an L<FS::payment_gateway> object for a batch gateway. This
242 takes precedence over I<format>.
244 I<no_close> - do not try to close batches
246 Supported format keys (defined in the specified FS::pay_batch module) are:
248 I<filetype> - required, can be CSV, fixed, variable, XML
250 I<fields> - required list of field names for each row/line
252 I<formatre> - regular expression for fixed filetype
254 I<parse> - required for variable filetype
256 I<xmlkeys> - required for XML filetype
258 I<xmlrow> - required for XML filetype
260 I<begin_condition> - sub, ignore all lines before this returns true
262 I<end_condition> - sub, stop processing lines when this returns true
264 I<end_hook> - sub, runs immediately after end_condition returns true
266 I<skip_condition> - sub, skip lines when this returns true
268 I<hook> - required, sub, runs before approved/declined conditions are checked
270 I<approved> - required, sub, returns true when approved
272 I<declined> - required, sub, returns true when declined
274 I<close_condition> - sub, decide whether or not to close the batch
281 my $param = ref($_[0]) ? shift : { @_ };
282 my $fh = $param->{'filehandle'};
283 my $job = $param->{'job'};
284 $job->update_statustext(0) if $job;
286 my $format = $param->{'format'};
287 my $info = $import_info{$format}
288 or die "unknown format $format";
290 my $conf = new FS::Conf;
292 my $filetype = $info->{'filetype'}; # CSV, fixed, variable
293 my @fields = @{ $info->{'fields'}};
294 my $formatre = $info->{'formatre'}; # for fixed
295 my $parse = $info->{'parse'}; # for variable
297 my $begin_condition = $info->{'begin_condition'};
298 my $end_condition = $info->{'end_condition'};
299 my $end_hook = $info->{'end_hook'};
300 my $skip_condition = $info->{'skip_condition'};
301 my $hook = $info->{'hook'};
302 my $approved_condition = $info->{'approved'};
303 my $declined_condition = $info->{'declined'};
304 my $close_condition = $info->{'close_condition'};
306 my %target_batches; # batches that had at least one payment updated
308 my $csv = new Text::CSV_XS;
310 local $SIG{HUP} = 'IGNORE';
311 local $SIG{INT} = 'IGNORE';
312 local $SIG{QUIT} = 'IGNORE';
313 local $SIG{TERM} = 'IGNORE';
314 local $SIG{TSTP} = 'IGNORE';
315 local $SIG{PIPE} = 'IGNORE';
317 my $oldAutoCommit = $FS::UID::AutoCommit;
318 local $FS::UID::AutoCommit = 0;
322 # if called on a specific pay_batch, check the status of that batch
324 my $reself = $self->select_for_update;
326 if ( $reself->status ne 'I'
327 and !$conf->exists('batch-manual_approval') ) {
328 $dbh->rollback if $oldAutoCommit;
329 return "batchnum ". $self->batchnum. "no longer in transit";
331 } # otherwise we can't enforce this constraint. sorry.
336 if ($filetype eq 'XML') {
337 eval "use XML::Simple";
339 my @xmlkeys = @{ $info->{'xmlkeys'} }; # for XML
340 my $xmlrow = $info->{'xmlrow'}; # also for XML
342 # Do everything differently.
343 my $data = XML::Simple::XMLin($fh, KeepRoot => 1);
345 # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
346 $rows = $rows->{$_} foreach( @$xmlrow );
347 if(!defined($rows)) {
348 $dbh->rollback if $oldAutoCommit;
349 return "can't find rows in XML file";
351 $rows = [ $rows ] if ref($rows) ne 'ARRAY';
352 foreach my $row (@$rows) {
353 push @all_values, [ @{$row}{@xmlkeys}, $row ];
357 while ( defined($line=<$fh>) ) {
359 next if $line =~ /^\s*$/; #skip blank lines
361 if ($filetype eq "CSV") {
362 $csv->parse($line) or do {
363 $dbh->rollback if $oldAutoCommit;
364 return "can't parse: ". $csv->error_input();
366 push @all_values, [ $csv->fields(), $line ];
367 }elsif ($filetype eq 'fixed'){
368 my @values = ( $line =~ /$formatre/ );
370 $dbh->rollback if $oldAutoCommit;
371 return "can't parse: ". $line;
374 push @all_values, \@values;
376 elsif ($filetype eq 'variable') {
378 my @values = ( eval { $parse->($self, $line) } );
380 $dbh->rollback if $oldAutoCommit;
384 push @all_values, \@values;
387 $dbh->rollback if $oldAutoCommit;
388 return "Unknown file type $filetype";
394 foreach (@all_values) {
397 $job->update_statustext(int(100 * $num/scalar(@all_values)));
402 my $line = pop @values;
403 foreach my $field ( @fields ) {
404 my $value = shift @values;
406 $hash{$field} = $value;
409 if ( defined($begin_condition) ) {
410 if ( &{$begin_condition}(\%hash, $line) ) {
411 undef $begin_condition;
418 if ( defined($end_condition) and &{$end_condition}(\%hash, $line) ) {
420 $error = &{$end_hook}(\%hash, $total, $line) if defined($end_hook);
422 $dbh->rollback if $oldAutoCommit;
428 if ( defined($skip_condition) and &{$skip_condition}(\%hash, $line) ) {
433 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
434 unless ( $cust_pay_batch ) {
435 return "unknown paybatchnum $hash{'paybatchnum'}\n";
437 # remember that we've touched this batch
438 $target_batches{ $cust_pay_batch->batchnum } = 1;
440 my $custnum = $cust_pay_batch->custnum,
441 my $payby = $cust_pay_batch->payby,
443 &{$hook}(\%hash, $cust_pay_batch->hashref);
445 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
448 if ( &{$approved_condition}(\%hash) ) {
450 foreach ('paid', '_date', 'payinfo') {
451 $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
453 $error = $new_cust_pay_batch->approve(%hash);
454 $total += $hash{'paid'};
456 } elsif ( &{$declined_condition}(\%hash) ) {
458 $error = $new_cust_pay_batch->decline($hash{'error_message'});;
463 $dbh->rollback if $oldAutoCommit;
467 # purge CVV when the batch is processed
468 if ( $payby =~ /^(CARD|DCRD)$/ ) {
469 my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
470 if ( ! grep { $_ eq cardtype($payinfo) }
471 $conf->config('cvv-save') ) {
472 $new_cust_pay_batch->cust_main->remove_cvv;
477 } # foreach (@all_values)
479 # decide whether to close batches that had payments posted
480 if ( !$param->{no_close} ) {
481 foreach my $batchnum (keys %target_batches) {
482 my $pay_batch = FS::pay_batch->by_key($batchnum);
484 if ( defined($close_condition) ) {
485 # Allow the module to decide whether to close the batch.
486 # $close_condition can also die() to abort the whole import.
487 $close = eval { $close_condition->($pay_batch) };
494 my $error = $pay_batch->set_status('R');
496 $dbh->rollback if $oldAutoCommit;
500 } # foreach $batchnum
501 } # if (!$param->{no_close})
503 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
511 sub process_import_results {
513 my $param = thaw(decode_base64(shift));
514 $param->{'job'} = $job;
515 warn Dumper($param) if $DEBUG;
516 my $gatewaynum = delete $param->{'gatewaynum'};
518 $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum)
519 or die "gatewaynum '$gatewaynum' not found\n";
520 delete $param->{'format'}; # to avoid confusion
523 my $file = $param->{'uploaded_files'} or die "no files provided\n";
524 $file =~ s/^(\w+):([\.\w]+)$/$2/;
525 my $dir = '%%%FREESIDE_CACHE%%%/cache.' . $FS::UID::datasrc;
526 open( $param->{'filehandle'},
529 or die "unable to open '$file'.\n";
532 if ( $param->{gateway} ) {
533 $error = FS::pay_batch->import_from_gateway(%$param);
535 my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
536 my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
537 $error = $batch->import_results($param);
540 die $error if $error;
543 =item import_from_gateway [ OPTIONS ]
545 Import results from a L<FS::payment_gateway>, using Business::BatchPayment,
546 and apply them. GATEWAY must use the Business::BatchPayment namespace.
548 This is a class method, since results can be applied to any batch.
549 The 'batch-reconsider' option determines whether an already-approved
550 or declined payment can have its status changed by a later import.
554 - gateway: the L<FS::payment_gateway>, required
555 - filehandle: a file name or handle to use as a data source.
556 - job: an L<FS::queue> object to update with progress messages.
560 sub import_from_gateway {
563 my $gateway = $opt{'gateway'};
564 my $conf = FS::Conf->new;
566 # unavoidable duplication with import_batch, for now
567 local $SIG{HUP} = 'IGNORE';
568 local $SIG{INT} = 'IGNORE';
569 local $SIG{QUIT} = 'IGNORE';
570 local $SIG{TERM} = 'IGNORE';
571 local $SIG{TSTP} = 'IGNORE';
572 local $SIG{PIPE} = 'IGNORE';
574 my $oldAutoCommit = $FS::UID::AutoCommit;
575 local $FS::UID::AutoCommit = 0;
578 my $job = delete($opt{'job'});
579 $job->update_statustext(0) if $job;
582 return "import_from_gateway requires a payment_gateway"
583 unless eval { $gateway->isa('FS::payment_gateway') };
586 'input' => $opt{'filehandle'}, # will do nothing if it's empty
587 # any other constructor options go here
591 my $mail_on_error = $conf->config('batch-errors_to');
592 if ( $mail_on_error ) {
593 # construct error trap
594 $proc_opt{'on_parse_error'} = sub {
595 my ($self, $line, $error) = @_;
596 push @item_errors, " '$line'\n$error";
600 my $processor = $gateway->batch_processor(%proc_opt);
602 my @processor_ids = map { $_->processor_id }
604 'table' => 'pay_batch',
605 'hashref' => { 'status' => 'I' },
606 'extra_sql' => q( AND processor_id != '' AND processor_id IS NOT NULL)
609 my @batches = $processor->receive(@processor_ids);
613 my $total_items = sum( map{$_->count} @batches);
615 # whether to allow items to change status
616 my $reconsider = $conf->exists('batch-reconsider');
618 # mutex all affected batches
619 my %pay_batch_for_update;
621 my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK');
623 BATCH: foreach my $batch (@batches) {
625 my %incoming_batch = (
630 ITEM: foreach my $item ($batch->elements) {
632 my $cust_pay_batch; # the new batch entry (with status)
633 my $pay_batch; # the freeside batch it belongs to
634 my $payby; # CARD or CHEK
637 my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module .
638 ':' . $item->authorization . ':' . $item->order_number;
640 if ( $batch->incoming ) {
641 # This is a one-way batch.
642 # Locate the customer, find an open batch correct for them,
643 # create a payment. Don't bother creating a cust_pay_batch
646 if ( defined($item->customer_id)
647 and $item->customer_id =~ /^\d+$/
648 and $item->customer_id > 0 ) {
650 $cust_main = FS::cust_main->by_key($item->customer_id)
651 || qsearchs('cust_main',
652 { 'agent_custid' => $item->customer_id }
655 push @item_errors, "Unknown customer_id ".$item->customer_id;
660 push @item_errors, "Illegal customer_id '".$item->customer_id."'";
663 # it may also make sense to allow selecting the customer by
664 # invoice_number, but no modules currently work that way
666 $payby = $bop2payby{ $item->payment_type };
668 $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent');
670 # create a batch if necessary
671 $pay_batch = $incoming_batch{$payby}->{$agentnum} ||=
673 status => 'R', # pre-resolve it
675 agentnum => $agentnum,
677 title => $batch->batch_id,
679 if ( !$pay_batch->batchnum ) {
680 $error = $pay_batch->insert;
681 die $error if $error; # can't do anything if this fails
684 if ( !$item->approved ) {
685 $error ||= "payment rejected - ".$item->error_message;
687 if ( !defined($item->amount) or $item->amount <= 0 ) {
688 $error ||= "no amount in item $num";
692 if ( $item->check_number ) {
693 $payby = 'BILL'; # right?
694 $payinfo = $item->check_number;
695 } elsif ( $item->assigned_token ) {
696 $payinfo = $item->assigned_token;
699 my $cust_pay = FS::cust_pay->new(
701 custnum => $cust_main->custnum,
702 _date => $item->payment_date->epoch,
703 paid => sprintf('%.2f',$item->amount),
705 invnum => $item->invoice_number,
706 batchnum => $pay_batch->batchnum,
708 gatewaynum => $gateway->gatewaynum,
709 processor => $gateway->gateway_module,
710 auth => $item->authorization,
711 order_number => $item->order_number,
714 $error ||= $cust_pay->insert;
715 eval { $cust_main->apply_payments };
719 push @item_errors, 'Payment for customer '.$item->customer_id."\n$error";
723 # This is a request/reply batch.
724 # Locate the request (the 'tid' attribute is the paybatchnum).
725 my $paybatchnum = $item->tid;
726 $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum);
727 if (!$cust_pay_batch) {
728 push @item_errors, "paybatchnum $paybatchnum not found";
731 $payby = $cust_pay_batch->payby;
733 my $batchnum = $cust_pay_batch->batchnum;
734 if ( $batch->batch_id and $batch->batch_id != $batchnum ) {
735 warn "batch ID ".$batch->batch_id.
736 " does not match batchnum ".$cust_pay_batch->batchnum."\n";
739 # lock the batch and check its status
740 $pay_batch = FS::pay_batch->by_key($batchnum);
741 $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update;
742 if ( $pay_batch->status ne 'I' and !$reconsider ) {
743 $error = "batch $batchnum no longer in transit";
746 if ( $cust_pay_batch->status ) {
747 my $new_status = $item->approved ? 'approved' : 'declined';
748 if ( lc( $cust_pay_batch->status ) eq $new_status ) {
749 # already imported with this status, so don't touch
752 elsif ( !$reconsider ) {
753 # then we're not allowed to change its status, so bail out
754 $error = "paybatchnum ".$item->tid.
755 " already resolved with status '". $cust_pay_batch->status . "'";
760 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
765 # update payinfo, if needed
766 if ( $item->assigned_token ) {
767 $new_payinfo = $item->assigned_token;
768 } elsif ( $payby eq 'CARD' ) {
769 $new_payinfo = $item->card_number if $item->card_number;
770 } else { #$payby eq 'CHEK'
771 $new_payinfo = $item->account_number . '@' . $item->routing_code
772 if $item->account_number;
774 $cust_pay_batch->set('payinfo', $new_payinfo) if $new_payinfo;
776 # set "paid" pseudo-field (transfers to cust_pay) to the actual amount
777 # paid, if the batch says it's different from the amount requested
778 if ( defined $item->amount ) {
779 $cust_pay_batch->set('paid', $item->amount);
781 $cust_pay_batch->set('paid', $cust_pay_batch->amount);
784 # set payment date to when it was processed
785 $cust_pay_batch->_date($item->payment_date->epoch)
786 if $item->payment_date;
789 if ( $item->approved ) {
790 # follow Billing_Realtime format for paybatch
791 $error = $cust_pay_batch->approve(
792 'gatewaynum' => $gateway->gatewaynum,
793 'processor' => $gateway->gateway_module,
794 'auth' => $item->authorization,
795 'order_number' => $item->order_number,
797 $total += $cust_pay_batch->paid;
800 $error = $cust_pay_batch->decline($item->error_message);
804 push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error";
810 $job->update_statustext(int(100 * $num/( $total_items ) ),
811 'Importing batch items')
816 } #foreach $batch (input batch, not pay_batch)
818 # Format an error message
819 if ( @item_errors ) {
820 my $error_text = join("\n\n",
821 "Errors during batch import: ".scalar(@item_errors),
824 if ( $mail_on_error ) {
825 my $subject = "Batch import errors"; #?
826 my $body = "Import from gateway ".$gateway->label."\n".$error_text;
828 to => $mail_on_error,
829 from => $conf->invoice_from_full(),
835 $dbh->rollback if $oldAutoCommit;
840 # Auto-resolve (with brute-force error handling)
841 foreach my $pay_batch (values %pay_batch_for_update) {
842 my $error = $pay_batch->try_to_resolve;
845 $dbh->rollback if $oldAutoCommit;
850 $dbh->commit if $oldAutoCommit;
856 Resolve this batch if possible. A batch can be resolved if all of its
857 entries have status. If the system options 'batch-auto_resolve_days'
858 and 'batch-auto_resolve_status' are set, and the batch's download date is
859 at least (batch-auto_resolve_days) before the current time, then it can
860 be auto-resolved; entries with no status will be approved or declined
861 according to the batch-auto_resolve_status setting.
867 my $conf = FS::Conf->new;;
869 return if $self->status ne 'I';
871 my @unresolved = qsearch('cust_pay_batch',
873 batchnum => $self->batchnum,
878 if ( @unresolved and $conf->exists('batch-auto_resolve_days') ) {
879 my $days = $conf->config('batch-auto_resolve_days'); # can be zero
880 # either 'approve' or 'decline'
881 my $action = $conf->config('batch-auto_resolve_status') || '';
885 time > ($self->download + 86400 * $days)
889 foreach my $cpb (@unresolved) {
890 if ( $action eq 'approve' ) {
891 # approve it for the full amount
892 $cpb->set('paid', $cpb->amount) unless ($cpb->paid || 0) > 0;
893 $error = $cpb->approve($self->batchnum);
895 elsif ( $action eq 'decline' ) {
896 $error = $cpb->decline('No response from processor');
898 return $error if $error;
900 } elsif ( @unresolved ) {
901 # auto resolve is not enabled, and we're not ready to resolve
905 $self->set_status('R');
908 =item prepare_for_export
910 Prepare the batch to be exported. This will:
911 - Set the status to "in transit".
912 - If batch-increment_expiration is set and this is a credit card batch,
913 increment expiration dates that are in the past.
914 - If this is the first download for this batch, adjust payment amounts to
915 not be greater than the customer's current balance. If the customer's
916 balance is zero, the entry will be removed.
918 Use this within a transaction.
922 sub prepare_for_export {
924 my $conf = FS::Conf->new;
925 my $curuser = $FS::CurrentUser::CurrentUser;
928 my $status = $self->status;
929 if ($status eq 'O') {
931 my $error = $self->set_status('I');
932 return "error updating pay_batch status: $error\n" if $error;
933 } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
935 } elsif ($status eq 'R' &&
936 $curuser->access_right('Redownload resolved batches')) {
939 die "No pending batch.\n";
942 my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
943 $self->cust_pay_batch;
945 # handle batch-increment_expiration option
946 if ( $self->payby eq 'CARD' ) {
947 my ($cmon, $cyear) = (localtime(time))[4,5];
948 foreach (@cust_pay_batch) {
949 my $etime = str2time($_->exp) or next;
950 my ($day, $mon, $year) = (localtime($etime))[3,4,5];
951 if( $conf->exists('batch-increment_expiration') ) {
952 $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
953 $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
955 my $error = $_->replace;
956 return $error if $error;
960 if ($first_download) { #remove or reduce entries if customer's balance changed
962 foreach my $cust_pay_batch (@cust_pay_batch) {
964 my $balance = $cust_pay_batch->cust_main->balance;
965 if ($balance <= 0) { # then don't charge this customer
966 my $error = $cust_pay_batch->delete;
967 return $error if $error;
968 } elsif ($balance < $cust_pay_batch->amount) {
969 # reduce the charge to the remaining balance
970 $cust_pay_batch->amount($balance);
971 my $error = $cust_pay_batch->replace;
972 return $error if $error;
974 # else $balance >= $cust_pay_batch->amount
976 } #if $first_download
981 =item export_batch [ format => FORMAT | gateway => GATEWAY ]
983 Export batch for processing. FORMAT is the name of an L<FS::pay_batch>
984 module, in which case the configuration options are in 'batchconfig-FORMAT'.
986 Alternatively, GATEWAY can be an L<FS::payment_gateway> object set to a
987 L<Business::BatchPayment> module.
995 my $conf = new FS::Conf;
998 my $gateway = $opt{'gateway'};
1000 # welcome to the future
1001 my $fh = IO::Scalar->new(\$batch);
1002 $self->export_to_gateway($gateway, 'file' => $fh);
1006 my $format = $opt{'format'} || $conf->config('batch-default_format')
1007 or die "No batch format configured\n";
1009 my $info = $export_info{$format} or die "Format not found: '$format'\n";
1011 &{$info->{'init'}}($conf, $self->agentnum) if exists($info->{'init'});
1013 my $oldAutoCommit = $FS::UID::AutoCommit;
1014 local $FS::UID::AutoCommit = 0;
1017 my $error = $self->prepare_for_export;
1019 die $error if $error;
1023 my @cust_pay_batch = $self->cust_pay_batch;
1025 my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
1027 my $h = $info->{'header'};
1028 if (ref($h) eq 'CODE') {
1029 $batch .= &$h($self, \@cust_pay_batch). $delim;
1031 $batch .= $h. $delim;
1034 foreach my $cust_pay_batch (@cust_pay_batch) {
1036 $batchtotal += $cust_pay_batch->amount;
1038 &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
1042 my $f = $info->{'footer'};
1043 if (ref($f) eq 'CODE') {
1044 $batch .= &$f($self, $batchcount, $batchtotal). $delim;
1046 $batch .= $f. $delim;
1049 if ($info->{'autopost'}) {
1050 my $error = &{$info->{'autopost'}}($self, $batch);
1052 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1057 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1061 =item export_to_gateway GATEWAY OPTIONS
1063 Given L<FS::payment_gateway> GATEWAY, export the items in this batch to
1064 that gateway via Business::BatchPayment. OPTIONS may include:
1066 - file: override the default transport and write to this file (name or handle)
1070 sub export_to_gateway {
1072 my ($self, $gateway, %opt) = @_;
1074 my $oldAutoCommit = $FS::UID::AutoCommit;
1075 local $FS::UID::AutoCommit = 0;
1078 my $error = $self->prepare_for_export;
1079 die $error if $error;
1082 'output' => $opt{'file'}, # will do nothing if it's empty
1083 # any other constructor options go here
1085 my $processor = $gateway->batch_processor(%proc_opt);
1087 my @items = map { $_->request_item } $self->cust_pay_batch;
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->rollback if $oldAutoCommit;
1104 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1108 sub manual_approve {
1112 my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
1113 my $conf = FS::Conf->new;
1114 return 'manual batch approval disabled'
1115 if ( ! $conf->exists('batch-manual_approval') );
1116 return 'batch already resolved' if $self->status eq 'R';
1117 return 'batch not yet submitted' if $self->status eq 'O';
1119 local $SIG{HUP} = 'IGNORE';
1120 local $SIG{INT} = 'IGNORE';
1121 local $SIG{QUIT} = 'IGNORE';
1122 local $SIG{TERM} = 'IGNORE';
1123 local $SIG{TSTP} = 'IGNORE';
1124 local $SIG{PIPE} = 'IGNORE';
1126 my $oldAutoCommit = $FS::UID::AutoCommit;
1127 local $FS::UID::AutoCommit = 0;
1131 foreach my $cust_pay_batch (
1132 qsearch('cust_pay_batch', { batchnum => $self->batchnum,
1135 my $new_cust_pay_batch = new FS::cust_pay_batch {
1136 $cust_pay_batch->hash,
1137 'paid' => $cust_pay_batch->amount,
1139 'usernum' => $usernum,
1141 my $error = $new_cust_pay_batch->approve();
1142 # there are no approval options here (authorization, order_number, etc.)
1143 # because the transaction wasn't really approved
1146 return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
1150 $self->set_status('R');
1155 =item batch_download_formats
1157 returns a hash of batch download formats.
1159 my %download_formats = FS::pay_batch::batch_download_formats;
1163 sub batch_download_formats {
1167 'Default batch mode',
1170 'csv-td_canada_trust-merchant_pc_batch' =>
1171 'CSV file for TD Canada Trust Merchant PC Batch',
1172 'csv-chase_canada-E-xactBatch' =>
1173 'CSV file for Chase Canada E-xactBatch',
1175 '80 byte file for TD Canada Trust PAP Batch',
1177 'Bank of Montreal ECA batch',
1179 'Spiritone ACH batch',
1181 'XML file for Chase Paymentech',
1183 'Royal Bank of Canada PDS batch',
1185 '1464 byte file for TD Commercial Banking EFT',
1187 'EFT Canada CSV batch',
1189 '80 byte file for Canadian Imperial Bank of Commerce',
1190 # insert new batch formats here
1195 =item batch_download_formats
1197 returns a hash of batch download formats.
1199 my %download_formats = FS::pay_batch::batch_download_formats;
1203 sub can_handle_electronic_refunds {
1207 my $conf = new FS::Conf;
1209 tie my %download_formats, 'Tie::IxHash', batch_download_formats;
1211 my %paybatch_mods = (
1213 'csv-td_canada_trust-merchant_pc_batch' => 'td_canada_trust',
1214 'csv-chase_canada-E-xactBatch' => 'chase-canada',
1217 'ach-spiritone' => 'ach_spiritone',
1218 'paymentech' => 'paymentech',
1220 'td_eft1464' => 'td_eft1464',
1221 'eft_canada' => 'eft_canada',
1225 %download_formats = ( $format => $download_formats{$format}, ) if $format;
1227 foreach my $key (keys %download_formats) {
1228 my $mod = "FS::pay_batch::".$paybatch_mods{$key};
1229 if ($mod->can('can_handle_credits')) {
1230 return '1' if $conf->exists('batchconfig-'.$key);
1238 use FS::upgrade_journal;
1241 # check if there are any pending batch refunds and no download format configured
1242 # that allows electronic refunds.
1243 unless ( FS::upgrade_journal->is_done('removed_refunds_nodownload_format') ) {
1245 ## get a list of all refunds in batches.
1246 my $extrasql = " LEFT JOIN pay_batch USING ( batchnum ) WHERE cust_pay_batch.paycode = 'C' AND pay_batch.download IS NULL AND pay_batch.type = 'DEBIT' ";
1248 my @batch_refunds = qsearch({
1249 'table' => 'cust_pay_batch',
1250 'select' => 'cust_pay_batch.*',
1251 'extra_sql' => $extrasql,
1256 if (@batch_refunds) {
1257 warn "found ".scalar @batch_refunds." batch refunds.\n";
1258 warn "Searching for their cust refunds...\n" if (scalar @batch_refunds > 0);
1260 my $oldAutoCommit = $FS::UID::AutoCommit;
1261 local $FS::UID::AutoCommit = 0;
1264 ## move refund to credit batch.
1265 foreach my $cust_pay_batch (@batch_refunds) {
1266 my $payby = $cust_pay_batch->payby eq "CARD" ? "CARD" : "CHEK";
1274 my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
1276 unless ( $pay_batch ) {
1277 $pay_batch = new FS::pay_batch \%pay_batch;
1278 my $error = $pay_batch->insert;
1280 $dbh->rollback if $oldAutoCommit;
1281 warn "error creating a $payby credit batch: $error\n";
1285 $cust_pay_batch->batchnum($pay_batch->batchnum);
1286 $replace_error = $cust_pay_batch->replace();
1287 if ( $replace_error ) {
1288 $dbh->rollback if $oldAutoCommit;
1289 warn "Unable to move credit to a credit batch: $replace_error";
1292 warn "Moved cust pay credit ".$cust_pay_batch->paybatchnum." to ".$cust_pay_batch->payby." credit batch ".$cust_pay_batch->batchnum."\n";
1295 } #end @batch_refunds
1296 else { warn "No batch refunds found\n"; }
1298 FS::upgrade_journal->set_done('removed_refunds_nodownload_format') unless $replace_error;
1301 # Set up configuration for gateways that have a Business::BatchPayment
1304 eval "use Class::MOP;";
1306 warn "Moose/Class::MOP not available.\n$@\nSkipping pay_batch upgrade.\n";
1309 my $conf = FS::Conf->new;
1310 for my $format (keys %export_info) {
1311 my $mod = "FS::pay_batch::$format";
1312 if ( $mod->can('_upgrade_gateway')
1313 and $conf->exists("batchconfig-$format") ) {
1316 my ($module, %gw_options) = $mod->_upgrade_gateway;
1317 my $gateway = FS::payment_gateway->new({
1318 gateway_namespace => 'Business::BatchPayment',
1319 gateway_module => $module,
1321 my $error = $gateway->insert(%gw_options);
1323 warn "Failed to migrate '$format' to a Business::BatchPayment::$module gateway:\n$error\n";
1327 # test whether it loads
1328 my $processor = eval { $gateway->batch_processor };
1329 if ( !$processor ) {
1330 warn "Couldn't load Business::BatchPayment module for '$format'.\n";
1331 # if not, remove it so it doesn't hang around and break things
1335 # remove the batchconfig-*
1336 warn "Created Business::BatchPayment gateway '".$gateway->label.
1337 "' for '$format' batch processing.\n";
1338 $conf->delete("batchconfig-$format");
1340 # and if appropriate, make it the system default
1341 for my $payby (qw(CARD CHEK)) {
1342 if ( ($conf->config("batch-fixed_format-$payby") || '') eq $format ) {
1343 warn "Setting as default for $payby.\n";
1344 $conf->set("batch-gateway-$payby", $gateway->gatewaynum);
1345 $conf->delete("batch-fixed_format-$payby");
1349 } #if can('_upgrade_gateway') and batchconfig-$format
1359 status is somewhat redundant now that download and upload exist
1363 L<FS::Record>, schema.html from the base documentation.