1 package FS::cust_pay_batch;
4 use vars qw( @ISA $DEBUG );
5 use FS::Record qw(dbh qsearch qsearchs);
6 use Business::CreditCard;
8 @ISA = qw( FS::Record );
10 # 1 is mostly method/subroutine entry and options
11 # 2 traces progress of some operations
12 # 3 is even more information including possibly sensitive data
17 FS::cust_pay_batch - Object methods for batch cards
21 use FS::cust_pay_batch;
23 $record = new FS::cust_pay_batch \%hash;
24 $record = new FS::cust_pay_batch { 'column' => 'value' };
26 $error = $record->insert;
28 $error = $new_record->replace($old_record);
30 $error = $record->delete;
32 $error = $record->check;
36 An FS::cust_pay_batch object represents a credit card transaction ready to be
37 batched (sent to a processor). FS::cust_pay_batch inherits from FS::Record.
38 Typically called by the collect method of an FS::cust_main object. The
39 following fields are currently supported:
43 =item paybatchnum - primary key (automatically assigned)
45 =item batchnum - indentifies group in batch
47 =item payby - CARD/CHEK/LECB/BILL/COMP
51 =item exp - card expiration
55 =item invnum - invoice
57 =item custnum - customer
59 =item payname - name on card
87 Creates a new record. To add the record to the database, see L<"insert">.
89 Note that this stores the hash reference, not a distinct copy of the hash it
90 points to. You can ask the object for a copy with the I<hash> method.
94 sub table { 'cust_pay_batch'; }
98 Adds this record to the database. If there is an error, returns the error,
99 otherwise returns false.
103 Delete this record from the database. If there is an error, returns the error,
104 otherwise returns false.
106 =item replace OLD_RECORD
108 Replaces the OLD_RECORD with this one in the database. If there is an error,
109 returns the error, otherwise returns false.
113 Checks all fields to make sure this is a valid transaction. If there is
114 an error, returns the error, otherwise returns false. Called by the insert
123 $self->ut_numbern('paybatchnum')
124 || $self->ut_numbern('trancode') #depriciated
125 || $self->ut_money('amount')
126 || $self->ut_number('invnum')
127 || $self->ut_number('custnum')
128 || $self->ut_text('address1')
129 || $self->ut_textn('address2')
130 || $self->ut_text('city')
131 || $self->ut_textn('state')
134 return $error if $error;
136 $self->getfield('last') =~ /^([\w \,\.\-\']+)$/ or return "Illegal last name";
137 $self->setfield('last',$1);
139 $self->first =~ /^([\w \,\.\-\']+)$/ or return "Illegal first name";
142 $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREP|CASH|WEST|MCRD)$/
143 or return "Illegal payby";
147 # there is no point in false laziness here
148 # we will effectively set "check_payinfo to 0"
149 # we can change that when we finish the refactor
151 #my $cardnum = $self->cardnum;
152 #$cardnum =~ s/\D//g;
153 #$cardnum =~ /^(\d{13,16})$/
154 # or return "Illegal credit card number";
156 #$self->cardnum($cardnum);
157 #validate($cardnum) or return "Illegal credit card number";
158 #return "Unknown card type" if cardtype($cardnum) eq "Unknown";
160 if ( $self->exp eq '' ) {
161 return "Expiration date required"
162 unless $self->payby =~ /^(CHEK|DCHK|LECB|WEST)$/;
165 if ( $self->exp =~ /^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/ ) {
166 $self->exp("$1-$2-$3");
167 } elsif ( $self->exp =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
168 if ( length($2) == 4 ) {
169 $self->exp("$2-$1-01");
170 } elsif ( $2 > 98 ) { #should pry change to check for "this year"
171 $self->exp("19$2-$1-01");
173 $self->exp("20$2-$1-01");
176 return "Illegal expiration date";
180 if ( $self->payname eq '' ) {
181 $self->payname( $self->first. " ". $self->getfield('last') );
183 $self->payname =~ /^([\w \,\.\-\']+)$/
184 or return "Illegal billing name";
188 #$self->zip =~ /^\s*(\w[\w\-\s]{3,8}\w)\s*$/
189 # or return "Illegal zip: ". $self->zip;
192 $self->country =~ /^(\w\w)$/ or return "Illegal country: ". $self->country;
195 $error = $self->ut_zip('zip', $self->country);
196 return $error if $error;
198 #check invnum, custnum, ?
205 Returns the customer (see L<FS::cust_main>) for this batched credit card
212 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
228 eval "use Text::CSV_XS;";
232 my $fh = $param->{'filehandle'};
233 my $format = $param->{'format'};
234 my $paybatch = $param->{'paybatch'};
236 my $filetype; # CSV, Fixed80, Fixed264
238 my $formatre; # for Fixed.+
244 my $approved_condition;
245 my $declined_condition;
247 if ( $format eq 'csv-td_canada_trust-merchant_pc_batch' ) {
252 'paybatchnum', # Reference#: Invoice number of the transaction
253 'paid', # Amount: Amount of the transaction. Dollars and cents
254 # with no decimal entered.
255 '', # Card Type: 0 - MCrd, 1 - Visa, 2 - AMEX, 3 - Discover,
256 # 4 - Insignia, 5 - Diners/EnRoute, 6 - JCB
257 '_date', # Transaction Date: Date the Transaction was processed
258 'time', # Transaction Time: Time the transaction was processed
259 'payinfo', # Card Number: Card number for the transaction
260 '', # Expiry Date: Expiry date of the card
261 '', # Auth#: Authorization number entered for force post
263 'type', # Transaction Type: 0 - purchase, 40 - refund,
265 'result', # Processing Result: 3 - Approval,
266 # 4 - Declined/Amount over limit,
267 # 5 - Invalid/Expired/stolen card,
269 '', # Terminal ID: Terminal ID used to process the transaction
272 $end_condition = sub {
274 $hash->{'type'} eq '0BC';
278 my( $hash, $total) = @_;
279 $total = sprintf("%.2f", $total);
280 my $batch_total = sprintf("%.2f", $hash->{'paybatchnum'} / 100 );
281 return "Our total $total does not match bank total $batch_total!"
282 if $total != $batch_total;
288 $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
289 $hash->{'_date'} = timelocal( substr($hash->{'time'}, 4, 2),
290 substr($hash->{'time'}, 2, 2),
291 substr($hash->{'time'}, 0, 2),
292 substr($hash->{'_date'}, 6, 2),
293 substr($hash->{'_date'}, 4, 2)-1,
294 substr($hash->{'_date'}, 0, 4)-1900, );
297 $approved_condition = sub {
299 $hash->{'type'} eq '0' && $hash->{'result'} == 3;
302 $declined_condition = sub {
304 $hash->{'type'} eq '0' && ( $hash->{'result'} == 4
305 || $hash->{'result'} == 5 );
309 }elsif ( $format eq 'PAP' ) {
311 $filetype = "Fixed264";
314 'recordtype', # We are interested in the 'D' or debit records
315 'batchnum', # Record#: batch number we used when sending the file
316 'datacenter', # Where in the bowels of the bank the data was processed
317 'paid', # Amount: Amount of the transaction. Dollars and cents
318 # with no decimal entered.
319 '_date', # Transaction Date: Date the Transaction was processed
320 'bank', # Routing information
321 'payinfo', # Account number for the transaction
322 'paybatchnum', # Reference#: Invoice number of the transaction
325 $formatre = '^(.).{19}(.{4})(.{3})(.{10})(.{6})(.{9})(.{12}).{110}(.{19}).{71}$';
327 $end_condition = sub {
329 $hash->{'recordtype'} eq 'W';
333 my( $hash, $total) = @_;
334 $total = sprintf("%.2f", $total);
335 my $batch_total = $hash->{'datacenter'}.$hash->{'paid'}.
336 substr($hash->{'_date'},0,1); # YUCK!
337 $batch_total = sprintf("%.2f", $batch_total / 100 );
338 return "Our total $total does not match bank total $batch_total!"
339 if $total != $batch_total;
345 $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
346 my $tmpdate = timelocal( 0,0,1,1,0,substr($hash->{'_date'}, 0, 3)+2000);
347 $tmpdate += 86400*(substr($hash->{'_date'}, 3, 3)-1) ;
348 $hash->{'_date'} = $tmpdate;
349 $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'bank'};
352 $approved_condition = sub {
356 $declined_condition = sub {
362 return "Unknown format $format";
365 my $csv = new Text::CSV_XS;
367 local $SIG{HUP} = 'IGNORE';
368 local $SIG{INT} = 'IGNORE';
369 local $SIG{QUIT} = 'IGNORE';
370 local $SIG{TERM} = 'IGNORE';
371 local $SIG{TSTP} = 'IGNORE';
372 local $SIG{PIPE} = 'IGNORE';
374 my $oldAutoCommit = $FS::UID::AutoCommit;
375 local $FS::UID::AutoCommit = 0;
378 my $pay_batch = qsearchs('pay_batch',{'batchnum'=> $paybatch});
379 unless ($pay_batch && $pay_batch->status eq 'I') {
380 $dbh->rollback if $oldAutoCommit;
381 return "batch $paybatch is not in transit";
384 my $newbatch = new FS::pay_batch { $pay_batch->hash };
385 $newbatch->status('R'); # Resolved
386 $newbatch->upload(time);
387 my $error = $newbatch->replace($pay_batch);
389 $dbh->rollback if $oldAutoCommit;
395 while ( defined($line=<$fh>) ) {
397 next if $line =~ /^\s*$/; #skip blank lines
399 if ($filetype eq "CSV") {
400 $csv->parse($line) or do {
401 $dbh->rollback if $oldAutoCommit;
402 return "can't parse: ". $csv->error_input();
404 @values = $csv->fields();
405 }elsif ($filetype eq "Fixed80" || $filetype eq "Fixed264"){
406 @values = $line =~ /$formatre/;
408 $dbh->rollback if $oldAutoCommit;
409 return "can't parse: ". $line;
412 $dbh->rollback if $oldAutoCommit;
413 return "Unknown file type $filetype";
417 foreach my $field ( @fields ) {
418 my $value = shift @values;
420 $hash{$field} = $value;
423 if ( &{$end_condition}(\%hash) ) {
424 my $error = &{$end_hook}(\%hash, $total);
426 $dbh->rollback if $oldAutoCommit;
433 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
434 unless ( $cust_pay_batch ) {
435 $dbh->rollback if $oldAutoCommit;
436 return "unknown paybatchnum $hash{'paybatchnum'}\n";
438 my $custnum = $cust_pay_batch->custnum,
439 my $payby = $cust_pay_batch->payby,
441 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
445 if ( &{$approved_condition}(\%hash) ) {
447 $new_cust_pay_batch->status('Approved');
449 my $cust_pay = new FS::cust_pay ( {
450 'custnum' => $custnum,
452 'paybatch' => $paybatch,
453 map { $_ => $hash{$_} } (qw( paid _date payinfo )),
455 $error = $cust_pay->insert;
457 $dbh->rollback if $oldAutoCommit;
458 return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
460 $total += $hash{'paid'};
462 $cust_pay->cust_main->apply_payments;
464 } elsif ( &{$declined_condition}(\%hash) ) {
466 $new_cust_pay_batch->status('Declined');
468 #this should be configurable... if anybody else ever uses batches
469 # $cust_pay_batch->cust_main->suspend;
471 foreach my $part_bill_event (
472 sort { $a->seconds <=> $b->seconds
473 || $a->weight <=> $b->weight
474 || $a->eventpart <=> $b->eventpart }
475 grep { ! qsearch( 'cust_bill_event', {
476 'invnum' => $cust_pay_batch->invnum,
477 'eventpart' => $_->eventpart,
482 'table' => 'part_bill_event',
483 'hashref' => { 'payby' => 'DCLN',
488 # don't run subsequent events if balance<=0
489 last if $cust_pay_batch->cust_main->balance <= 0;
491 warn " calling invoice event (". $part_bill_event->eventcode. ")\n"
493 my $cust_main = $cust_pay_batch->cust_main; #for callback
497 local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
498 $error = eval $part_bill_event->eventcode;
508 $statustext = $error;
514 my $cust_bill_event = new FS::cust_bill_event {
515 'invnum' => $cust_pay_batch->invnum,
516 'eventpart' => $part_bill_event->eventpart,
519 'statustext' => $statustext,
521 $error = $cust_bill_event->insert;
523 # gah, even with transactions.
524 $dbh->commit if $oldAutoCommit; #well.
525 my $e = 'WARNING: Event run but database not updated - '.
526 'error inserting cust_bill_event, invnum #'. $cust_pay_batch->invnum.
527 ', eventpart '. $part_bill_event->eventpart.
537 my $error = $new_cust_pay_batch->replace($cust_pay_batch);
539 $dbh->rollback if $oldAutoCommit;
540 return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
545 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
554 There should probably be a configuration file with a list of allowed credit
559 L<FS::cust_main>, L<FS::Record>