1 package FS::cust_pay_batch;
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;
9 @ISA = qw( FS::Record );
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
18 FS::cust_pay_batch - Object methods for batch cards
22 use FS::cust_pay_batch;
24 $record = new FS::cust_pay_batch \%hash;
25 $record = new FS::cust_pay_batch { 'column' => 'value' };
27 $error = $record->insert;
29 $error = $new_record->replace($old_record);
31 $error = $record->delete;
33 $error = $record->check;
35 $error = $record->retriable;
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:
46 =item paybatchnum - primary key (automatically assigned)
48 =item batchnum - indentifies group in batch
50 =item payby - CARD/CHEK/LECB/BILL/COMP
54 =item exp - card expiration
58 =item invnum - invoice
60 =item custnum - customer
62 =item payname - name on card
90 Creates a new record. To add the record to the database, see L<"insert">.
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.
97 sub table { 'cust_pay_batch'; }
101 Adds this record to the database. If there is an error, returns the error,
102 otherwise returns false.
106 Delete this record from the database. If there is an error, returns the error,
107 otherwise returns false.
109 =item replace OLD_RECORD
111 Replaces the OLD_RECORD with this one in the database. If there is an error,
112 returns the error, otherwise returns false.
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
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')
137 return $error if $error;
139 $self->getfield('last') =~ /^([\w \,\.\-\']+)$/ or return "Illegal last name";
140 $self->setfield('last',$1);
142 $self->first =~ /^([\w \,\.\-\']+)$/ or return "Illegal first name";
145 $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREP|CASH|WEST|MCRD)$/
146 or return "Illegal payby";
149 $error = FS::payby::payinfo_check($self->payby, \$self->payinfo);
150 return $error if $error;
152 if ( $self->exp eq '' ) {
153 return "Expiration date required"
154 unless $self->payby =~ /^(CHEK|DCHK|LECB|WEST)$/;
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");
165 $self->exp("20$2-$1-01");
168 return "Illegal expiration date";
172 if ( $self->payname eq '' ) {
173 $self->payname( $self->first. " ". $self->getfield('last') );
175 $self->payname =~ /^([\w \,\.\-\']+)$/
176 or return "Illegal billing name";
180 #$self->zip =~ /^\s*(\w[\w\-\s]{3,8}\w)\s*$/
181 # or return "Illegal zip: ". $self->zip;
184 $self->country =~ /^(\w\w)$/ or return "Illegal country: ". $self->country;
187 $error = $self->ut_zip('zip', $self->country);
188 return $error if $error;
190 #check invnum, custnum, ?
197 Returns the customer (see L<FS::cust_main>) for this batched credit card
204 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
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
214 Implementation details: For the named customer's invoice, changes the
215 statustext of the 'done' (without statustext) event to 'retriable.'
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';
229 my $oldAutoCommit = $FS::UID::AutoCommit;
230 local $FS::UID::AutoCommit = 0;
233 my $cust_bill = qsearchs('cust_bill', { 'invnum' => $self->invnum } )
234 or return "event $self->eventnum references nonexistant invoice $self->invnum";
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 }
240 $_->part_bill_event->eventcode =~ /\$cust_bill->batch_card/
241 && $_->status eq 'done'
244 $cust_bill->cust_bill_event;
245 # complain loudly if scalar(@cust_bill_event) > 1 ?
246 my $error = $cust_bill_event[0]->retriable;
248 # gah, even with transactions.
249 $dbh->commit if $oldAutoCommit; #well.
250 return "error marking invoice event retriable: $error";
268 eval "use Text::CSV_XS;";
272 my $fh = $param->{'filehandle'};
273 my $format = $param->{'format'};
274 my $paybatch = $param->{'paybatch'};
276 my $filetype; # CSV, Fixed80, Fixed264
278 my $formatre; # for Fixed.+
284 my $approved_condition;
285 my $declined_condition;
287 if ( $format eq 'csv-td_canada_trust-merchant_pc_batch' ) {
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
303 'type', # Transaction Type: 0 - purchase, 40 - refund,
305 'result', # Processing Result: 3 - Approval,
306 # 4 - Declined/Amount over limit,
307 # 5 - Invalid/Expired/stolen card,
309 '', # Terminal ID: Terminal ID used to process the transaction
312 $end_condition = sub {
314 $hash->{'type'} eq '0BC';
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;
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, );
337 $approved_condition = sub {
339 $hash->{'type'} eq '0' && $hash->{'result'} == 3;
342 $declined_condition = sub {
344 $hash->{'type'} eq '0' && ( $hash->{'result'} == 4
345 || $hash->{'result'} == 5 );
349 }elsif ( $format eq 'csv-chase_canada-E-xactBatch' ) {
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
377 $end_condition = sub {
383 $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'}); #hmmmm
384 $hash->{'_date'} = time; # got a better one?
387 $approved_condition = sub {
389 $hash->{'etgcode'} eq '00' && $hash->{'result'} eq "Approved";
392 $declined_condition = sub {
394 $hash->{'etgcode'} ne '00' # internal processing error
395 || ( $hash->{'result'} eq "Declined" );
399 }elsif ( $format eq 'PAP' ) {
401 $filetype = "Fixed264";
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
415 $formatre = '^(.).{19}(.{4})(.{3})(.{10})(.{6})(.{9})(.{12}).{110}(.{19}).{71}$';
417 $end_condition = sub {
419 $hash->{'recordtype'} eq 'W';
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;
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'};
442 $approved_condition = sub {
446 $declined_condition = sub {
452 return "Unknown format $format";
455 my $csv = new Text::CSV_XS;
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';
464 my $oldAutoCommit = $FS::UID::AutoCommit;
465 local $FS::UID::AutoCommit = 0;
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";
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);
479 $dbh->rollback if $oldAutoCommit;
485 while ( defined($line=<$fh>) ) {
487 next if $line =~ /^\s*$/; #skip blank lines
489 if ($filetype eq "CSV") {
490 $csv->parse($line) or do {
491 $dbh->rollback if $oldAutoCommit;
492 return "can't parse: ". $csv->error_input();
494 @values = $csv->fields();
495 }elsif ($filetype eq "Fixed80" || $filetype eq "Fixed264"){
496 @values = $line =~ /$formatre/;
498 $dbh->rollback if $oldAutoCommit;
499 return "can't parse: ". $line;
502 $dbh->rollback if $oldAutoCommit;
503 return "Unknown file type $filetype";
507 foreach my $field ( @fields ) {
508 my $value = shift @values;
510 $hash{$field} = $value;
513 if ( &{$end_condition}(\%hash) ) {
514 my $error = &{$end_hook}(\%hash, $total);
516 $dbh->rollback if $oldAutoCommit;
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";
528 my $custnum = $cust_pay_batch->custnum,
529 my $payby = $cust_pay_batch->payby,
531 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
535 if ( &{$approved_condition}(\%hash) ) {
537 $new_cust_pay_batch->status('Approved');
539 my $cust_pay = new FS::cust_pay ( {
540 'custnum' => $custnum,
542 'paybatch' => $paybatch,
543 map { $_ => $hash{$_} } (qw( paid _date payinfo )),
545 $error = $cust_pay->insert;
547 $dbh->rollback if $oldAutoCommit;
548 return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
550 $total += $hash{'paid'};
552 $cust_pay->cust_main->apply_payments;
554 } elsif ( &{$declined_condition}(\%hash) ) {
556 $new_cust_pay_batch->status('Declined');
558 foreach my $part_bill_event ( due_events ( $new_cust_pay_batch,
563 # don't run subsequent events if balance<=0
564 last if $cust_pay_batch->cust_main->balance <= 0;
566 if (my $error = $part_bill_event->do_event($new_cust_pay_batch)) {
567 # gah, even with transactions.
568 $dbh->commit if $oldAutoCommit; #well.
576 my $error = $new_cust_pay_batch->replace($cust_pay_batch);
578 $dbh->rollback if $oldAutoCommit;
579 return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
584 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
593 There should probably be a configuration file with a list of allowed credit
598 L<FS::cust_main>, L<FS::Record>