add to ACH batch feature from customer view page
[freeside.git] / FS / FS / pay_batch.pm
1 package FS::pay_batch;
2
3 use strict;
4 use vars qw( @ISA );
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::part_bill_event qw(due_events);
10
11 @ISA = qw(FS::Record);
12
13 =head1 NAME
14
15 FS::pay_batch - Object methods for pay_batch records
16
17 =head1 SYNOPSIS
18
19   use FS::pay_batch;
20
21   $record = new FS::pay_batch \%hash;
22   $record = new FS::pay_batch { 'column' => 'value' };
23
24   $error = $record->insert;
25
26   $error = $new_record->replace($old_record);
27
28   $error = $record->delete;
29
30   $error = $record->check;
31
32 =head1 DESCRIPTION
33
34 An FS::pay_batch object represents an example.  FS::pay_batch inherits from
35 FS::Record.  The following fields are currently supported:
36
37 =over 4
38
39 =item batchnum - primary key
40
41 =item payby - CARD or CHEK
42
43 =item status - O (Open), I (In-transit), or R (Resolved)
44
45 =item download - 
46
47 =item upload - 
48
49
50 =back
51
52 =head1 METHODS
53
54 =over 4
55
56 =item new HASHREF
57
58 Creates a new example.  To add the example to the database, see L<"insert">.
59
60 Note that this stores the hash reference, not a distinct copy of the hash it
61 points to.  You can ask the object for a copy with the I<hash> method.
62
63 =cut
64
65 # the new method can be inherited from FS::Record, if a table method is defined
66
67 sub table { 'pay_batch'; }
68
69 =item insert
70
71 Adds this record to the database.  If there is an error, returns the error,
72 otherwise returns false.
73
74 =cut
75
76 # the insert method can be inherited from FS::Record
77
78 =item delete
79
80 Delete this record from the database.
81
82 =cut
83
84 # the delete method can be inherited from FS::Record
85
86 =item replace OLD_RECORD
87
88 Replaces the OLD_RECORD with this one in the database.  If there is an error,
89 returns the error, otherwise returns false.
90
91 =cut
92
93 # the replace method can be inherited from FS::Record
94
95 =item check
96
97 Checks all fields to make sure this is a valid example.  If there is
98 an error, returns the error, otherwise returns false.  Called by the insert
99 and replace methods.
100
101 =cut
102
103 # the check method should currently be supplied - FS::Record contains some
104 # data checking routines
105
106 sub check {
107   my $self = shift;
108
109   my $error = 
110     $self->ut_numbern('batchnum')
111     || $self->ut_enum('payby', [ 'CARD', 'CHEK' ])
112     || $self->ut_enum('status', [ 'O', 'I', 'R' ])
113   ;
114   return $error if $error;
115
116   $self->SUPER::check;
117 }
118
119 =item rebalance
120
121 =cut
122
123 sub rebalance {
124   my $self = shift;
125 }
126
127 =item set_status 
128
129 =cut
130
131 sub set_status {
132   my $self = shift;
133   $self->status(shift);
134   $self->download(time)
135     if $self->status eq 'I' && ! $self->download;
136   $self->upload(time)
137     if $self->status eq 'R' && ! $self->upload;
138   $self->replace();
139 }
140
141 =item import_results OPTION => VALUE, ...
142
143 Import batch results.
144
145 Options are:
146
147 I<filehandle> - open filehandle of results file.
148
149 I<format> - "csv-td_canada_trust-merchant_pc_batch", "csv-chase_canada-E-xactBatch", "ach-spiritone", or "PAP"
150
151 =cut
152
153 sub import_results {
154   my $self = shift;
155
156   my $param = ref($_[0]) ? shift : { @_ };
157   my $fh = $param->{'filehandle'};
158   my $format = $param->{'format'};
159
160   my $filetype;      # CSV, Fixed80, Fixed264
161   my @fields;
162   my $formatre;      # for Fixed.+
163   my @values;
164   my $begin_condition;
165   my $pre_hook;
166   my $end_condition;
167   my $end_hook;
168   my $hook;
169   my $approved_condition;
170   my $declined_condition;
171
172   if ( $format eq 'csv-td_canada_trust-merchant_pc_batch' ) {
173
174     $filetype = "CSV";
175
176     @fields = (
177       'paybatchnum', # Reference#:  Invoice number of the transaction
178       'paid',        # Amount:  Amount of the transaction.  Dollars and cents
179                      #          with no decimal entered.
180       '',            # Card Type:  0 - MCrd, 1 - Visa, 2 - AMEX, 3 - Discover,
181                      #             4 - Insignia, 5 - Diners/EnRoute, 6 - JCB
182       '_date',       # Transaction Date:  Date the Transaction was processed
183       'time',        # Transaction Time:  Time the transaction was processed
184       'payinfo',     # Card Number:  Card number for the transaction
185       '',            # Expiry Date:  Expiry date of the card
186       '',            # Auth#:  Authorization number entered for force post
187                      #         transaction
188       'type',        # Transaction Type:  0 - purchase, 40 - refund,
189                      #                    20 - force post
190       'result',      # Processing Result: 3 - Approval,
191                      #                    4 - Declined/Amount over limit,
192                      #                    5 - Invalid/Expired/stolen card,
193                      #                    6 - Comm Error
194       '',            # Terminal ID: Terminal ID used to process the transaction
195     );
196
197     $end_condition = sub {
198       my $hash = shift;
199       $hash->{'type'} eq '0BC';
200     };
201
202     $end_hook = sub {
203       my( $hash, $total) = @_;
204       $total = sprintf("%.2f", $total);
205       my $batch_total = sprintf("%.2f", $hash->{'paybatchnum'} / 100 );
206       return "Our total $total does not match bank total $batch_total!"
207         if $total != $batch_total;
208       '';
209     };
210
211     $hook = sub {
212       my $hash = shift;
213       $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
214       $hash->{'_date'} = timelocal( substr($hash->{'time'},  4, 2),
215                                     substr($hash->{'time'},  2, 2),
216                                     substr($hash->{'time'},  0, 2),
217                                     substr($hash->{'_date'}, 6, 2),
218                                     substr($hash->{'_date'}, 4, 2)-1,
219                                     substr($hash->{'_date'}, 0, 4)-1900, );
220     };
221
222     $approved_condition = sub {
223       my $hash = shift;
224       $hash->{'type'} eq '0' && $hash->{'result'} == 3;
225     };
226
227     $declined_condition = sub {
228       my $hash = shift;
229       $hash->{'type'} eq '0' && (    $hash->{'result'} == 4
230                                   || $hash->{'result'} == 5 );
231     };
232
233
234   }elsif ( $format eq 'csv-chase_canada-E-xactBatch' ) {
235
236     $filetype = "CSV";
237
238     @fields = (
239       '',            # Internal(bank) id of the transaction
240       '',            # Transaction Type:  00 - purchase,      01 - preauth,
241                      #                    02 - completion,    03 - forcepost,
242                      #                    04 - refund,        05 - auth,
243                      #                    06 - purchase corr, 07 - refund corr,
244                      #                    08 - void           09 - void return
245       '',            # gateway used to process this transaction
246       'paid',        # Amount:  Amount of the transaction.  Dollars and cents
247                      #          with decimal entered.
248       'auth',        # Auth#:  Authorization number (if approved)
249       'payinfo',     # Card Number:  Card number for the transaction
250       '',            # Expiry Date:  Expiry date of the card
251       '',            # Cardholder Name
252       'bankcode',    # Bank response code (3 alphanumeric)
253       'bankmess',    # Bank response message
254       'etgcode',     # ETG response code (2 alphanumeric)
255       'etgmess',     # ETG response message
256       '',            # Returned customer number for the transaction
257       'paybatchnum', # Reference#:  paybatch number of the transaction
258       '',            # Reference#:  Invoice number of the transaction
259       'result',      # Processing Result: Approved of Declined
260     );
261
262     $end_condition = sub {
263       '';
264     };
265
266     $hook = sub {
267       my $hash = shift;
268       my $cpb = shift;
269       $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'}); #hmmmm
270       $hash->{'_date'} = time;  # got a better one?
271       $hash->{'payinfo'} = $cpb->{'payinfo'}
272         if( substr($hash->{'payinfo'}, -4) eq substr($cpb->{'payinfo'}, -4) );
273     };
274
275     $approved_condition = sub {
276       my $hash = shift;
277       $hash->{'etgcode'} eq '00' && $hash->{'result'} eq "Approved";
278     };
279
280     $declined_condition = sub {
281       my $hash = shift;
282       $hash->{'etgcode'} ne '00' # internal processing error
283         || ( $hash->{'result'} eq "Declined" );
284     };
285
286
287   }elsif ( $format eq 'PAP' ) {
288
289     $filetype = "Fixed264";
290
291     @fields = (
292       'recordtype',  # We are interested in the 'D' or debit records
293       'batchnum',    # Record#:  batch number we used when sending the file
294       'datacenter',  # Where in the bowels of the bank the data was processed
295       'paid',        # Amount:  Amount of the transaction.  Dollars and cents
296                      #          with no decimal entered.
297       '_date',       # Transaction Date:  Date the Transaction was processed
298       'bank',        # Routing information
299       'payinfo',     # Account number for the transaction
300       'paybatchnum', # Reference#:  Invoice number of the transaction
301     );
302
303     $formatre = '^(.).{19}(.{4})(.{3})(.{10})(.{6})(.{9})(.{12}).{110}(.{19}).{71}$'; 
304
305     $end_condition = sub {
306       my $hash = shift;
307       $hash->{'recordtype'} eq 'W';
308     };
309
310     $end_hook = sub {
311       my( $hash, $total) = @_;
312       $total = sprintf("%.2f", $total);
313       my $batch_total = $hash->{'datacenter'}.$hash->{'paid'}.
314                         substr($hash->{'_date'},0,1);          # YUCK!
315       $batch_total = sprintf("%.2f", $batch_total / 100 );
316       return "Our total $total does not match bank total $batch_total!"
317         if $total != $batch_total;
318       '';
319     };
320
321     $hook = sub {
322       my $hash = shift;
323       $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
324       my $tmpdate = timelocal( 0,0,1,1,0,substr($hash->{'_date'}, 0, 3)+2000); 
325       $tmpdate += 86400*(substr($hash->{'_date'}, 3, 3)-1) ;
326       $hash->{'_date'} = $tmpdate;
327       $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'bank'};
328     };
329
330     $approved_condition = sub {
331       1;
332     };
333
334     $declined_condition = sub {
335       0;
336     };
337
338   }elsif ( $format eq 'ach-spiritone' ) {
339
340     $filetype = "CSV";
341
342     @fields = (
343       '',            # Name
344       'custnum'    , # ID:  Customer number of the transaction
345       'aba',         # ABA Number for the transaction
346       'payinfo',     # Bank Account Number for the transaction
347       '',            # Transaction Type:  27 - debit
348       'paid',        # Amount:  Amount of the transaction.  Dollars and cents
349                      #          with decimal entered.
350       '',            # Default Transaction Type
351       '',            # Default Amount:  Dollars and cents with decimal entered.
352     );
353
354     $end_condition = sub {
355       '';
356     };
357
358     $pre_hook = sub {
359       my $hash = shift;
360       my @cust_pay_batch =    # this is dodgy, it works due to autoposting
361         qsearch('cust_pay_batch', { 'custnum' => $hash->{'custnum'}+0, 
362                                     'status'  => ''
363                                   } );
364       if ( scalar(@cust_pay_batch) == 1 ) {
365         $hash->{'paybatchnum'} = $cust_pay_batch[0]->paybatchnum;
366       }else{
367         return "can't find batch payment for customer number " .$hash->{custnum};
368       }
369       '';
370     };
371
372     $hook = sub {
373       my $hash = shift;
374       $hash->{'_date'} = time;  # got a better one?
375       $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'aba'};
376     };
377
378     $approved_condition = sub {
379       1;
380     };
381
382     $declined_condition = sub {
383       0;
384     };
385
386
387   } else {
388     return "Unknown format $format";
389   }
390
391   my $csv = new Text::CSV_XS;
392
393   local $SIG{HUP} = 'IGNORE';
394   local $SIG{INT} = 'IGNORE';
395   local $SIG{QUIT} = 'IGNORE';
396   local $SIG{TERM} = 'IGNORE';
397   local $SIG{TSTP} = 'IGNORE';
398   local $SIG{PIPE} = 'IGNORE';
399
400   my $oldAutoCommit = $FS::UID::AutoCommit;
401   local $FS::UID::AutoCommit = 0;
402   my $dbh = dbh;
403
404   my $reself = $self->select_for_update;
405
406   unless ( $reself->status eq 'I' ) {
407     $dbh->rollback if $oldAutoCommit;
408     return "batchnum ". $self->batchnum. "no longer in transit";
409   };
410
411   my $error = $self->set_status('R');
412   if ( $error ) {
413     $dbh->rollback if $oldAutoCommit;
414     return $error
415   }
416
417   my $total = 0;
418   my $line;
419   while ( defined($line=<$fh>) ) {
420
421     next if $line =~ /^\s*$/; #skip blank lines
422
423     if ($filetype eq "CSV") {
424       $csv->parse($line) or do {
425         $dbh->rollback if $oldAutoCommit;
426         return "can't parse: ". $csv->error_input();
427       };
428       @values = $csv->fields();
429     }elsif ($filetype eq "Fixed80" || $filetype eq "Fixed264"){
430       @values = $line =~ /$formatre/;
431       unless (@values) {
432         $dbh->rollback if $oldAutoCommit;
433         return "can't parse: ". $line;
434       };
435     }else{
436       $dbh->rollback if $oldAutoCommit;
437       return "Unknown file type $filetype";
438     }
439
440     my %hash;
441     foreach my $field ( @fields ) {
442       my $value = shift @values;
443       next unless $field;
444       $hash{$field} = $value;
445     }
446
447     if ( defined($pre_hook) ) {
448       my $error = &{$pre_hook}(\%hash);
449       if ( $error ) {
450         $dbh->rollback if $oldAutoCommit;
451         return $error;
452       }
453     }
454
455     if ( &{$end_condition}(\%hash) ) {
456       my $error = &{$end_hook}(\%hash, $total);
457       if ( $error ) {
458         $dbh->rollback if $oldAutoCommit;
459         return $error;
460       }
461       last;
462     }
463
464     my $cust_pay_batch =
465       qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
466     unless ( $cust_pay_batch ) {
467       return "unknown paybatchnum $hash{'paybatchnum'}\n";
468     }
469     my $custnum = $cust_pay_batch->custnum,
470     my $payby = $cust_pay_batch->payby,
471
472     my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
473
474     &{$hook}(\%hash, $cust_pay_batch->hashref);
475
476     if ( &{$approved_condition}(\%hash) ) {
477
478       $new_cust_pay_batch->status('Approved');
479
480       my $cust_pay = new FS::cust_pay ( {
481         'custnum'  => $custnum,
482         'payby'    => $payby,
483         'paybatch' => $self->batchnum,
484         map { $_ => $hash{$_} } (qw( paid _date payinfo )),
485       } );
486       $error = $cust_pay->insert;
487       if ( $error ) {
488         $dbh->rollback if $oldAutoCommit;
489         return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
490       }
491       $total += $hash{'paid'};
492   
493       $cust_pay->cust_main->apply_payments;
494
495     } elsif ( &{$declined_condition}(\%hash) ) {
496
497       $new_cust_pay_batch->status('Declined');
498
499       foreach my $part_bill_event ( due_events ( $new_cust_pay_batch,
500                                                  'DCLN',
501                                                  '',
502                                                  '') ) {
503
504         # don't run subsequent events if balance<=0
505         last if $cust_pay_batch->cust_main->balance <= 0;
506
507         if (my $error = $part_bill_event->do_event($new_cust_pay_batch)) {
508           # gah, even with transactions.
509           $dbh->commit if $oldAutoCommit; #well.
510           return $error;
511         }
512
513       }
514
515     }
516
517     my $error = $new_cust_pay_batch->replace($cust_pay_batch);
518     if ( $error ) {
519       $dbh->rollback if $oldAutoCommit;
520       return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
521     }
522
523   }
524   
525   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
526   '';
527
528 }
529
530 =back
531
532 =head1 BUGS
533
534 status is somewhat redundant now that download and upload exist
535
536 =head1 SEE ALSO
537
538 L<FS::Record>, schema.html from the base documentation.
539
540 =cut
541
542 1;
543