agent-virt batches and batchconfig-eftcanada config (argh!), RT#14859
[freeside.git] / FS / FS / pay_batch.pm
1 package FS::pay_batch;
2
3 use strict;
4 use vars qw( @ISA $DEBUG %import_info %export_info $conf );
5 use Time::Local;
6 use Text::CSV_XS;
7 use FS::Record qw( dbh qsearch qsearchs );
8 use FS::Conf;
9 use FS::cust_pay;
10 use FS::agent;
11 use Date::Parse qw(str2time);
12 use Business::CreditCard qw(cardtype);
13
14 @ISA = qw(FS::Record);
15
16 =head1 NAME
17
18 FS::pay_batch - Object methods for pay_batch records
19
20 =head1 SYNOPSIS
21
22   use FS::pay_batch;
23
24   $record = new FS::pay_batch \%hash;
25   $record = new FS::pay_batch { 'column' => 'value' };
26
27   $error = $record->insert;
28
29   $error = $new_record->replace($old_record);
30
31   $error = $record->delete;
32
33   $error = $record->check;
34
35 =head1 DESCRIPTION
36
37 An FS::pay_batch object represents an payment batch.  FS::pay_batch inherits
38 from FS::Record.  The following fields are currently supported:
39
40 =over 4
41
42 =item batchnum - primary key
43
44 =item agentnum - optional agent number for agent batches
45
46 =item payby - CARD or CHEK
47
48 =item status - O (Open), I (In-transit), or R (Resolved)
49
50 =item download - 
51
52 =item upload - 
53
54
55 =back
56
57 =head1 METHODS
58
59 =over 4
60
61 =item new HASHREF
62
63 Creates a new batch.  To add the batch to the database, see L<"insert">.
64
65 Note that this stores the hash reference, not a distinct copy of the hash it
66 points to.  You can ask the object for a copy with the I<hash> method.
67
68 =cut
69
70 # the new method can be inherited from FS::Record, if a table method is defined
71
72 sub table { 'pay_batch'; }
73
74 =item insert
75
76 Adds this record to the database.  If there is an error, returns the error,
77 otherwise returns false.
78
79 =cut
80
81 # the insert method can be inherited from FS::Record
82
83 =item delete
84
85 Delete this record from the database.
86
87 =cut
88
89 # the delete method can be inherited from FS::Record
90
91 =item replace OLD_RECORD
92
93 Replaces the OLD_RECORD with this one in the database.  If there is an error,
94 returns the error, otherwise returns false.
95
96 =cut
97
98 # the replace method can be inherited from FS::Record
99
100 =item check
101
102 Checks all fields to make sure this is a valid batch.  If there is
103 an error, returns the error, otherwise returns false.  Called by the insert
104 and replace methods.
105
106 =cut
107
108 # the check method should currently be supplied - FS::Record contains some
109 # data checking routines
110
111 sub check {
112   my $self = shift;
113
114   my $error = 
115     $self->ut_numbern('batchnum')
116     || $self->ut_enum('payby', [ 'CARD', 'CHEK' ])
117     || $self->ut_enum('status', [ 'O', 'I', 'R' ])
118     || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
119   ;
120   return $error if $error;
121
122   $self->SUPER::check;
123 }
124
125 =item agent
126
127 Returns the L<FS::agent> object for this template.
128
129 =cut
130
131 sub agent {
132   qsearchs('agent', { 'agentnum' => $_[0]->agentnum });
133 }
134
135 =item rebalance
136
137 =cut
138
139 sub rebalance {
140   my $self = shift;
141 }
142
143 =item set_status 
144
145 =cut
146
147 sub set_status {
148   my $self = shift;
149   $self->status(shift);
150   $self->download(time)
151     if $self->status eq 'I' && ! $self->download;
152   $self->upload(time)
153     if $self->status eq 'R' && ! $self->upload;
154   $self->replace();
155 }
156
157 # further false laziness
158
159 %import_info = %export_info = ();
160 foreach my $INC (@INC) {
161   warn "globbing $INC/FS/pay_batch/*.pm\n" if $DEBUG;
162   foreach my $file ( glob("$INC/FS/pay_batch/*.pm")) {
163     warn "attempting to load batch format from $file\n" if $DEBUG;
164     $file =~ /\/(\w+)\.pm$/;
165     next if !$1;
166     my $mod = $1;
167     my ($import, $export, $name) = 
168       eval "use FS::pay_batch::$mod; 
169            ( \\%FS::pay_batch::$mod\::import_info,
170              \\%FS::pay_batch::$mod\::export_info,
171              \$FS::pay_batch::$mod\::name)";
172     $name ||= $mod; # in case it's not defined
173     if( $@) {
174       # in FS::cdr this is a die, not a warn.  That's probably a bug.
175       warn "error using FS::pay_batch::$mod (skipping): $@\n";
176       next;
177     }
178     if(!keys(%$import)) {
179       warn "no \%import_info found in FS::pay_batch::$mod (skipping)\n";
180     }
181     else {
182       $import_info{$name} = $import;
183     }
184     if(!keys(%$export)) {
185       warn "no \%export_info found in FS::pay_batch::$mod (skipping)\n";
186     }
187     else {
188       $export_info{$name} = $export;
189     }
190   }
191 }
192
193 =item import_results OPTION => VALUE, ...
194
195 Import batch results.
196
197 Options are:
198
199 I<filehandle> - open filehandle of results file.
200
201 I<format> - "csv-td_canada_trust-merchant_pc_batch", "csv-chase_canada-E-xactBatch", "ach-spiritone", or "PAP"
202
203 =cut
204
205 sub import_results {
206   my $self = shift;
207
208   my $param = ref($_[0]) ? shift : { @_ };
209   my $fh = $param->{'filehandle'};
210   my $format = $param->{'format'};
211   my $info = $import_info{$format}
212     or die "unknown format $format";
213
214   my $job = $param->{'job'};
215   $job->update_statustext(0) if $job;
216
217   my $conf = new FS::Conf;
218
219   my $filetype            = $info->{'filetype'};      # CSV, fixed, variable
220   my @fields              = @{ $info->{'fields'}};
221   my $formatre            = $info->{'formatre'};      # for fixed
222   my $parse               = $info->{'parse'};         # for variable
223   my @all_values;
224   my $begin_condition     = $info->{'begin_condition'};
225   my $end_condition       = $info->{'end_condition'};
226   my $end_hook            = $info->{'end_hook'};
227   my $skip_condition      = $info->{'skip_condition'};
228   my $hook                = $info->{'hook'};
229   my $approved_condition  = $info->{'approved'};
230   my $declined_condition  = $info->{'declined'};
231   my $close_condition     = $info->{'close_condition'};
232
233   my $csv = new Text::CSV_XS;
234
235   local $SIG{HUP} = 'IGNORE';
236   local $SIG{INT} = 'IGNORE';
237   local $SIG{QUIT} = 'IGNORE';
238   local $SIG{TERM} = 'IGNORE';
239   local $SIG{TSTP} = 'IGNORE';
240   local $SIG{PIPE} = 'IGNORE';
241
242   my $oldAutoCommit = $FS::UID::AutoCommit;
243   local $FS::UID::AutoCommit = 0;
244   my $dbh = dbh;
245
246   my $reself = $self->select_for_update;
247
248   if ( $reself->status ne 'I' 
249       and !$conf->exists('batch-manual_approval') ) {
250     $dbh->rollback if $oldAutoCommit;
251     return "batchnum ". $self->batchnum. "no longer in transit";
252   }
253
254   my $total = 0;
255   my $line;
256
257   # Order of operations has been changed here.
258   # We now slurp everything into @all_values, then 
259   # process one line at a time.
260
261   if ($filetype eq 'XML') {
262     eval "use XML::Simple";
263     die $@ if $@;
264     my @xmlkeys = @{ $info->{'xmlkeys'} };  # for XML
265     my $xmlrow  = $info->{'xmlrow'};        # also for XML
266
267     # Do everything differently.
268     my $data = XML::Simple::XMLin($fh, KeepRoot => 1);
269     my $rows = $data;
270     # $xmlrow = [ RootKey, FirstLevelKey, SecondLevelKey... ]
271     $rows = $rows->{$_} foreach( @$xmlrow );
272     if(!defined($rows)) {
273       $dbh->rollback if $oldAutoCommit;
274       return "can't find rows in XML file";
275     }
276     $rows = [ $rows ] if ref($rows) ne 'ARRAY';
277     foreach my $row (@$rows) {
278       push @all_values, [ @{$row}{@xmlkeys}, $row ];
279     }
280   }
281   else {
282     while ( defined($line=<$fh>) ) {
283
284       next if $line =~ /^\s*$/; #skip blank lines
285
286       if ($filetype eq "CSV") {
287         $csv->parse($line) or do {
288           $dbh->rollback if $oldAutoCommit;
289           return "can't parse: ". $csv->error_input();
290         };
291         push @all_values, [ $csv->fields(), $line ];
292       }elsif ($filetype eq 'fixed'){
293         my @values = ( $line =~ /$formatre/ );
294         unless (@values) {
295           $dbh->rollback if $oldAutoCommit;
296           return "can't parse: ". $line;
297         };
298         push @values, $line;
299         push @all_values, \@values;
300       }
301       elsif ($filetype eq 'variable') {
302         my @values = ( eval { $parse->($self, $line) } );
303         if( $@ ) {
304           $dbh->rollback if $oldAutoCommit;
305           return $@;
306         };
307         push @values, $line;
308         push @all_values, \@values;
309       }
310       else {
311         $dbh->rollback if $oldAutoCommit;
312         return "Unknown file type $filetype";
313       }
314     }
315   }
316
317   my $num = 0;
318   foreach (@all_values) {
319     if($job) {
320       $num++;
321       $job->update_statustext(int(100 * $num/scalar(@all_values)));
322     }
323     my @values = @$_;
324
325     my %hash;
326     my $line = pop @values;
327     foreach my $field ( @fields ) {
328       my $value = shift @values;
329       next unless $field;
330       $hash{$field} = $value;
331     }
332
333     if ( defined($begin_condition) ) {
334       if ( &{$begin_condition}(\%hash, $line) ) {
335         undef $begin_condition;
336       }
337       else {
338         next;
339       }
340     }
341
342     if ( defined($end_condition) and &{$end_condition}(\%hash, $line) ) {
343       my $error;
344       $error = &{$end_hook}(\%hash, $total, $line) if defined($end_hook);
345       if ( $error ) {
346         $dbh->rollback if $oldAutoCommit;
347         return $error;
348       }
349       last;
350     }
351
352     if ( defined($skip_condition) and &{$skip_condition}(\%hash, $line) ) {
353       next;
354     }
355
356     my $cust_pay_batch =
357       qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
358     unless ( $cust_pay_batch ) {
359       return "unknown paybatchnum $hash{'paybatchnum'}\n";
360     }
361     my $custnum = $cust_pay_batch->custnum,
362     my $payby = $cust_pay_batch->payby,
363
364     &{$hook}(\%hash, $cust_pay_batch->hashref);
365
366     my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
367
368     my $error = '';
369     if ( &{$approved_condition}(\%hash) ) {
370
371       foreach ('paid', '_date', 'payinfo') {
372         $new_cust_pay_batch->$_($hash{$_}) if $hash{$_};
373       }
374       $error = $new_cust_pay_batch->approve($hash{'paybatch'} || $self->batchnum);
375       $total += $hash{'paid'};
376
377     } elsif ( &{$declined_condition}(\%hash) ) {
378
379       $error = $new_cust_pay_batch->decline;
380
381     }
382
383     if ( $error ) {
384       $dbh->rollback if $oldAutoCommit;
385       return $error;
386     }
387
388     # purge CVV when the batch is processed
389     if ( $payby =~ /^(CARD|DCRD)$/ ) {
390       my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
391       if ( ! grep { $_ eq cardtype($payinfo) }
392           $conf->config('cvv-save') ) {
393         $new_cust_pay_batch->cust_main->remove_cvv;
394       }
395
396     }
397
398   } # foreach (@all_values)
399
400   my $close = 1;
401   if ( defined($close_condition) ) {
402     # Allow the module to decide whether to close the batch.
403     # $close_condition can also die() to abort the whole import.
404     $close = eval { $close_condition->($self) };
405     if ( $@ ) {
406       $dbh->rollback;
407       die $@;
408     }
409   }
410   if ( $close ) {
411     my $error = $self->set_status('R');
412     if ( $error ) {
413       $dbh->rollback if $oldAutoCommit;
414       return $error;
415     }
416   }
417
418   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
419   '';
420
421 }
422
423 use MIME::Base64;
424 use Storable 'thaw';
425 use Data::Dumper;
426 sub process_import_results {
427   my $job = shift;
428   my $param = thaw(decode_base64(shift));
429   $param->{'job'} = $job;
430   warn Dumper($param) if $DEBUG;
431   my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n";
432   my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n";
433
434   my $file = $param->{'uploaded_files'} or die "no files provided\n";
435   $file =~ s/^(\w+):([\.\w]+)$/$2/;
436   my $dir = '%%%FREESIDE_CACHE%%%/cache.' . $FS::UID::datasrc;
437   open( $param->{'filehandle'}, 
438         '<',
439         "$dir/$file" )
440       or die "unable to open '$file'.\n";
441   my $error = $batch->import_results($param);
442   unlink $file;
443   die $error if $error;
444 }
445
446 # Formerly httemplate/misc/download-batch.cgi
447 sub export_batch {
448   my $self = shift;
449   my $conf = new FS::Conf;
450   my $format = shift || $conf->config('batch-default_format')
451                or die "No batch format configured\n";
452   my $info = $export_info{$format} or die "Format not found: '$format'\n";
453   &{$info->{'init'}}($conf) if exists($info->{'init'});
454
455   my $curuser = $FS::CurrentUser::CurrentUser;
456
457   my $oldAutoCommit = $FS::UID::AutoCommit;
458   local $FS::UID::AutoCommit = 0;
459   my $dbh = dbh;  
460
461   my $first_download;
462   my $status = $self->status;
463   if ($status eq 'O') {
464     $first_download = 1;
465     my $error = $self->set_status('I');
466     die "error updating pay_batch status: $error\n" if $error;
467   } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
468     $first_download = 0;
469   } else {
470     die "No pending batch.\n";
471   }
472
473   my $batch = '';
474   my $batchtotal = 0;
475   my $batchcount = 0;
476
477   my @cust_pay_batch = sort { $a->paybatchnum <=> $b->paybatchnum }
478                       qsearch('cust_pay_batch', { batchnum => $self->batchnum } );
479   
480   # handle batch-increment_expiration option
481   if ( $self->payby eq 'CARD' ) {
482     my ($cmon, $cyear) = (localtime(time))[4,5];
483     foreach (@cust_pay_batch) {
484       my $etime = str2time($_->exp) or next;
485       my ($day, $mon, $year) = (localtime($etime))[3,4,5];
486       if( $conf->exists('batch-increment_expiration') ) {
487         $year++ while( $year < $cyear or ($year == $cyear and $mon <= $cmon) );
488         $_->exp( sprintf('%4u-%02u-%02u', $year + 1900, $mon+1, $day) );
489       }
490       $_->setfield('expmmyy', sprintf('%02u%02u', $mon+1, $year % 100));
491     }
492   }
493
494   if ($first_download) { #remove or reduce entries if customer's balance changed
495
496     my @new = ();
497     foreach my $cust_pay_batch (@cust_pay_batch) {
498
499       my $balance = $cust_pay_batch->cust_main->balance;
500       if ($balance <= 0) { # then don't charge this customer
501         my $error = $cust_pay_batch->delete;
502         if ( $error ) {
503           $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
504           die $error;
505         }
506         next;
507       } elsif ($balance < $cust_pay_batch->amount) {
508         # reduce the charge to the remaining balance
509         $cust_pay_batch->amount($balance);
510         my $error = $cust_pay_batch->replace;
511         if ( $error ) {
512           $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
513           die $error;
514         }
515       }
516       # else $balance >= $cust_pay_batch->amount
517
518       push @new, $cust_pay_batch;
519     }
520     @cust_pay_batch = @new;
521
522   }
523
524   my $delim = exists($info->{'delimiter'}) ? $info->{'delimiter'} : "\n";
525
526   my $h = $info->{'header'};
527   if (ref($h) eq 'CODE') {
528     $batch .= &$h($self, \@cust_pay_batch). $delim;
529   } else {
530     $batch .= $h. $delim;
531   }
532
533   foreach my $cust_pay_batch (@cust_pay_batch) {
534     $batchcount++;
535     $batchtotal += $cust_pay_batch->amount;
536     $batch .=
537       &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal).
538       $delim;
539   }
540
541   my $f = $info->{'footer'};
542   if (ref($f) eq 'CODE') {
543     $batch .= &$f($self, $batchcount, $batchtotal). $delim;
544   } else {
545     $batch .= $f. $delim;
546   }
547
548   if ($info->{'autopost'}) {
549     my $error = &{$info->{'autopost'}}($self, $batch);
550     if($error) {
551       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
552       die $error;
553     }
554   }
555
556   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
557   return $batch;
558 }
559
560 sub manual_approve {
561   my $self = shift;
562   my $date = time;
563   my %opt = @_;
564   my $paybatch = $opt{'paybatch'} || $self->batchnum;
565   my $usernum = $opt{'usernum'} || die "manual approval requires a usernum";
566   my $conf = FS::Conf->new;
567   return 'manual batch approval disabled' 
568     if ( ! $conf->exists('batch-manual_approval') );
569   return 'batch already resolved' if $self->status eq 'R';
570   return 'batch not yet submitted' if $self->status eq 'O';
571
572   local $SIG{HUP} = 'IGNORE';
573   local $SIG{INT} = 'IGNORE';
574   local $SIG{QUIT} = 'IGNORE';
575   local $SIG{TERM} = 'IGNORE';
576   local $SIG{TSTP} = 'IGNORE';
577   local $SIG{PIPE} = 'IGNORE';
578
579   my $oldAutoCommit = $FS::UID::AutoCommit;
580   local $FS::UID::AutoCommit = 0;
581   my $dbh = dbh;
582
583   my $payments = 0;
584   foreach my $cust_pay_batch ( 
585     qsearch('cust_pay_batch', { batchnum => $self->batchnum,
586         status   => '' })
587   ) {
588     my $new_cust_pay_batch = new FS::cust_pay_batch { 
589       $cust_pay_batch->hash,
590       'paid'    => $cust_pay_batch->amount,
591       '_date'   => $date,
592       'usernum' => $usernum,
593     };
594     my $error = $new_cust_pay_batch->approve($paybatch);
595     if ( $error ) {
596       $dbh->rollback;
597       return 'paybatchnum '.$cust_pay_batch->paybatchnum.": $error";
598     }
599     $payments++;
600   }
601   $self->set_status('R');
602   $dbh->commit;
603   return;
604 }
605
606 =back
607
608 =head1 BUGS
609
610 status is somewhat redundant now that download and upload exist
611
612 =head1 SEE ALSO
613
614 L<FS::Record>, schema.html from the base documentation.
615
616 =cut
617
618 1;
619