use strict;
use vars qw( @ISA $DEBUG );
+use Carp qw( confess );
+use Business::CreditCard 0.28;
use FS::Record qw(dbh qsearch qsearchs);
-use Business::CreditCard;
+use FS::payinfo_Mixin;
+use FS::cust_main;
+use FS::cust_bill;
-@ISA = qw( FS::Record );
+@ISA = qw( FS::payinfo_Mixin FS::cust_main_Mixin FS::Record );
# 1 is mostly method/subroutine entry and options
# 2 traces progress of some operations
$error = $record->check;
+ #deprecated# $error = $record->retriable;
+
=head1 DESCRIPTION
An FS::cust_pay_batch object represents a credit card transaction ready to be
=item country
-=item status
+=item status - 'Approved' or 'Declined'
+
+=item error_message - the error returned by the gateway if any
+
+=item failure_status - the normalized L<Business::BatchPayment> failure
+status, if any
=back
Checks all fields to make sure this is a valid transaction. If there is
an error, returns the error, otherwise returns false. Called by the insert
-and repalce methods.
+and replace methods.
=cut
my $error =
$self->ut_numbern('paybatchnum')
- || $self->ut_numbern('trancode') #depriciated
+ || $self->ut_numbern('trancode') #deprecated
|| $self->ut_money('amount')
|| $self->ut_number('invnum')
|| $self->ut_number('custnum')
$self->first =~ /^([\w \,\.\-\']+)$/ or return "Illegal first name";
$self->first($1);
- $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREP|CASH|WEST|MCRD)$/
- or return "Illegal payby";
- $self->payby($1);
-
- # FIXME
- # there is no point in false laziness here
- # we will effectively set "check_payinfo to 0"
- # we can change that when we finish the refactor
-
- #my $cardnum = $self->cardnum;
- #$cardnum =~ s/\D//g;
- #$cardnum =~ /^(\d{13,16})$/
- # or return "Illegal credit card number";
- #$cardnum = $1;
- #$self->cardnum($cardnum);
- #validate($cardnum) or return "Illegal credit card number";
- #return "Unknown card type" if cardtype($cardnum) eq "Unknown";
+ $error = $self->payinfo_check();
+ return $error if $error;
if ( $self->exp eq '' ) {
return "Expiration date required"
$self->payname($1);
}
- #$self->zip =~ /^\s*(\w[\w\-\s]{3,8}\w)\s*$/
- # or return "Illegal zip: ". $self->zip;
- #$self->zip($1);
+ #we have lots of old zips in there... don't hork up batch results cause of em
+ $self->zip =~ /^\s*(\w[\w\-\s]{2,8}\w)\s*$/
+ or return "Illegal zip: ". $self->zip;
+ $self->zip($1);
$self->country =~ /^(\w\w)$/ or return "Illegal country: ". $self->country;
$self->country($1);
- $error = $self->ut_zip('zip', $self->country);
- return $error if $error;
+ #$error = $self->ut_zip('zip', $self->country);
+ #return $error if $error;
#check invnum, custnum, ?
qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
}
-=back
+=item expmmyy
-=head1 SUBROUTINES
+Returns the credit card expiration date in MMYY format. If this is a
+CHEK payment, returns an empty string.
-=over 4
+=cut
-=item import_results
+sub expmmyy {
+ my $self = shift;
+ if ( $self->payby eq 'CARD' ) {
+ $self->get('exp') =~ /^(\d{4})-(\d{2})-(\d{2})$/;
+ return sprintf('%02u%02u', $2, ($1 % 100));
+ }
+ else {
+ return '';
+ }
+}
+
+=item pay_batch
+
+Returns the payment batch this payment belongs to (L<FS::pay_batch).
=cut
-sub import_results {
- use Time::Local;
- use FS::cust_pay;
- eval "use Text::CSV_XS;";
- die $@ if $@;
+sub pay_batch {
+ my $self = shift;
+ FS::pay_batch->by_key($self->batchnum);
+}
+
+#you know what, screw this in the new world of events. we should be able to
+#get the event defs to retry (remove once.pm condition, add every.pm) without
+#mucking about with statuses of previous cust_event records. right?
+#
+#=item retriable
+#
+#Marks the corresponding event (see L<FS::cust_bill_event>) for this batched
+#credit card payment as retriable. Useful if the corresponding financial
+#institution account was declined for temporary reasons and/or a manual
+#retry is desired.
#
- my $param = shift;
- my $fh = $param->{'filehandle'};
- my $format = $param->{'format'};
- my $paybatch = $param->{'paybatch'};
-
- my $filetype; # CSV, Fixed80, Fixed264
- my @fields;
- my $formatre; # for Fixed.+
- my @values;
- my $begin_condition;
- my $end_condition;
- my $end_hook;
- my $hook;
- my $approved_condition;
- my $declined_condition;
-
- if ( $format eq 'csv-td_canada_trust-merchant_pc_batch' ) {
-
- $filetype = "CSV";
-
- @fields = (
- 'paybatchnum', # Reference#: Invoice number of the transaction
- 'paid', # Amount: Amount of the transaction. Dollars and cents
- # with no decimal entered.
- '', # Card Type: 0 - MCrd, 1 - Visa, 2 - AMEX, 3 - Discover,
- # 4 - Insignia, 5 - Diners/EnRoute, 6 - JCB
- '_date', # Transaction Date: Date the Transaction was processed
- 'time', # Transaction Time: Time the transaction was processed
- 'payinfo', # Card Number: Card number for the transaction
- '', # Expiry Date: Expiry date of the card
- '', # Auth#: Authorization number entered for force post
- # transaction
- 'type', # Transaction Type: 0 - purchase, 40 - refund,
- # 20 - force post
- 'result', # Processing Result: 3 - Approval,
- # 4 - Declined/Amount over limit,
- # 5 - Invalid/Expired/stolen card,
- # 6 - Comm Error
- '', # Terminal ID: Terminal ID used to process the transaction
- );
-
- $end_condition = sub {
- my $hash = shift;
- $hash->{'type'} eq '0BC';
- };
-
- $end_hook = sub {
- my( $hash, $total) = @_;
- $total = sprintf("%.2f", $total);
- my $batch_total = sprintf("%.2f", $hash->{'paybatchnum'} / 100 );
- return "Our total $total does not match bank total $batch_total!"
- if $total != $batch_total;
- '';
- };
-
- $hook = sub {
- my $hash = shift;
- $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
- $hash->{'_date'} = timelocal( substr($hash->{'time'}, 4, 2),
- substr($hash->{'time'}, 2, 2),
- substr($hash->{'time'}, 0, 2),
- substr($hash->{'_date'}, 6, 2),
- substr($hash->{'_date'}, 4, 2)-1,
- substr($hash->{'_date'}, 0, 4)-1900, );
- };
-
- $approved_condition = sub {
- my $hash = shift;
- $hash->{'type'} eq '0' && $hash->{'result'} == 3;
- };
-
- $declined_condition = sub {
- my $hash = shift;
- $hash->{'type'} eq '0' && ( $hash->{'result'} == 4
- || $hash->{'result'} == 5 );
- };
-
-
- }elsif ( $format eq 'PAP' ) {
-
- $filetype = "Fixed264";
-
- @fields = (
- 'recordtype', # We are interested in the 'D' or debit records
- 'batchnum', # Record#: batch number we used when sending the file
- 'datacenter', # Where in the bowels of the bank the data was processed
- 'paid', # Amount: Amount of the transaction. Dollars and cents
- # with no decimal entered.
- '_date', # Transaction Date: Date the Transaction was processed
- 'bank', # Routing information
- 'payinfo', # Account number for the transaction
- 'paybatchnum', # Reference#: Invoice number of the transaction
- );
-
- $formatre = '^(.).{19}(.{4})(.{3})(.{10})(.{6})(.{9})(.{12}).{110}(.{19}).{71}$';
-
- $end_condition = sub {
- my $hash = shift;
- $hash->{'recordtype'} eq 'W';
- };
-
- $end_hook = sub {
- my( $hash, $total) = @_;
- $total = sprintf("%.2f", $total);
- my $batch_total = $hash->{'datacenter'}.$hash->{'paid'}.
- substr($hash->{'_date'},0,1); # YUCK!
- $batch_total = sprintf("%.2f", $batch_total / 100 );
- return "Our total $total does not match bank total $batch_total!"
- if $total != $batch_total;
- '';
- };
-
- $hook = sub {
- my $hash = shift;
- $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
- my $tmpdate = timelocal( 0,0,1,1,0,substr($hash->{'_date'}, 0, 3)+2000);
- $tmpdate += 86400*(substr($hash->{'_date'}, 3, 3)-1) ;
- $hash->{'_date'} = $tmpdate;
- $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'bank'};
- };
-
- $approved_condition = sub {
- 1;
- };
-
- $declined_condition = sub {
- 0;
- };
+#Implementation details: For the named customer's invoice, changes the
+#statustext of the 'done' (without statustext) event to 'retriable.'
+#
+#=cut
+sub retriable {
- } else {
- return "Unknown format $format";
- }
+ confess "deprecated method cust_pay_batch->retriable called; try removing ".
+ "the once condition and adding an every condition?";
- my $csv = new Text::CSV_XS;
+ my $self = shift;
- local $SIG{HUP} = 'IGNORE';
+ local $SIG{HUP} = 'IGNORE'; #Hmm
local $SIG{INT} = 'IGNORE';
local $SIG{QUIT} = 'IGNORE';
local $SIG{TERM} = 'IGNORE';
local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
- my $pay_batch = qsearchs('pay_batch',{'batchnum'=> $paybatch});
- unless ($pay_batch && $pay_batch->status eq 'I') {
- $dbh->rollback if $oldAutoCommit;
- return "batch $paybatch is not in transit";
- };
-
- my $newbatch = new FS::pay_batch { $pay_batch->hash };
- $newbatch->status('R'); # Resolved
- $newbatch->upload(time);
- my $error = $newbatch->replace($pay_batch);
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return $error
+ my $cust_bill = qsearchs('cust_bill', { 'invnum' => $self->invnum } )
+ or return "event $self->eventnum references nonexistant invoice $self->invnum";
+
+ warn "cust_pay_batch->retriable working with self of " . $self->paybatchnum . " and invnum of " . $self->invnum;
+ my @cust_bill_event =
+ sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
+ grep {
+ $_->part_bill_event->eventcode =~ /\$cust_bill->batch_card/
+ && $_->status eq 'done'
+ && ! $_->statustext
+ }
+ $cust_bill->cust_bill_event;
+ # complain loudly if scalar(@cust_bill_event) > 1 ?
+ my $error = $cust_bill_event[0]->retriable;
+ if ($error ) {
+ # gah, even with transactions.
+ $dbh->commit if $oldAutoCommit; #well.
+ return "error marking invoice event retriable: $error";
}
+ '';
+}
- my $total = 0;
- my $line;
- while ( defined($line=<$fh>) ) {
-
- next if $line =~ /^\s*$/; #skip blank lines
-
- if ($filetype eq "CSV") {
- $csv->parse($line) or do {
- $dbh->rollback if $oldAutoCommit;
- return "can't parse: ". $csv->error_input();
- };
- @values = $csv->fields();
- }elsif ($filetype eq "Fixed80" || $filetype eq "Fixed264"){
- @values = $line =~ /$formatre/;
- unless (@values) {
- $dbh->rollback if $oldAutoCommit;
- return "can't parse: ". $line;
- };
- }else{
- $dbh->rollback if $oldAutoCommit;
- return "Unknown file type $filetype";
- }
+=item approve OPTIONS
- my %hash;
- foreach my $field ( @fields ) {
- my $value = shift @values;
- next unless $field;
- $hash{$field} = $value;
- }
+Approve this payment. This will replace the existing record with the
+same paybatchnum, set its status to 'Approved', and generate a payment
+record (L<FS::cust_pay>). This should only be called from the batch
+import process.
- if ( &{$end_condition}(\%hash) ) {
- my $error = &{$end_hook}(\%hash, $total);
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return $error;
- }
- last;
- }
+OPTIONS may contain "gatewaynum", "processor", "auth", and "order_number".
- my $cust_pay_batch =
- qsearchs('cust_pay_batch', { 'paybatchnum' => $hash{'paybatchnum'}+0 } );
- unless ( $cust_pay_batch ) {
- $dbh->rollback if $oldAutoCommit;
- return "unknown paybatchnum $hash{'paybatchnum'}\n";
- }
- my $custnum = $cust_pay_batch->custnum,
- my $payby = $cust_pay_batch->payby,
+=cut
- my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash };
+sub approve {
+ # to break up the Big Wall of Code that is import_results
+ my $new = shift;
+ my %opt = @_;
+ my $paybatchnum = $new->paybatchnum;
+ my $old = qsearchs('cust_pay_batch', { paybatchnum => $paybatchnum })
+ or return "paybatchnum $paybatchnum not found";
+ # leave these restrictions in place until TD EFT is converted over
+ # to B::BP
+ return "paybatchnum $paybatchnum already resolved ('".$old->status."')"
+ if $old->status;
+ $new->status('Approved');
+ my $error = $new->replace($old);
+ if ( $error ) {
+ return "error updating status of paybatchnum $paybatchnum: $error\n";
+ }
+ my $cust_pay = new FS::cust_pay ( {
+ 'custnum' => $new->custnum,
+ 'payby' => $new->payby,
+ 'payinfo' => $new->payinfo || $old->payinfo,
+ 'paid' => $new->paid,
+ '_date' => $new->_date,
+ 'usernum' => $new->usernum,
+ 'batchnum' => $new->batchnum,
+ 'gatewaynum' => $opt{'gatewaynum'},
+ 'processor' => $opt{'processor'},
+ 'auth' => $opt{'auth'},
+ 'order_number' => $opt{'order_number'}
+ } );
+
+ $error = $cust_pay->insert;
+ if ( $error ) {
+ return "error inserting payment for paybatchnum $paybatchnum: $error\n";
+ }
+ $cust_pay->cust_main->apply_payments;
+ return;
+}
- &{$hook}(\%hash);
+=item decline [ REASON [ STATUS ] ]
- if ( &{$approved_condition}(\%hash) ) {
+Decline this payment. This will replace the existing record with the
+same paybatchnum, set its status to 'Declined', and run collection events
+as appropriate. This should only be called from the batch import process.
- $new_cust_pay_batch->status('Approved');
+REASON is a string description of the decline reason, defaulting to
+'Returned payment', and will go into the "error_message" field.
- my $cust_pay = new FS::cust_pay ( {
- 'custnum' => $custnum,
- 'payby' => $payby,
- 'paybatch' => $paybatch,
- map { $_ => $hash{$_} } (qw( paid _date payinfo )),
- } );
- $error = $cust_pay->insert;
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return "error adding payment paybatchnum $hash{'paybatchnum'}: $error\n";
- }
- $total += $hash{'paid'};
-
- $cust_pay->cust_main->apply_payments;
-
- } elsif ( &{$declined_condition}(\%hash) ) {
-
- $new_cust_pay_batch->status('Declined');
-
- #this should be configurable... if anybody else ever uses batches
- # $cust_pay_batch->cust_main->suspend;
-
- foreach my $part_bill_event (
- sort { $a->seconds <=> $b->seconds
- || $a->weight <=> $b->weight
- || $a->eventpart <=> $b->eventpart }
- grep { ! qsearch( 'cust_bill_event', {
- 'invnum' => $cust_pay_batch->invnum,
- 'eventpart' => $_->eventpart,
- 'status' => 'done',
- } )
- }
- qsearch( {
- 'table' => 'part_bill_event',
- 'hashref' => { 'payby' => 'DCLN',
- 'disabled' => '', },
- } )
- ) {
-
- # don't run subsequent events if balance<=0
- last if $cust_pay_batch->cust_main->balance <= 0;
-
- warn " calling invoice event (". $part_bill_event->eventcode. ")\n"
- if $DEBUG > 1;
- my $cust_main = $cust_pay_batch->cust_main; #for callback
-
- my $error;
- {
- local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
- $error = eval $part_bill_event->eventcode;
- }
-
- my $status = '';
- my $statustext = '';
- if ( $@ ) {
- $status = 'failed';
- $statustext = $@;
- } elsif ( $error ) {
- $status = 'done';
- $statustext = $error;
- } else {
- $status = 'done'
- }
-
- #add cust_bill_event
- my $cust_bill_event = new FS::cust_bill_event {
- 'invnum' => $cust_pay_batch->invnum,
- 'eventpart' => $part_bill_event->eventpart,
- '_date' => time,
- 'status' => $status,
- 'statustext' => $statustext,
- };
- $error = $cust_bill_event->insert;
- if ( $error ) {
- # gah, even with transactions.
- $dbh->commit if $oldAutoCommit; #well.
- my $e = 'WARNING: Event run but database not updated - '.
- 'error inserting cust_bill_event, invnum #'. $cust_pay_batch->invnum.
- ', eventpart '. $part_bill_event->eventpart.
- ": $error";
- warn $e;
- return $e;
- }
+STATUS is a normalized failure status defined by L<Business::BatchPayment>,
+and will go into the "failure_status" field.
- }
+=cut
+sub decline {
+ my $new = shift;
+ my $reason = shift || 'Returned payment';
+ my $failure_status = shift || '';
+ #my $conf = new FS::Conf;
+
+ my $paybatchnum = $new->paybatchnum;
+ my $old = qsearchs('cust_pay_batch', { paybatchnum => $paybatchnum })
+ or return "paybatchnum $paybatchnum not found";
+ if ( $old->status ) {
+ # Handle the case where payments are rejected after the batch has been
+ # approved. FS::pay_batch::import_results won't allow results to be
+ # imported to a closed batch unless batch-manual_approval is enabled,
+ # so we don't check it here.
+# if ( $conf->exists('batch-manual_approval') and
+ if ( lc($old->status) eq 'approved' ) {
+ # Void the payment
+ my $cust_pay = qsearchs('cust_pay', {
+ custnum => $new->custnum,
+ batchnum => $new->batchnum
+ });
+ # these should all be migrated over, but if it's not found, look for
+ # batchnum in the 'paybatch' field also
+ $cust_pay ||= qsearchs('cust_pay', {
+ custnum => $new->custnum,
+ paybatch => $new->batchnum
+ });
+ if ( !$cust_pay ) {
+ # should never happen...
+ return "failed to revoke paybatchnum $paybatchnum, payment not found";
+ }
+ $cust_pay->void($reason);
}
-
- my $error = $new_cust_pay_batch->replace($cust_pay_batch);
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
+ else {
+ # normal case: refuse to do anything
+ return "paybatchnum $paybatchnum already resolved ('".$old->status."')";
}
+ } # !$old->status
+ $new->status('Declined');
+ $new->error_message($reason);
+ $new->failure_status($failure_status);
+ my $error = $new->replace($old);
+ if ( $error ) {
+ return "error updating status of paybatchnum $paybatchnum: $error\n";
+ }
+ my $due_cust_event = $new->cust_main->due_cust_event(
+ 'eventtable' => 'cust_pay_batch',
+ 'objects' => [ $new ],
+ );
+ if ( !ref($due_cust_event) ) {
+ return $due_cust_event;
+ }
+ # XXX breaks transaction integrity
+ foreach my $cust_event (@$due_cust_event) {
+ next unless $cust_event->test_conditions;
+ if ( my $error = $cust_event->do_event() ) {
+ return $error;
+ }
+ }
+ return;
+}
+
+=item request_item [ OPTIONS ]
+
+Returns a L<Business::BatchPayment::Item> object for this batch payment
+entry. This can be submitted to a processor.
+OPTIONS can be a list of key/values to append to the attributes. The most
+useful case of this is "process_date" to set a processing date based on the
+date the batch is being submitted.
+
+=cut
+
+sub request_item {
+ local $@;
+ my $self = shift;
+
+ eval "use Business::BatchPayment;";
+ die "couldn't load Business::BatchPayment: $@" if $@;
+
+ my $cust_main = $self->cust_main;
+ my $location = $cust_main->bill_location;
+ my $pay_batch = $self->pay_batch;
+
+ my %payment;
+ $payment{payment_type} = FS::payby->payby2bop( $pay_batch->payby );
+ if ( $payment{payment_type} eq 'CC' ) {
+ $payment{card_number} = $self->payinfo,
+ $payment{expiration} = $self->expmmyy,
+ } elsif ( $payment{payment_type} eq 'ECHECK' ) {
+ $self->payinfo =~ /(\d+)@(\d+)/; # or else what?
+ $payment{account_number} = $1;
+ $payment{routing_code} = $2;
+ $payment{account_type} = $cust_main->paytype;
+ # XXX what if this isn't their regular payment method?
+ } else {
+ die "unsupported BatchPayment method: ".$pay_batch->payby;
}
-
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
- '';
+ Business::BatchPayment->create(Item =>
+ # required
+ action => 'payment',
+ tid => $self->paybatchnum,
+ amount => $self->amount,
+
+ # customer info
+ customer_id => $self->custnum,
+ first_name => $cust_main->first,
+ last_name => $cust_main->last,
+ company => $cust_main->company,
+ address => $location->address1,
+ ( map { $_ => $location->$_ } qw(address2 city state country zip) ),
+
+ invoice_number => $self->invnum,
+ %payment,
+ );
}
=back