ticket 1436, ACH export format, return processing and autopost
[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 $end_condition;
166   my $end_hook;
167   my $hook;
168   my $approved_condition;
169   my $declined_condition;
170
171   if ( $format eq 'csv-td_canada_trust-merchant_pc_batch' ) {
172
173     $filetype = "CSV";
174
175     @fields = (
176       'paybatchnum', # Reference#:  Invoice number of the transaction
177       'paid',        # Amount:  Amount of the transaction.  Dollars and cents
178                      #          with no decimal entered.
179       '',            # Card Type:  0 - MCrd, 1 - Visa, 2 - AMEX, 3 - Discover,
180                      #             4 - Insignia, 5 - Diners/EnRoute, 6 - JCB
181       '_date',       # Transaction Date:  Date the Transaction was processed
182       'time',        # Transaction Time:  Time the transaction was processed
183       'payinfo',     # Card Number:  Card number for the transaction
184       '',            # Expiry Date:  Expiry date of the card
185       '',            # Auth#:  Authorization number entered for force post
186                      #         transaction
187       'type',        # Transaction Type:  0 - purchase, 40 - refund,
188                      #                    20 - force post
189       'result',      # Processing Result: 3 - Approval,
190                      #                    4 - Declined/Amount over limit,
191                      #                    5 - Invalid/Expired/stolen card,
192                      #                    6 - Comm Error
193       '',            # Terminal ID: Terminal ID used to process the transaction
194     );
195
196     $end_condition = sub {
197       my $hash = shift;
198       $hash->{'type'} eq '0BC';
199     };
200
201     $end_hook = sub {
202       my( $hash, $total) = @_;
203       $total = sprintf("%.2f", $total);
204       my $batch_total = sprintf("%.2f", $hash->{'paybatchnum'} / 100 );
205       return "Our total $total does not match bank total $batch_total!"
206         if $total != $batch_total;
207       '';
208     };
209
210     $hook = sub {
211       my $hash = shift;
212       $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
213       $hash->{'_date'} = timelocal( substr($hash->{'time'},  4, 2),
214                                     substr($hash->{'time'},  2, 2),
215                                     substr($hash->{'time'},  0, 2),
216                                     substr($hash->{'_date'}, 6, 2),
217                                     substr($hash->{'_date'}, 4, 2)-1,
218                                     substr($hash->{'_date'}, 0, 4)-1900, );
219     };
220
221     $approved_condition = sub {
222       my $hash = shift;
223       $hash->{'type'} eq '0' && $hash->{'result'} == 3;
224     };
225
226     $declined_condition = sub {
227       my $hash = shift;
228       $hash->{'type'} eq '0' && (    $hash->{'result'} == 4
229                                   || $hash->{'result'} == 5 );
230     };
231
232
233   }elsif ( $format eq 'csv-chase_canada-E-xactBatch' ) {
234
235     $filetype = "CSV";
236
237     @fields = (
238       '',            # Internal(bank) id of the transaction
239       '',            # Transaction Type:  00 - purchase,      01 - preauth,
240                      #                    02 - completion,    03 - forcepost,
241                      #                    04 - refund,        05 - auth,
242                      #                    06 - purchase corr, 07 - refund corr,
243                      #                    08 - void           09 - void return
244       '',            # gateway used to process this transaction
245       'paid',        # Amount:  Amount of the transaction.  Dollars and cents
246                      #          with decimal entered.
247       'auth',        # Auth#:  Authorization number (if approved)
248       'payinfo',     # Card Number:  Card number for the transaction
249       '',            # Expiry Date:  Expiry date of the card
250       '',            # Cardholder Name
251       'bankcode',    # Bank response code (3 alphanumeric)
252       'bankmess',    # Bank response message
253       'etgcode',     # ETG response code (2 alphanumeric)
254       'etgmess',     # ETG response message
255       '',            # Returned customer number for the transaction
256       'paybatchnum', # Reference#:  paybatch number of the transaction
257       '',            # Reference#:  Invoice number of the transaction
258       'result',      # Processing Result: Approved of Declined
259     );
260
261     $end_condition = sub {
262       '';
263     };
264
265     $hook = sub {
266       my $hash = shift;
267       my $cpb = shift;
268       $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'}); #hmmmm
269       $hash->{'_date'} = time;  # got a better one?
270       $hash->{'payinfo'} = $cpb->{'payinfo'}
271         if( substr($hash->{'payinfo'}, -4) eq substr($cpb->{'payinfo'}, -4) );
272     };
273
274     $approved_condition = sub {
275       my $hash = shift;
276       $hash->{'etgcode'} eq '00' && $hash->{'result'} eq "Approved";
277     };
278
279     $declined_condition = sub {
280       my $hash = shift;
281       $hash->{'etgcode'} ne '00' # internal processing error
282         || ( $hash->{'result'} eq "Declined" );
283     };
284
285
286   }elsif ( $format eq 'PAP' ) {
287
288     $filetype = "Fixed264";
289
290     @fields = (
291       'recordtype',  # We are interested in the 'D' or debit records
292       'batchnum',    # Record#:  batch number we used when sending the file
293       'datacenter',  # Where in the bowels of the bank the data was processed
294       'paid',        # Amount:  Amount of the transaction.  Dollars and cents
295                      #          with no decimal entered.
296       '_date',       # Transaction Date:  Date the Transaction was processed
297       'bank',        # Routing information
298       'payinfo',     # Account number for the transaction
299       'paybatchnum', # Reference#:  Invoice number of the transaction
300     );
301
302     $formatre = '^(.).{19}(.{4})(.{3})(.{10})(.{6})(.{9})(.{12}).{110}(.{19}).{71}$'; 
303
304     $end_condition = sub {
305       my $hash = shift;
306       $hash->{'recordtype'} eq 'W';
307     };
308
309     $end_hook = sub {
310       my( $hash, $total) = @_;
311       $total = sprintf("%.2f", $total);
312       my $batch_total = $hash->{'datacenter'}.$hash->{'paid'}.
313                         substr($hash->{'_date'},0,1);          # YUCK!
314       $batch_total = sprintf("%.2f", $batch_total / 100 );
315       return "Our total $total does not match bank total $batch_total!"
316         if $total != $batch_total;
317       '';
318     };
319
320     $hook = sub {
321       my $hash = shift;
322       $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
323       my $tmpdate = timelocal( 0,0,1,1,0,substr($hash->{'_date'}, 0, 3)+2000); 
324       $tmpdate += 86400*(substr($hash->{'_date'}, 3, 3)-1) ;
325       $hash->{'_date'} = $tmpdate;
326       $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'bank'};
327     };
328
329     $approved_condition = sub {
330       1;
331     };
332
333     $declined_condition = sub {
334       0;
335     };
336
337   }elsif ( $format eq 'ach-spiritone' ) {
338
339     $filetype = "CSV";
340
341     @fields = (
342       '',            # Name
343       'paybatchnum', # ID:  Invoice number of the transaction
344       'aba',         # ABA Number for the transaction
345       'payinfo',     # Bank Account Number for the transaction
346       '',            # Transaction Type:  27 - debit
347       'paid',        # Amount:  Amount of the transaction.  Dollars and cents
348                      #          with decimal entered.
349       '',            # Default Transaction Type
350       '',            # Default Amount:  Dollars and cents with decimal entered.
351     );
352
353     $end_condition = sub {
354       '';
355     };
356
357     $hook = sub {
358       my $hash = shift;
359       $hash->{'_date'} = time;  # got a better one?
360       $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'aba'};
361     };
362
363     $approved_condition = sub {
364       1;
365     };
366
367     $declined_condition = sub {
368       0;
369     };
370
371
372   } else {
373     return "Unknown format $format";
374   }
375
376   my $csv = new Text::CSV_XS;
377
378   local $SIG{HUP} = 'IGNORE';
379   local $SIG{INT} = 'IGNORE';
380   local $SIG{QUIT} = 'IGNORE';
381   local $SIG{TERM} = 'IGNORE';
382   local $SIG{TSTP} = 'IGNORE';
383   local $SIG{PIPE} = 'IGNORE';
384
385   my $oldAutoCommit = $FS::UID::AutoCommit;
386   local $FS::UID::AutoCommit = 0;
387   my $dbh = dbh;
388
389   my $reself = $self->select_for_update;
390
391   unless ( $reself->status eq 'I' ) {
392     $dbh->rollback if $oldAutoCommit;
393     return "batchnum ". $self->batchnum. "no longer in transit";
394   };
395
396   my $error = $self->set_status('R');
397   if ( $error ) {
398     $dbh->rollback if $oldAutoCommit;
399     return $error
400   }
401
402   my $total = 0;
403   my $line;
404   while ( defined($line=<$fh>) ) {
405
406     next if $line =~ /^\s*$/; #skip blank lines
407
408     if ($filetype eq "CSV") {
409       $csv->parse($line) or do {
410         $dbh->rollback if $oldAutoCommit;
411         return "can't parse: ". $csv->error_input();
412       };
413       @values = $csv->fields();
414     }elsif ($filetype eq "Fixed80" || $filetype eq "Fixed264"){
415       @values = $line =~ /$formatre/;
416       unless (@values) {
417         $dbh->rollback if $oldAutoCommit;
418         return "can't parse: ". $line;
419       };
420     }else{
421       $dbh->rollback if $oldAutoCommit;
422       return "Unknown file type $filetype";
423     }
424
425     my %hash;
426     foreach my $field ( @fields ) {
427       my $value = shift @values;
428       next unless $field;
429       $hash{$field} = $value;
430     }
431
432     if ( &{$end_condition}(\%hash) ) {
433       my $error = &{$end_hook}(\%hash, $total);
434       if ( $error ) {
435         $dbh->rollback if $oldAutoCommit;
436         return $error;
437       }
438       last;
439     }
440
441     my $cust_pay_batch =
442       qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
443     unless ( $cust_pay_batch ) {
444       return "unknown paybatchnum $hash{'paybatchnum'}\n";
445     }
446     my $custnum = $cust_pay_batch->custnum,
447     my $payby = $cust_pay_batch->payby,
448
449     my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
450
451     &{$hook}(\%hash, $cust_pay_batch->hashref);
452
453     if ( &{$approved_condition}(\%hash) ) {
454
455       $new_cust_pay_batch->status('Approved');
456
457       my $cust_pay = new FS::cust_pay ( {
458         'custnum'  => $custnum,
459         'payby'    => $payby,
460         'paybatch' => $self->batchnum,
461         map { $_ => $hash{$_} } (qw( paid _date payinfo )),
462       } );
463       $error = $cust_pay->insert;
464       if ( $error ) {
465         $dbh->rollback if $oldAutoCommit;
466         return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
467       }
468       $total += $hash{'paid'};
469   
470       $cust_pay->cust_main->apply_payments;
471
472     } elsif ( &{$declined_condition}(\%hash) ) {
473
474       $new_cust_pay_batch->status('Declined');
475
476       foreach my $part_bill_event ( due_events ( $new_cust_pay_batch,
477                                                  'DCLN',
478                                                  '',
479                                                  '') ) {
480
481         # don't run subsequent events if balance<=0
482         last if $cust_pay_batch->cust_main->balance <= 0;
483
484         if (my $error = $part_bill_event->do_event($new_cust_pay_batch)) {
485           # gah, even with transactions.
486           $dbh->commit if $oldAutoCommit; #well.
487           return $error;
488         }
489
490       }
491
492     }
493
494     my $error = $new_cust_pay_batch->replace($cust_pay_batch);
495     if ( $error ) {
496       $dbh->rollback if $oldAutoCommit;
497       return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
498     }
499
500   }
501   
502   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
503   '';
504
505 }
506
507 =back
508
509 =head1 BUGS
510
511 status is somewhat redundant now that download and upload exist
512
513 =head1 SEE ALSO
514
515 L<FS::Record>, schema.html from the base documentation.
516
517 =cut
518
519 1;
520