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