1 package FS::cust_pay_batch;
4 use vars qw( @ISA $DEBUG );
5 use FS::Record qw(dbh qsearch qsearchs);
7 use FS::part_bill_event qw(due_events);
8 use Business::CreditCard 0.28;
10 @ISA = qw( FS::Record FS::payinfo_Mixin );
12 # 1 is mostly method/subroutine entry and options
13 # 2 traces progress of some operations
14 # 3 is even more information including possibly sensitive data
19 FS::cust_pay_batch - Object methods for batch cards
23 use FS::cust_pay_batch;
25 $record = new FS::cust_pay_batch \%hash;
26 $record = new FS::cust_pay_batch { 'column' => 'value' };
28 $error = $record->insert;
30 $error = $new_record->replace($old_record);
32 $error = $record->delete;
34 $error = $record->check;
36 $error = $record->retriable;
40 An FS::cust_pay_batch object represents a credit card transaction ready to be
41 batched (sent to a processor). FS::cust_pay_batch inherits from FS::Record.
42 Typically called by the collect method of an FS::cust_main object. The
43 following fields are currently supported:
47 =item paybatchnum - primary key (automatically assigned)
49 =item batchnum - indentifies group in batch
51 =item payby - CARD/CHEK/LECB/BILL/COMP
55 =item exp - card expiration
59 =item invnum - invoice
61 =item custnum - customer
63 =item payname - name on card
91 Creates a new record. To add the record to the database, see L<"insert">.
93 Note that this stores the hash reference, not a distinct copy of the hash it
94 points to. You can ask the object for a copy with the I<hash> method.
98 sub table { 'cust_pay_batch'; }
102 Adds this record to the database. If there is an error, returns the error,
103 otherwise returns false.
107 Delete this record from the database. If there is an error, returns the error,
108 otherwise returns false.
110 =item replace OLD_RECORD
112 Replaces the OLD_RECORD with this one in the database. If there is an error,
113 returns the error, otherwise returns false.
117 Checks all fields to make sure this is a valid transaction. If there is
118 an error, returns the error, otherwise returns false. Called by the insert
127 $self->ut_numbern('paybatchnum')
128 || $self->ut_numbern('trancode') #deprecated
129 || $self->ut_money('amount')
130 || $self->ut_number('invnum')
131 || $self->ut_number('custnum')
132 || $self->ut_text('address1')
133 || $self->ut_textn('address2')
134 || $self->ut_text('city')
135 || $self->ut_textn('state')
138 return $error if $error;
140 $self->getfield('last') =~ /^([\w \,\.\-\']+)$/ or return "Illegal last name";
141 $self->setfield('last',$1);
143 $self->first =~ /^([\w \,\.\-\']+)$/ or return "Illegal first name";
146 $error = $self->payinfo_check();
147 return $error if $error;
149 if ( $self->exp eq '' ) {
150 return "Expiration date required"
151 unless $self->payby =~ /^(CHEK|DCHK|LECB|WEST)$/;
154 if ( $self->exp =~ /^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/ ) {
155 $self->exp("$1-$2-$3");
156 } elsif ( $self->exp =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
157 if ( length($2) == 4 ) {
158 $self->exp("$2-$1-01");
159 } elsif ( $2 > 98 ) { #should pry change to check for "this year"
160 $self->exp("19$2-$1-01");
162 $self->exp("20$2-$1-01");
165 return "Illegal expiration date";
169 if ( $self->payname eq '' ) {
170 $self->payname( $self->first. " ". $self->getfield('last') );
172 $self->payname =~ /^([\w \,\.\-\']+)$/
173 or return "Illegal billing name";
177 #$self->zip =~ /^\s*(\w[\w\-\s]{3,8}\w)\s*$/
178 # or return "Illegal zip: ". $self->zip;
181 $self->country =~ /^(\w\w)$/ or return "Illegal country: ". $self->country;
184 $error = $self->ut_zip('zip', $self->country);
185 return $error if $error;
187 #check invnum, custnum, ?
194 Returns the customer (see L<FS::cust_main>) for this batched credit card
201 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
206 Marks the corresponding event (see L<FS::cust_bill_event>) for this batched
207 credit card payment as retriable. Useful if the corresponding financial
208 institution account was declined for temporary reasons and/or a manual
211 Implementation details: For the named customer's invoice, changes the
212 statustext of the 'done' (without statustext) event to 'retriable.'
219 local $SIG{HUP} = 'IGNORE'; #Hmm
220 local $SIG{INT} = 'IGNORE';
221 local $SIG{QUIT} = 'IGNORE';
222 local $SIG{TERM} = 'IGNORE';
223 local $SIG{TSTP} = 'IGNORE';
224 local $SIG{PIPE} = 'IGNORE';
226 my $oldAutoCommit = $FS::UID::AutoCommit;
227 local $FS::UID::AutoCommit = 0;
230 my $cust_bill = qsearchs('cust_bill', { 'invnum' => $self->invnum } )
231 or return "event $self->eventnum references nonexistant invoice $self->invnum";
233 warn "cust_pay_batch->retriable working with self of " . $self->paybatchnum . " and invnum of " . $self->invnum;
234 my @cust_bill_event =
235 sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
237 $_->part_bill_event->eventcode =~ /\$cust_bill->batch_card/
238 && $_->status eq 'done'
241 $cust_bill->cust_bill_event;
242 # complain loudly if scalar(@cust_bill_event) > 1 ?
243 my $error = $cust_bill_event[0]->retriable;
245 # gah, even with transactions.
246 $dbh->commit if $oldAutoCommit; #well.
247 return "error marking invoice event retriable: $error";
265 eval "use Text::CSV_XS;";
269 my $fh = $param->{'filehandle'};
270 my $format = $param->{'format'};
271 my $paybatch = $param->{'paybatch'};
273 my $filetype; # CSV, Fixed80, Fixed264
275 my $formatre; # for Fixed.+
281 my $approved_condition;
282 my $declined_condition;
284 if ( $format eq 'csv-td_canada_trust-merchant_pc_batch' ) {
289 'paybatchnum', # Reference#: Invoice number of the transaction
290 'paid', # Amount: Amount of the transaction. Dollars and cents
291 # with no decimal entered.
292 '', # Card Type: 0 - MCrd, 1 - Visa, 2 - AMEX, 3 - Discover,
293 # 4 - Insignia, 5 - Diners/EnRoute, 6 - JCB
294 '_date', # Transaction Date: Date the Transaction was processed
295 'time', # Transaction Time: Time the transaction was processed
296 'payinfo', # Card Number: Card number for the transaction
297 '', # Expiry Date: Expiry date of the card
298 '', # Auth#: Authorization number entered for force post
300 'type', # Transaction Type: 0 - purchase, 40 - refund,
302 'result', # Processing Result: 3 - Approval,
303 # 4 - Declined/Amount over limit,
304 # 5 - Invalid/Expired/stolen card,
306 '', # Terminal ID: Terminal ID used to process the transaction
309 $end_condition = sub {
311 $hash->{'type'} eq '0BC';
315 my( $hash, $total) = @_;
316 $total = sprintf("%.2f", $total);
317 my $batch_total = sprintf("%.2f", $hash->{'paybatchnum'} / 100 );
318 return "Our total $total does not match bank total $batch_total!"
319 if $total != $batch_total;
325 $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
326 $hash->{'_date'} = timelocal( substr($hash->{'time'}, 4, 2),
327 substr($hash->{'time'}, 2, 2),
328 substr($hash->{'time'}, 0, 2),
329 substr($hash->{'_date'}, 6, 2),
330 substr($hash->{'_date'}, 4, 2)-1,
331 substr($hash->{'_date'}, 0, 4)-1900, );
334 $approved_condition = sub {
336 $hash->{'type'} eq '0' && $hash->{'result'} == 3;
339 $declined_condition = sub {
341 $hash->{'type'} eq '0' && ( $hash->{'result'} == 4
342 || $hash->{'result'} == 5 );
346 }elsif ( $format eq 'csv-chase_canada-E-xactBatch' ) {
351 '', # Internal(bank) id of the transaction
352 '', # Transaction Type: 00 - purchase, 01 - preauth,
353 # 02 - completion, 03 - forcepost,
354 # 04 - refund, 05 - auth,
355 # 06 - purchase corr, 07 - refund corr,
356 # 08 - void 09 - void return
357 '', # gateway used to process this transaction
358 'paid', # Amount: Amount of the transaction. Dollars and cents
359 # with decimal entered.
360 'auth', # Auth#: Authorization number (if approved)
361 'payinfo', # Card Number: Card number for the transaction
362 '', # Expiry Date: Expiry date of the card
363 '', # Cardholder Name
364 'bankcode', # Bank response code (3 alphanumeric)
365 'bankmess', # Bank response message
366 'etgcode', # ETG response code (2 alphanumeric)
367 'etgmess', # ETG response message
368 '', # Returned customer number for the transaction
369 'paybatchnum', # Reference#: paybatch number of the transaction
370 '', # Reference#: Invoice number of the transaction
371 'result', # Processing Result: Approved of Declined
374 $end_condition = sub {
381 $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'}); #hmmmm
382 $hash->{'_date'} = time; # got a better one?
383 $hash->{'payinfo'} = $cpb->{'payinfo'}
384 if( substr($hash->{'payinfo'}, -4) eq substr($cpb->{'payinfo'}, -4) );
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 };
533 &{$hook}(\%hash, $cust_pay_batch->hashref);
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>