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 'PAP' ) {
351 $filetype = "Fixed264";
354 'recordtype', # We are interested in the 'D' or debit records
355 'batchnum', # Record#: batch number we used when sending the file
356 'datacenter', # Where in the bowels of the bank the data was processed
357 'paid', # Amount: Amount of the transaction. Dollars and cents
358 # with no decimal entered.
359 '_date', # Transaction Date: Date the Transaction was processed
360 'bank', # Routing information
361 'payinfo', # Account number for the transaction
362 'paybatchnum', # Reference#: Invoice number of the transaction
365 $formatre = '^(.).{19}(.{4})(.{3})(.{10})(.{6})(.{9})(.{12}).{110}(.{19}).{71}$';
367 $end_condition = sub {
369 $hash->{'recordtype'} eq 'W';
373 my( $hash, $total) = @_;
374 $total = sprintf("%.2f", $total);
375 my $batch_total = $hash->{'datacenter'}.$hash->{'paid'}.
376 substr($hash->{'_date'},0,1); # YUCK!
377 $batch_total = sprintf("%.2f", $batch_total / 100 );
378 return "Our total $total does not match bank total $batch_total!"
379 if $total != $batch_total;
385 $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
386 my $tmpdate = timelocal( 0,0,1,1,0,substr($hash->{'_date'}, 0, 3)+2000);
387 $tmpdate += 86400*(substr($hash->{'_date'}, 3, 3)-1) ;
388 $hash->{'_date'} = $tmpdate;
389 $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'bank'};
392 $approved_condition = sub {
396 $declined_condition = sub {
402 return "Unknown format $format";
405 my $csv = new Text::CSV_XS;
407 local $SIG{HUP} = 'IGNORE';
408 local $SIG{INT} = 'IGNORE';
409 local $SIG{QUIT} = 'IGNORE';
410 local $SIG{TERM} = 'IGNORE';
411 local $SIG{TSTP} = 'IGNORE';
412 local $SIG{PIPE} = 'IGNORE';
414 my $oldAutoCommit = $FS::UID::AutoCommit;
415 local $FS::UID::AutoCommit = 0;
418 my $pay_batch = qsearchs('pay_batch',{'batchnum'=> $paybatch});
419 unless ($pay_batch && $pay_batch->status eq 'I') {
420 $dbh->rollback if $oldAutoCommit;
421 return "batch $paybatch is not in transit";
424 my $newbatch = new FS::pay_batch { $pay_batch->hash };
425 $newbatch->status('R'); # Resolved
426 $newbatch->upload(time);
427 my $error = $newbatch->replace($pay_batch);
429 $dbh->rollback if $oldAutoCommit;
435 while ( defined($line=<$fh>) ) {
437 next if $line =~ /^\s*$/; #skip blank lines
439 if ($filetype eq "CSV") {
440 $csv->parse($line) or do {
441 $dbh->rollback if $oldAutoCommit;
442 return "can't parse: ". $csv->error_input();
444 @values = $csv->fields();
445 }elsif ($filetype eq "Fixed80" || $filetype eq "Fixed264"){
446 @values = $line =~ /$formatre/;
448 $dbh->rollback if $oldAutoCommit;
449 return "can't parse: ". $line;
452 $dbh->rollback if $oldAutoCommit;
453 return "Unknown file type $filetype";
457 foreach my $field ( @fields ) {
458 my $value = shift @values;
460 $hash{$field} = $value;
463 if ( &{$end_condition}(\%hash) ) {
464 my $error = &{$end_hook}(\%hash, $total);
466 $dbh->rollback if $oldAutoCommit;
473 qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
474 unless ( $cust_pay_batch ) {
475 $dbh->rollback if $oldAutoCommit;
476 return "unknown paybatchnum $hash{'paybatchnum'}\n";
478 my $custnum = $cust_pay_batch->custnum,
479 my $payby = $cust_pay_batch->payby,
481 my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
485 if ( &{$approved_condition}(\%hash) ) {
487 $new_cust_pay_batch->status('Approved');
489 my $cust_pay = new FS::cust_pay ( {
490 'custnum' => $custnum,
492 'paybatch' => $paybatch,
493 map { $_ => $hash{$_} } (qw( paid _date payinfo )),
495 $error = $cust_pay->insert;
497 $dbh->rollback if $oldAutoCommit;
498 return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
500 $total += $hash{'paid'};
502 $cust_pay->cust_main->apply_payments;
504 } elsif ( &{$declined_condition}(\%hash) ) {
506 $new_cust_pay_batch->status('Declined');
508 foreach my $part_bill_event ( due_events ( $new_cust_pay_batch,
513 # don't run subsequent events if balance<=0
514 last if $cust_pay_batch->cust_main->balance <= 0;
516 if (my $error = $part_bill_event->do_event($new_cust_pay_batch)) {
517 # gah, even with transactions.
518 $dbh->commit if $oldAutoCommit; #well.
526 my $error = $new_cust_pay_batch->replace($cust_pay_batch);
528 $dbh->rollback if $oldAutoCommit;
529 return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
534 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
543 There should probably be a configuration file with a list of allowed credit
548 L<FS::cust_main>, L<FS::Record>