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