chase canada E-xactBatch
[freeside.git] / FS / FS / cust_pay_batch.pm
1 package FS::cust_pay_batch;
2
3 use strict;
4 use vars qw( @ISA $DEBUG );
5 use FS::Record qw(dbh qsearch qsearchs);
6 use FS::part_bill_event qw(due_events);
7 use Business::CreditCard 0.28;
8
9 @ISA = qw( FS::Record );
10
11 # 1 is mostly method/subroutine entry and options
12 # 2 traces progress of some operations
13 # 3 is even more information including possibly sensitive data
14 $DEBUG = 0;
15
16 =head1 NAME
17
18 FS::cust_pay_batch - Object methods for batch cards
19
20 =head1 SYNOPSIS
21
22   use FS::cust_pay_batch;
23
24   $record = new FS::cust_pay_batch \%hash;
25   $record = new FS::cust_pay_batch { 'column' => 'value' };
26
27   $error = $record->insert;
28
29   $error = $new_record->replace($old_record);
30
31   $error = $record->delete;
32
33   $error = $record->check;
34
35   $error = $record->retriable;
36
37 =head1 DESCRIPTION
38
39 An FS::cust_pay_batch object represents a credit card transaction ready to be
40 batched (sent to a processor).  FS::cust_pay_batch inherits from FS::Record.  
41 Typically called by the collect method of an FS::cust_main object.  The
42 following fields are currently supported:
43
44 =over 4
45
46 =item paybatchnum - primary key (automatically assigned)
47
48 =item batchnum - indentifies group in batch
49
50 =item payby - CARD/CHEK/LECB/BILL/COMP
51
52 =item payinfo
53
54 =item exp - card expiration 
55
56 =item amount 
57
58 =item invnum - invoice
59
60 =item custnum - customer 
61
62 =item payname - name on card 
63
64 =item first - name 
65
66 =item last - name 
67
68 =item address1 
69
70 =item address2 
71
72 =item city 
73
74 =item state 
75
76 =item zip 
77
78 =item country 
79
80 =item status
81
82 =back
83
84 =head1 METHODS
85
86 =over 4
87
88 =item new HASHREF
89
90 Creates a new record.  To add the record to the database, see L<"insert">.
91
92 Note that this stores the hash reference, not a distinct copy of the hash it
93 points to.  You can ask the object for a copy with the I<hash> method.
94
95 =cut
96
97 sub table { 'cust_pay_batch'; }
98
99 =item insert
100
101 Adds this record to the database.  If there is an error, returns the error,
102 otherwise returns false.
103
104 =item delete
105
106 Delete this record from the database.  If there is an error, returns the error,
107 otherwise returns false.
108
109 =item replace OLD_RECORD
110
111 Replaces the OLD_RECORD with this one in the database.  If there is an error,
112 returns the error, otherwise returns false.
113
114 =item check
115
116 Checks all fields to make sure this is a valid transaction.  If there is
117 an error, returns the error, otherwise returns false.  Called by the insert
118 and replace methods.
119
120 =cut
121
122 sub check {
123   my $self = shift;
124
125   my $error = 
126       $self->ut_numbern('paybatchnum')
127     || $self->ut_numbern('trancode') #depriciated
128     || $self->ut_money('amount')
129     || $self->ut_number('invnum')
130     || $self->ut_number('custnum')
131     || $self->ut_text('address1')
132     || $self->ut_textn('address2')
133     || $self->ut_text('city')
134     || $self->ut_textn('state')
135   ;
136
137   return $error if $error;
138
139   $self->getfield('last') =~ /^([\w \,\.\-\']+)$/ or return "Illegal last name";
140   $self->setfield('last',$1);
141
142   $self->first =~ /^([\w \,\.\-\']+)$/ or return "Illegal first name";
143   $self->first($1);
144
145   $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREP|CASH|WEST|MCRD)$/
146     or return "Illegal payby";
147   $self->payby($1);
148
149   $error = FS::payby::payinfo_check($self->payby, \$self->payinfo);
150   return $error if $error;
151
152   if ( $self->exp eq '' ) {
153     return "Expiration date required"
154       unless $self->payby =~ /^(CHEK|DCHK|LECB|WEST)$/;
155     $self->exp('');
156   } else {
157     if ( $self->exp =~ /^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/ ) {
158       $self->exp("$1-$2-$3");
159     } elsif ( $self->exp =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
160       if ( length($2) == 4 ) {
161         $self->exp("$2-$1-01");
162       } elsif ( $2 > 98 ) { #should pry change to check for "this year"
163         $self->exp("19$2-$1-01");
164       } else {
165         $self->exp("20$2-$1-01");
166       }
167     } else {
168       return "Illegal expiration date";
169     }
170   }
171
172   if ( $self->payname eq '' ) {
173     $self->payname( $self->first. " ". $self->getfield('last') );
174   } else {
175     $self->payname =~ /^([\w \,\.\-\']+)$/
176       or return "Illegal billing name";
177     $self->payname($1);
178   }
179
180   #$self->zip =~ /^\s*(\w[\w\-\s]{3,8}\w)\s*$/
181   #  or return "Illegal zip: ". $self->zip;
182   #$self->zip($1);
183
184   $self->country =~ /^(\w\w)$/ or return "Illegal country: ". $self->country;
185   $self->country($1);
186
187   $error = $self->ut_zip('zip', $self->country);
188   return $error if $error;
189
190   #check invnum, custnum, ?
191
192   $self->SUPER::check;
193 }
194
195 =item cust_main
196
197 Returns the customer (see L<FS::cust_main>) for this batched credit card
198 payment.
199
200 =cut
201
202 sub cust_main {
203   my $self = shift;
204   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
205 }
206
207 =item retriable
208
209 Marks the corresponding event (see L<FS::cust_bill_event>) for this batched
210 credit card payment as retriable.  Useful if the corresponding financial
211 institution account was declined for temporary reasons and/or a manual 
212 retry is desired.
213
214 Implementation details: For the named customer's invoice, changes the
215 statustext of the 'done' (without statustext) event to 'retriable.'
216
217 =cut
218
219 sub retriable {
220   my $self = shift;
221
222   local $SIG{HUP} = 'IGNORE';        #Hmm
223   local $SIG{INT} = 'IGNORE';
224   local $SIG{QUIT} = 'IGNORE';
225   local $SIG{TERM} = 'IGNORE';
226   local $SIG{TSTP} = 'IGNORE';
227   local $SIG{PIPE} = 'IGNORE';
228
229   my $oldAutoCommit = $FS::UID::AutoCommit;
230   local $FS::UID::AutoCommit = 0;
231   my $dbh = dbh;
232
233   my $cust_bill = qsearchs('cust_bill', { 'invnum' => $self->invnum } )
234     or return "event $self->eventnum references nonexistant invoice $self->invnum";
235
236   warn "cust_pay_batch->retriable working with self of " . $self->paybatchnum . " and invnum of " . $self->invnum;
237   my @cust_bill_event =
238     sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
239       grep {
240         $_->part_bill_event->eventcode =~ /\$cust_bill->batch_card/
241           && $_->status eq 'done'
242           && ! $_->statustext
243         }
244       $cust_bill->cust_bill_event;
245   # complain loudly if scalar(@cust_bill_event) > 1 ?
246   my $error = $cust_bill_event[0]->retriable;
247   if ($error ) {
248     # gah, even with transactions.
249     $dbh->commit if $oldAutoCommit; #well.
250     return "error marking invoice event retriable: $error";
251   }
252   '';
253 }
254
255 =back
256
257 =head1 SUBROUTINES
258
259 =over 4
260
261 =item import_results
262
263 =cut
264
265 sub import_results {
266   use Time::Local;
267   use FS::cust_pay;
268   eval "use Text::CSV_XS;";
269   die $@ if $@;
270 #
271   my $param = shift;
272   my $fh = $param->{'filehandle'};
273   my $format = $param->{'format'};
274   my $paybatch = $param->{'paybatch'};
275
276   my $filetype;      # CSV, Fixed80, Fixed264
277   my @fields;
278   my $formatre;      # for Fixed.+
279   my @values;
280   my $begin_condition;
281   my $end_condition;
282   my $end_hook;
283   my $hook;
284   my $approved_condition;
285   my $declined_condition;
286
287   if ( $format eq 'csv-td_canada_trust-merchant_pc_batch' ) {
288
289     $filetype = "CSV";
290
291     @fields = (
292       'paybatchnum', # Reference#:  Invoice number of the transaction
293       'paid',        # Amount:  Amount of the transaction.  Dollars and cents
294                      #          with no decimal entered.
295       '',            # Card Type:  0 - MCrd, 1 - Visa, 2 - AMEX, 3 - Discover,
296                      #             4 - Insignia, 5 - Diners/EnRoute, 6 - JCB
297       '_date',       # Transaction Date:  Date the Transaction was processed
298       'time',        # Transaction Time:  Time the transaction was processed
299       'payinfo',     # Card Number:  Card number for the transaction
300       '',            # Expiry Date:  Expiry date of the card
301       '',            # Auth#:  Authorization number entered for force post
302                      #         transaction
303       'type',        # Transaction Type:  0 - purchase, 40 - refund,
304                      #                    20 - force post
305       'result',      # Processing Result: 3 - Approval,
306                      #                    4 - Declined/Amount over limit,
307                      #                    5 - Invalid/Expired/stolen card,
308                      #                    6 - Comm Error
309       '',            # Terminal ID: Terminal ID used to process the transaction
310     );
311
312     $end_condition = sub {
313       my $hash = shift;
314       $hash->{'type'} eq '0BC';
315     };
316
317     $end_hook = sub {
318       my( $hash, $total) = @_;
319       $total = sprintf("%.2f", $total);
320       my $batch_total = sprintf("%.2f", $hash->{'paybatchnum'} / 100 );
321       return "Our total $total does not match bank total $batch_total!"
322         if $total != $batch_total;
323       '';
324     };
325
326     $hook = sub {
327       my $hash = shift;
328       $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
329       $hash->{'_date'} = timelocal( substr($hash->{'time'},  4, 2),
330                                     substr($hash->{'time'},  2, 2),
331                                     substr($hash->{'time'},  0, 2),
332                                     substr($hash->{'_date'}, 6, 2),
333                                     substr($hash->{'_date'}, 4, 2)-1,
334                                     substr($hash->{'_date'}, 0, 4)-1900, );
335     };
336
337     $approved_condition = sub {
338       my $hash = shift;
339       $hash->{'type'} eq '0' && $hash->{'result'} == 3;
340     };
341
342     $declined_condition = sub {
343       my $hash = shift;
344       $hash->{'type'} eq '0' && (    $hash->{'result'} == 4
345                                   || $hash->{'result'} == 5 );
346     };
347
348
349   }elsif ( $format eq 'csv-chase_canada-E-xactBatch' ) {
350
351     $filetype = "CSV";
352
353     @fields = (
354       '',            # Internal(bank) id of the transaction
355       '',            # Transaction Type:  00 - purchase,      01 - preauth,
356                      #                    02 - completion,    03 - forcepost,
357                      #                    04 - refund,        05 - auth,
358                      #                    06 - purchase corr, 07 - refund corr,
359                      #                    08 - void           09 - void return
360       '',            # gateway used to process this transaction
361       'paid',        # Amount:  Amount of the transaction.  Dollars and cents
362                      #          with decimal entered.
363       'auth',        # Auth#:  Authorization number (if approved)
364       'payinfo',     # Card Number:  Card number for the transaction
365       '',            # Expiry Date:  Expiry date of the card
366       '',            # Cardholder Name
367       'bankcode',    # Bank response code (3 alphanumeric)
368       'bankmess',    # Bank response message
369       'etgcode',     # ETG response code (2 alphanumeric)
370       'etgmess',     # ETG response message
371       '',            # Returned customer number for the transaction
372       'paybatchnum', # Reference#:  paybatch number of the transaction
373       '',            # Reference#:  Invoice number of the transaction
374       'result',      # Processing Result: Approved of Declined
375     );
376
377     $end_condition = sub {
378       '';
379     };
380
381     $hook = sub {
382       my $hash = shift;
383       $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'}); #hmmmm
384       $hash->{'_date'} = time;  # got a better one?
385     };
386
387     $approved_condition = sub {
388       my $hash = shift;
389       $hash->{'etgcode'} eq '00' && $hash->{'result'} eq "Approved";
390     };
391
392     $declined_condition = sub {
393       my $hash = shift;
394       $hash->{'etgcode'} ne '00' # internal processing error
395         || ( $hash->{'result'} eq "Declined" );
396     };
397
398
399   }elsif ( $format eq 'PAP' ) {
400
401     $filetype = "Fixed264";
402
403     @fields = (
404       'recordtype',  # We are interested in the 'D' or debit records
405       'batchnum',    # Record#:  batch number we used when sending the file
406       'datacenter',  # Where in the bowels of the bank the data was processed
407       'paid',        # Amount:  Amount of the transaction.  Dollars and cents
408                      #          with no decimal entered.
409       '_date',       # Transaction Date:  Date the Transaction was processed
410       'bank',        # Routing information
411       'payinfo',     # Account number for the transaction
412       'paybatchnum', # Reference#:  Invoice number of the transaction
413     );
414
415     $formatre = '^(.).{19}(.{4})(.{3})(.{10})(.{6})(.{9})(.{12}).{110}(.{19}).{71}$'; 
416
417     $end_condition = sub {
418       my $hash = shift;
419       $hash->{'recordtype'} eq 'W';
420     };
421
422     $end_hook = sub {
423       my( $hash, $total) = @_;
424       $total = sprintf("%.2f", $total);
425       my $batch_total = $hash->{'datacenter'}.$hash->{'paid'}.
426                         substr($hash->{'_date'},0,1);          # YUCK!
427       $batch_total = sprintf("%.2f", $batch_total / 100 );
428       return "Our total $total does not match bank total $batch_total!"
429         if $total != $batch_total;
430       '';
431     };
432
433     $hook = sub {
434       my $hash = shift;
435       $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
436       my $tmpdate = timelocal( 0,0,1,1,0,substr($hash->{'_date'}, 0, 3)+2000); 
437       $tmpdate += 86400*(substr($hash->{'_date'}, 3, 3)-1) ;
438       $hash->{'_date'} = $tmpdate;
439       $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'bank'};
440     };
441
442     $approved_condition = sub {
443       1;
444     };
445
446     $declined_condition = sub {
447       0;
448     };
449
450
451   } else {
452     return "Unknown format $format";
453   }
454
455   my $csv = new Text::CSV_XS;
456
457   local $SIG{HUP} = 'IGNORE';
458   local $SIG{INT} = 'IGNORE';
459   local $SIG{QUIT} = 'IGNORE';
460   local $SIG{TERM} = 'IGNORE';
461   local $SIG{TSTP} = 'IGNORE';
462   local $SIG{PIPE} = 'IGNORE';
463
464   my $oldAutoCommit = $FS::UID::AutoCommit;
465   local $FS::UID::AutoCommit = 0;
466   my $dbh = dbh;
467
468   my $pay_batch = qsearchs('pay_batch',{'batchnum'=> $paybatch});
469   unless ($pay_batch && $pay_batch->status eq 'I') {
470     $dbh->rollback if $oldAutoCommit;
471     return "batch $paybatch is not in transit";
472   };
473
474   my $newbatch = new FS::pay_batch { $pay_batch->hash };
475   $newbatch->status('R');   # Resolved
476   $newbatch->upload(time);
477   my $error = $newbatch->replace($pay_batch);
478   if ( $error ) {
479     $dbh->rollback if $oldAutoCommit;
480     return $error
481   }
482
483   my $total = 0;
484   my $line;
485   while ( defined($line=<$fh>) ) {
486
487     next if $line =~ /^\s*$/; #skip blank lines
488
489     if ($filetype eq "CSV") {
490       $csv->parse($line) or do {
491         $dbh->rollback if $oldAutoCommit;
492         return "can't parse: ". $csv->error_input();
493       };
494       @values = $csv->fields();
495     }elsif ($filetype eq "Fixed80" || $filetype eq "Fixed264"){
496       @values = $line =~ /$formatre/;
497       unless (@values) {
498         $dbh->rollback if $oldAutoCommit;
499         return "can't parse: ". $line;
500       };
501     }else{
502       $dbh->rollback if $oldAutoCommit;
503       return "Unknown file type $filetype";
504     }
505
506     my %hash;
507     foreach my $field ( @fields ) {
508       my $value = shift @values;
509       next unless $field;
510       $hash{$field} = $value;
511     }
512
513     if ( &{$end_condition}(\%hash) ) {
514       my $error = &{$end_hook}(\%hash, $total);
515       if ( $error ) {
516         $dbh->rollback if $oldAutoCommit;
517         return $error;
518       }
519       last;
520     }
521
522     my $cust_pay_batch =
523       qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
524     unless ( $cust_pay_batch ) {
525       $dbh->rollback if $oldAutoCommit;
526       return "unknown paybatchnum $hash{'paybatchnum'}\n";
527     }
528     my $custnum = $cust_pay_batch->custnum,
529     my $payby = $cust_pay_batch->payby,
530
531     my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
532
533     &{$hook}(\%hash);
534
535     if ( &{$approved_condition}(\%hash) ) {
536
537       $new_cust_pay_batch->status('Approved');
538
539       my $cust_pay = new FS::cust_pay ( {
540         'custnum'  => $custnum,
541         'payby'    => $payby,
542         'paybatch' => $paybatch,
543         map { $_ => $hash{$_} } (qw( paid _date payinfo )),
544       } );
545       $error = $cust_pay->insert;
546       if ( $error ) {
547         $dbh->rollback if $oldAutoCommit;
548         return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
549       }
550       $total += $hash{'paid'};
551   
552       $cust_pay->cust_main->apply_payments;
553
554     } elsif ( &{$declined_condition}(\%hash) ) {
555
556       $new_cust_pay_batch->status('Declined');
557
558       foreach my $part_bill_event ( due_events ( $new_cust_pay_batch,
559                                                  'DCLN',
560                                                  '',
561                                                  '') ) {
562
563         # don't run subsequent events if balance<=0
564         last if $cust_pay_batch->cust_main->balance <= 0;
565
566         if (my $error = $part_bill_event->do_event($new_cust_pay_batch)) {
567           # gah, even with transactions.
568           $dbh->commit if $oldAutoCommit; #well.
569           return $error;
570         }
571
572       }
573
574     }
575
576     my $error = $new_cust_pay_batch->replace($cust_pay_batch);
577     if ( $error ) {
578       $dbh->rollback if $oldAutoCommit;
579       return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
580     }
581
582   }
583   
584   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
585   '';
586
587 }
588
589 =back
590
591 =head1 BUGS
592
593 There should probably be a configuration file with a list of allowed credit
594 card types.
595
596 =head1 SEE ALSO
597
598 L<FS::cust_main>, L<FS::Record>
599
600 =cut
601
602 1;
603