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