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 {
380 $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'}); #hmmmm
381 $hash->{'_date'} = time; # got a better one?
384 $approved_condition = sub {
386 $hash->{'etgcode'} eq '00' && $hash->{'result'} eq "Approved";
389 $declined_condition = sub {
391 $hash->{'etgcode'} ne '00' # internal processing error
392 || ( $hash->{'result'} eq "Declined" );
396 }elsif ( $format eq 'PAP' ) {
398 $filetype = "Fixed264";
401 'recordtype', # We are interested in the 'D' or debit records
402 'batchnum', # Record#: batch number we used when sending the file
403 'datacenter', # Where in the bowels of the bank the data was processed
404 'paid', # Amount: Amount of the transaction. Dollars and cents
405 # with no decimal entered.
406 '_date', # Transaction Date: Date the Transaction was processed
407 'bank', # Routing information
408 'payinfo', # Account number for the transaction
409 'paybatchnum', # Reference#: Invoice number of the transaction
412 $formatre = '^(.).{19}(.{4})(.{3})(.{10})(.{6})(.{9})(.{12}).{110}(.{19}).{71}$';
414 $end_condition = sub {
416 $hash->{'recordtype'} eq 'W';
420 my( $hash, $total) = @_;
421 $total = sprintf("%.2f", $total);
422 my $batch_total = $hash->{'datacenter'}.$hash->{'paid'}.
423 substr($hash->{'_date'},0,1); # YUCK!
424 $batch_total = sprintf("%.2f", $batch_total / 100 );
425 return "Our total $total does not match bank total $batch_total!"
426 if $total != $batch_total;
432 $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
433 my $tmpdate = timelocal( 0,0,1,1,0,substr($hash->{'_date'}, 0, 3)+2000);
434 $tmpdate += 86400*(substr($hash->{'_date'}, 3, 3)-1) ;
435 $hash->{'_date'} = $tmpdate;
436 $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'bank'};
439 $approved_condition = sub {
443 $declined_condition = sub {
449 return "Unknown format $format";
452 my $csv = new Text::CSV_XS;
454 local $SIG{HUP} = 'IGNORE';
455 local $SIG{INT} = 'IGNORE';
456 local $SIG{QUIT} = 'IGNORE';
457 local $SIG{TERM} = 'IGNORE';
458 local $SIG{TSTP} = 'IGNORE';
459 local $SIG{PIPE} = 'IGNORE';
461 my $oldAutoCommit = $FS::UID::AutoCommit;
462 local $FS::UID::AutoCommit = 0;
465 my $pay_batch = qsearchs('pay_batch',{'batchnum'=> $paybatch});
466 unless ($pay_batch && $pay_batch->status eq 'I') {
467 $dbh->rollback if $oldAutoCommit;
468 return "batch $paybatch is not in transit";
471 my $newbatch = new FS::pay_batch { $pay_batch->hash };
472 $newbatch->status('R'); # Resolved
473 $newbatch->upload(time);
474 my $error = $newbatch->replace($pay_batch);
476 $dbh->rollback if $oldAutoCommit;
482 while ( defined($line=<$fh>) ) {
484 next if $line =~ /^\s*$/; #skip blank lines
486 if ($filetype eq "CSV") {
487 $csv->parse($line) or do {
488 $dbh->rollback if $oldAutoCommit;
489 return "can't parse: ". $csv->error_input();
491 @values = $csv->fields();
492 }elsif ($filetype eq "Fixed80" || $filetype eq "Fixed264"){
493 @values = $line =~ /$formatre/;
495 $dbh->rollback if $oldAutoCommit;
496 return "can't parse: ". $line;
499 $dbh->rollback if $oldAutoCommit;
500 return "Unknown file type $filetype";
504 foreach my $field ( @fields ) {
505 my $value = shift @values;
507 $hash{$field} = $value;
510 if ( &{$end_condition}(\%hash) ) {
511 my $error = &{$end_hook}(\%hash, $total);
513 $dbh->rollback if $oldAutoCommit;
520 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
521 unless ( $cust_pay_batch ) {
522 $dbh->rollback if $oldAutoCommit;
523 return "unknown paybatchnum $hash{'paybatchnum'}\n";
525 my $custnum = $cust_pay_batch->custnum,
526 my $payby = $cust_pay_batch->payby,
528 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
532 if ( &{$approved_condition}(\%hash) ) {
534 $new_cust_pay_batch->status('Approved');
536 my $cust_pay = new FS::cust_pay ( {
537 'custnum' => $custnum,
539 'paybatch' => $paybatch,
540 map { $_ => $hash{$_} } (qw( paid _date payinfo )),
542 $error = $cust_pay->insert;
544 $dbh->rollback if $oldAutoCommit;
545 return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
547 $total += $hash{'paid'};
549 $cust_pay->cust_main->apply_payments;
551 } elsif ( &{$declined_condition}(\%hash) ) {
553 $new_cust_pay_batch->status('Declined');
555 foreach my $part_bill_event ( due_events ( $new_cust_pay_batch,
560 # don't run subsequent events if balance<=0
561 last if $cust_pay_batch->cust_main->balance <= 0;
563 if (my $error = $part_bill_event->do_event($new_cust_pay_batch)) {
564 # gah, even with transactions.
565 $dbh->commit if $oldAutoCommit; #well.
573 my $error = $new_cust_pay_batch->replace($cust_pay_batch);
575 $dbh->rollback if $oldAutoCommit;
576 return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
581 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
590 There should probably be a configuration file with a list of allowed credit
595 L<FS::cust_main>, L<FS::Record>