move the due_events import too... whew! this should be it
[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" 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
338   } else {
339     return "Unknown format $format";
340   }
341
342   my $csv = new Text::CSV_XS;
343
344   local $SIG{HUP} = 'IGNORE';
345   local $SIG{INT} = 'IGNORE';
346   local $SIG{QUIT} = 'IGNORE';
347   local $SIG{TERM} = 'IGNORE';
348   local $SIG{TSTP} = 'IGNORE';
349   local $SIG{PIPE} = 'IGNORE';
350
351   my $oldAutoCommit = $FS::UID::AutoCommit;
352   local $FS::UID::AutoCommit = 0;
353   my $dbh = dbh;
354
355   my $reself = $self->select_for_update;
356
357   unless ( $reself->status eq 'I' ) {
358     $dbh->rollback if $oldAutoCommit;
359     return "batchnum ". $self->batchnum. "no longer in transit";
360   };
361
362   my $error = $self->set_status('R');
363   if ( $error ) {
364     $dbh->rollback if $oldAutoCommit;
365     return $error
366   }
367
368   my $total = 0;
369   my $line;
370   while ( defined($line=<$fh>) ) {
371
372     next if $line =~ /^\s*$/; #skip blank lines
373
374     if ($filetype eq "CSV") {
375       $csv->parse($line) or do {
376         $dbh->rollback if $oldAutoCommit;
377         return "can't parse: ". $csv->error_input();
378       };
379       @values = $csv->fields();
380     }elsif ($filetype eq "Fixed80" || $filetype eq "Fixed264"){
381       @values = $line =~ /$formatre/;
382       unless (@values) {
383         $dbh->rollback if $oldAutoCommit;
384         return "can't parse: ". $line;
385       };
386     }else{
387       $dbh->rollback if $oldAutoCommit;
388       return "Unknown file type $filetype";
389     }
390
391     my %hash;
392     foreach my $field ( @fields ) {
393       my $value = shift @values;
394       next unless $field;
395       $hash{$field} = $value;
396     }
397
398     if ( &{$end_condition}(\%hash) ) {
399       my $error = &{$end_hook}(\%hash, $total);
400       if ( $error ) {
401         $dbh->rollback if $oldAutoCommit;
402         return $error;
403       }
404       last;
405     }
406
407     my $cust_pay_batch =
408       qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
409     unless ( $cust_pay_batch ) {
410       return "unknown paybatchnum $hash{'paybatchnum'}\n";
411     }
412     my $custnum = $cust_pay_batch->custnum,
413     my $payby = $cust_pay_batch->payby,
414
415     my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
416
417     &{$hook}(\%hash, $cust_pay_batch->hashref);
418
419     if ( &{$approved_condition}(\%hash) ) {
420
421       $new_cust_pay_batch->status('Approved');
422
423       my $cust_pay = new FS::cust_pay ( {
424         'custnum'  => $custnum,
425         'payby'    => $payby,
426         'paybatch' => $self->batchnum,
427         map { $_ => $hash{$_} } (qw( paid _date payinfo )),
428       } );
429       $error = $cust_pay->insert;
430       if ( $error ) {
431         $dbh->rollback if $oldAutoCommit;
432         return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
433       }
434       $total += $hash{'paid'};
435   
436       $cust_pay->cust_main->apply_payments;
437
438     } elsif ( &{$declined_condition}(\%hash) ) {
439
440       $new_cust_pay_batch->status('Declined');
441
442       foreach my $part_bill_event ( due_events ( $new_cust_pay_batch,
443                                                  'DCLN',
444                                                  '',
445                                                  '') ) {
446
447         # don't run subsequent events if balance<=0
448         last if $cust_pay_batch->cust_main->balance <= 0;
449
450         if (my $error = $part_bill_event->do_event($new_cust_pay_batch)) {
451           # gah, even with transactions.
452           $dbh->commit if $oldAutoCommit; #well.
453           return $error;
454         }
455
456       }
457
458     }
459
460     my $error = $new_cust_pay_batch->replace($cust_pay_batch);
461     if ( $error ) {
462       $dbh->rollback if $oldAutoCommit;
463       return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
464     }
465
466   }
467   
468   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
469   '';
470
471 }
472
473 =back
474
475 =head1 BUGS
476
477 status is somewhat redundant now that download and upload exist
478
479 =head1 SEE ALSO
480
481 L<FS::Record>, schema.html from the base documentation.
482
483 =cut
484
485 1;
486