From: ivan Date: Tue, 6 Jul 2004 17:26:02 +0000 (+0000) Subject: payment voiding part deux & credit card refunds! X-Git-Tag: BEFORE_FINAL_MASONIZE~993 X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=4ae85517a9c3a8a2f61e87bc27a74eb616e396a4 payment voiding part deux & credit card refunds! --- diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index c553f0c22..c4b214845 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -543,7 +543,7 @@ httemplate/docs/config.html { 'key' => 'invoice_send_receipts', 'section' => 'deprecated',q - 'description' => 'DEPRECATED, this used to send an invoice copy on payments and credits. See the payment_receipt_email and instead.', + 'description' => 'DEPRECATED, this used to send an invoice copy on payments and credits. See the payment_receipt_email and XXXX instead.', 'type' => 'checkbox', }, @@ -1269,7 +1269,12 @@ httemplate/docs/config.html 'type' => 'checkbox', }, - + { + 'key' => 'card_refund-days', + 'section' => 'billing', + 'description' => 'After a payment, the number of days a refund link will be available for that payment. Defaults to 120.', + 'type' => 'text', + }, ); diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index 740b483d5..cc9195913 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -21,6 +21,7 @@ use FS::cust_pkg; use FS::cust_bill; use FS::cust_bill_pkg; use FS::cust_pay; +use FS::cust_pay_void; use FS::cust_credit; use FS::cust_refund; use FS::part_referral; @@ -1775,7 +1776,7 @@ sub realtime_bop { } my $email = $invoicing_list[0]; - my %content; + my %content = (); if ( $method eq 'CC' ) { $content{card_number} = $self->payinfo; @@ -1808,8 +1809,7 @@ sub realtime_bop { my( $action1, $action2 ) = split(/\s*\,\s*/, $action ); - my $transaction = - new Business::OnlinePayment( $processor, @bop_options ); + my $transaction = new Business::OnlinePayment( $processor, @bop_options ); $transaction->content( 'type' => $method, 'login' => $login, @@ -1900,6 +1900,11 @@ sub realtime_bop { 'LEC' => 'LECB', ); + my $paybatch = "$processor:". $transaction->authorization; + $paybatch .= ':'. $transaction->order_number + if $transaction->can('order_number') + && length($transaction->order_number); + my $cust_pay = new FS::cust_pay ( { 'custnum' => $self->custnum, 'invnum' => $options{'invnum'}, @@ -1907,7 +1912,7 @@ sub realtime_bop { '_date' => '', 'payby' => $method2payby{$method}, 'payinfo' => $self->payinfo, - 'paybatch' => "$processor:". $transaction->authorization, + 'paybatch' => $paybatch, } ); my $error = $cust_pay->insert; if ( $error ) { @@ -1962,6 +1967,235 @@ sub realtime_bop { } +=item realtime_refund_bop METHOD [ OPTION => VALUE ... ] + +Refunds a realtime credit card, ACH (electronic check) or phone bill transaction +via a Business::OnlinePayment realtime gateway. See +L for supported gateways. + +Available methods are: I, I and I + +Available options are: I, I, I + +Most gateways require a reference to an original payment transaction to refund, +so you probably need to specify a I. + +I defaults to the original amount of the payment if not specified. + +I specifies a reason for the refund. + +Implementation note: If I is unspecified or equal to the amount of the +orignal payment, first an attempt is made to "void" the transaction via +the gateway (to cancel a not-yet settled transaction) and then if that fails, +the normal attempt is made to "refund" ("credit") the transaction via the +gateway is attempted. + +#The additional options I, I, I, I, I, +#I, I and I are also available. Any of these options, +#if set, will override the value from the customer record. + +#If an I is specified, this payment (if sucessful) is applied to the +#specified invoice. If you don't specify an I you might want to +#call the B method. + +=cut + +#some false laziness w/realtime_bop, not enough to make it worth merging +#but some useful small subs should be pulled out +sub realtime_refund_bop { + my( $self, $method, %options ) = @_; + if ( $DEBUG ) { + warn "$self $method refund\n"; + warn " $_ => $options{$_}\n" foreach keys %options; + } + + #pre-requisites + die "Real-time processing not enabled\n" + unless $conf->exists('business-onlinepayment'); + eval "use Business::OnlinePayment"; + die $@ if $@; + + ##overrides + #$self->set( $_ => $options{$_} ) + # foreach grep { exists($options{$_}) } + # qw( payname address1 address2 city state zip payinfo paydate paycvv); + + #load up config + my $bop_config = 'business-onlinepayment'; + $bop_config .= '-ach' + if $method eq 'ECHECK' && $conf->exists($bop_config. '-ach'); + my ( $processor, $login, $password, $unused_action, @bop_options ) = + $conf->config($bop_config); + #$action ||= 'normal authorization'; + pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/; + die "No real-time processor is enabled - ". + "did you set the business-onlinepayment configuration value?\n" + unless $processor; + + my $cust_pay = ''; + my $amount = $options{'amount'}; + my( $pay_processor, $auth, $order_number ); + if ( $options{'paynum'} ) { + warn "FS::cust_main::realtime_bop: paynum: $options{paynum}\n" if $DEBUG; + $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } ) + or return "Unknown paynum $options{'paynum'}"; + $amount ||= $cust_pay->paid; + $cust_pay->paybatch =~ /^(\w+):(\w+)(:(\w+))?$/ + or return "Can't parse paybatch for paynum $options{'paynum'}: ". + $cust_pay->paybatch; + ( $pay_processor, $auth, $order_number ) = ( $1, $2, $4 ); + return "processor of payment $options{'paynum'} $pay_processor does not". + " match current processor $processor" + unless $pay_processor eq $processor; + } + return "neither amount nor paynum specified" unless $amount; + + #first try void if applicable + if ( $cust_pay && $cust_pay->paid == $amount ) { #and check dates? + my $void = new Business::OnlinePayment( $processor, @bop_options ); + $void->content( + 'type' => $method, + 'action' => 'void', + 'login' => $login, + 'password' => $password, + 'order_number' => $order_number, + 'amount' => $amount, + 'authorization' => $auth, + 'referer' => 'http://cleanwhisker.420.am/', + ); + $void->submit(); + if ( $void->is_success ) { + my $error = $cust_pay->void($options{'reason'}); + if ( $error ) { + # gah, even with transactions. + my $e = 'WARNING: Card/ACH voided but database not updated - '. + "error voiding payment: $error"; + warn $e; + return $e; + } + return ''; + } + } + + #massage data + my $address = $self->address1; + $address .= ", ". $self->address2 if $self->address2; + + my($payname, $payfirst, $paylast); + if ( $self->payname && $method ne 'ECHECK' ) { + $payname = $self->payname; + $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/ + or return "Illegal payname $payname"; + ($payfirst, $paylast) = ($1, $2); + } else { + $payfirst = $self->getfield('first'); + $paylast = $self->getfield('last'); + $payname = "$payfirst $paylast"; + } + + my %content = (); + if ( $method eq 'CC' ) { + + $content{card_number} = $self->payinfo; + $self->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; + $content{expiration} = "$2/$1"; + + #$content{cvv2} = $self->paycvv + # if defined $self->dbdef_table->column('paycvv') + # && length($self->paycvv); + + #$content{recurring_billing} = 'YES' + # if qsearch('cust_pay', { 'custnum' => $self->custnum, + # 'payby' => 'CARD', + # 'payinfo' => $self->payinfo, } ); + + } elsif ( $method eq 'ECHECK' ) { + my($account_number,$routing_code) = $self->payinfo; + ( $content{account_number}, $content{routing_code} ) = + split('@', $self->payinfo); + $content{bank_name} = $self->payname; + $content{account_type} = 'CHECKING'; + $content{account_name} = $payname; + $content{customer_org} = $self->company ? 'B' : 'I'; + $content{customer_ssn} = $self->ss; + } elsif ( $method eq 'LEC' ) { + $content{phone} = $self->payinfo; + } + + #then try refund + my $refund = new Business::OnlinePayment( $processor, @bop_options ); + $refund->content( + 'type' => $method, + 'action' => 'credit', + 'login' => $login, + 'password' => $password, + 'order_number' => $order_number, + 'amount' => $amount, + 'authorization' => $auth, + 'customer_id' => $self->custnum, + 'last_name' => $paylast, + 'first_name' => $payfirst, + 'name' => $payname, + 'address' => $address, + 'city' => $self->city, + 'state' => $self->state, + 'zip' => $self->zip, + 'country' => $self->country, + 'referer' => 'http://cleanwhisker.420.am/', + %content, #after + ); + $refund->submit(); + + return "$processor error: ". $refund->error_message + unless $refund->is_success(); + + my %method2payby = ( + 'CC' => 'CARD', + 'ECHECK' => 'CHEK', + 'LEC' => 'LECB', + ); + + my $paybatch = "$processor:". $refund->authorization; + $paybatch .= ':'. $refund->order_number + if $refund->can('order_number') && $refund->order_number; + + while ( $cust_pay && $cust_pay->unappled < $amount ) { + my @cust_bill_pay = $cust_pay->cust_bill_pay; + last unless @cust_bill_pay; + my $cust_bill_pay = pop @cust_bill_pay; + my $error = $cust_bill_pay->delete; + last if $error; + } + + my $cust_refund = new FS::cust_refund ( { + 'custnum' => $self->custnum, + 'paynum' => $options{'paynum'}, + 'refund' => $amount, + '_date' => '', + 'payby' => $method2payby{$method}, + 'payinfo' => $self->payinfo, + 'paybatch' => $paybatch, + 'reason' => $options{'reason'} || 'card refund', + } ); + my $error = $cust_refund->insert; + if ( $error ) { + $cust_refund->paynum(''); #try again with no specific paynum + my $error2 = $cust_refund->insert; + if ( $error2 ) { + # gah, even with transactions. + my $e = 'WARNING: Card/ACH refunded but database not updated - '. + "error inserting refund ($processor): $error2". + " (previously tried insert with paynum #$options{'paynum'}" . + ": $error )"; + warn $e; + return $e; + } + } + + ''; #no error + +} + =item total_owed Returns the total owed for this customer on all invoices @@ -2516,6 +2750,19 @@ sub cust_pay { qsearch( 'cust_pay', { 'custnum' => $self->custnum } ) } +=item cust_pay_void + +Returns all voided payments (see L) for this customer. + +=cut + +sub cust_pay_void { + my $self = shift; + sort { $a->_date <=> $b->_date } + qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } ) +} + + =item cust_refund Returns all the refunds (see L) for this customer. diff --git a/FS/FS/cust_pay.pm b/FS/FS/cust_pay.pm index 3317a32bd..42e3e1e1f 100644 --- a/FS/FS/cust_pay.pm +++ b/FS/FS/cust_pay.pm @@ -396,7 +396,8 @@ payment. sub cust_bill_pay { my $self = shift; - sort { $a->_date <=> $b->_date } + sort { $a->_date <=> $b->_date + || $a->invnum <=> $b->invnum } qsearch( 'cust_bill_pay', { 'paynum' => $self->paynum } ) ; } @@ -432,6 +433,21 @@ sub unapplied { sprintf("%.2f", $amount ); } +=item unrefunded + +Returns the amount of this payment that has not been refuned; which is +paid minus all refund applications (see L). + +=cut + +sub unrefunded { + my $self = shift; + my $amount = $self->paid; + $amount -= $_->amount foreach ( $self->cust_pay_refund ); + sprintf("%.2f", $amount ); +} + + =item cust_main Returns the parent customer object (see L). diff --git a/httemplate/edit/cust_refund.cgi b/httemplate/edit/cust_refund.cgi new file mode 100755 index 000000000..425c17738 --- /dev/null +++ b/httemplate/edit/cust_refund.cgi @@ -0,0 +1,93 @@ + +<% + +my $conf = new FS::Conf; +my $custnum = $cgi->param('custnum'); +my $refund = $cgi->param('refund'); +my $payby = $cgi->param('payby'); +my $reason = $cgi->param('reason'); + +my( $paynum, $cust_pay ) = ( '', '' ); +if ( $cgi->param('paynum') =~ /^(\d+)$/ ) { + $paynum = $1; + $cust_pay = qsearchs('cust_pay', { paynum=>$paynum } ) + or die "unknown payment # $paynum"; + $refund ||= $cust_pay->unrefunded; + if ( $custnum ) { + die "payment # $paynum is not for specified customer # $custnum" + unless $custnum == $cust_pay->custnum; + } else { + $custnum = $cust_pay->custnum; + } +} +die "no custnum or paynum specified!" unless $custnum; + +my $_date = time; + +my $p1 = popurl(1); + +print header('Refund '. ucfirst(lc($payby)). ' payment', ''); +print qq!Error: !, $cgi->param('error'), + "" + if $cgi->param('error'); +print <config('countrydefault')); +
+ + + + + + + + +
+END + +if ( $cust_pay ) { + + #false laziness w/FS/FS/cust_pay.pm + my $payby = $cust_pay->payby; + my $payinfo = $cust_pay->payinfo; + $payby =~ s/^BILL$/Check/ if $payinfo; + $payinfo = $cust_pay->payinfo_masked if $payby eq 'CARD'; + + print '
Payment'. ntable("#cccccc", 2). + 'Amount$'. + $cust_pay->paid. ''. + 'Date'. + time2str("%D",$cust_pay->_date). ''. + 'Method'. + ucfirst(lc($payby)). ' # '. $payinfo. ''; + #false laziness w/FS/FS/cust_main::realtime_refund_bop + if ( $cust_pay->paybatch =~ /^(\w+):(\w+)(:(\w+))?$/ ) { + my ( $processor, $auth, $order_number ) = ( $1, $2, $4 ); + print 'Processor'. + $processor. ''; + print 'Authorization'. + $auth. '' + if length($auth); + print 'Order number'. + $order_number. '' + if length($order_number); + } + print ''; +} + +print '
Refund'. ntable("#cccccc", 2). + 'Date'. + time2str("%D",$_date). ''; + +print qq!Amount\$!; + +print qq!Reason!; + +print < +
+ + + + +END + +%> diff --git a/httemplate/edit/process/cust_credit.cgi b/httemplate/edit/process/cust_credit.cgi index ac92631f8..85bfd4489 100755 --- a/httemplate/edit/process/cust_credit.cgi +++ b/httemplate/edit/process/cust_credit.cgi @@ -3,12 +3,9 @@ $cgi->param('custnum') =~ /^(\d*)$/ or die "Illegal custnum!"; my $custnum = $1; -$cgi->param('otaker',getotaker); - my $new = new FS::cust_credit ( { map { $_, scalar($cgi->param($_)); - #} qw(custnum _date amount otaker reason) } fields('cust_credit') } ); @@ -26,5 +23,4 @@ if ( $error ) { print $cgi->redirect(popurl(3). "view/cust_main.cgi?$custnum"); } - %> diff --git a/httemplate/edit/process/cust_refund.cgi b/httemplate/edit/process/cust_refund.cgi new file mode 100755 index 000000000..fc1635781 --- /dev/null +++ b/httemplate/edit/process/cust_refund.cgi @@ -0,0 +1,37 @@ +<% + +$cgi->param('custnum') =~ /^(\d*)$/ or die "Illegal custnum!"; +my $custnum = $1; +my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ) + or die "unknown custnum $custnum"; + +my $error = ''; +if ( $cgi->param('payby') eq 'CARD' ) { + $cgi->param('refund') =~ /^(\d*)(\.\d{2})?$/ + or die "illegal refund amount ". $cgi->param('refund'); + my $refund = "$1$2"; + $cgi->param('paynum') =~ /^(\d*)$/ or die "Illegal paynum!"; + my $paynum = $1; + my $reason = $cgi->param('reason'); + $error = $cust_main->realtime_refund_bop( 'CC', 'amount' => $refund, + 'paynum' => $paynum, + 'reason' => $reason, ); +} else { + die 'unimplemented'; + #my $new = new FS::cust_refund ( { + # map { + # $_, scalar($cgi->param($_)); + # } ( fields('cust_refund'), 'paynum' ) + #} ); + #$error = $new->insert; +} + + +if ( $error ) { + $cgi->param('error', $error); + print $cgi->redirect(popurl(2). "cust_refund.cgi?". $cgi->query_string ); +} else { + print $cgi->redirect(popurl(3). "view/cust_main.cgi?$custnum"); +} + +%> diff --git a/httemplate/view/cust_main.cgi b/httemplate/view/cust_main.cgi index 125c51aef..a34ddc429 100755 --- a/httemplate/view/cust_main.cgi +++ b/httemplate/view/cust_main.cgi @@ -36,15 +36,15 @@ print qq!Edit this customer!; %> <% -print qq! | !. +print qq! | !. 'Cancel this customer' if $cust_main->ncancelled_pkgs; @@ -349,21 +349,6 @@ if ( $conf->config('payby-default') ne 'HIDE' ) { } -%> - - - -<% - print qq!Packages !, qq!( Order and cancel packages (preserves services) )!, ; @@ -544,29 +529,6 @@ print ''; #end display packages %> - - <% if ( $conf->config('payby-default') ne 'HIDE' ) { %>

Payment History
@@ -660,31 +622,77 @@ function cust_credit_areyousure(href) { } } + my $refund = ''; + my $refund_days = $conf->config('card_refund-days') || 120; + if ( $cust_pay->closed !~ /^Y/i + && $cust_pay->payby eq 'CARD' + && time-$cust_pay->_date < $refund_days*86400 + && $cust_pay->unrefunded > 0 + ) { + $refund = qq! (refund)!; + } + + my $void = ''; + if ( $cust_pay->closed !~ /^Y/i + && $cust_pay->payby ne 'CARD' + ) { + $void = qq! (!. + qq!void)!; + } + my $delete = ''; if ( $cust_pay->closed !~ /^Y/i && $conf->exists('deletepayments') ) { - $delete = qq! (paynum. - qq!')">delete)!; + qq!', 'Are you sure you want to delete this payment?')">!. + qq!delete)!; } my $unapply = ''; if ( $cust_pay->closed !~ /^Y/i && $conf->exists('unapplypayments') && scalar(@cust_bill_pay) ) { - $unapply = qq! (paynum. - qq!')">unapply)!; + qq!', 'Are you sure you want to unapply this payment?')">!. + qq!unapply)!; } push @history, { 'date' => $cust_pay->_date, 'desc' => $pre. "Payment$post$info$desc". - "$apply$delete$unapply", + "$apply$refund$void$delete$unapply", 'payment' => $cust_pay->paid, 'target' => $target, }; } + #voided payments + foreach my $cust_pay_void ($cust_main->cust_pay_void) { + + my $payby = $cust_pay_void->payby; + my $payinfo = $payby eq 'CARD' + ? $cust_pay_void->payinfo_masked + : $cust_pay_void->payinfo; + + $payby =~ s/^BILL$/Check #/ if $payinfo; + $payby =~ s/^BILL$//; + $payby =~ s/^(CARD|COMP)$/$1 /; + my $info = $payby ? " ($payby$payinfo)" : ''; + + push @history, { + 'date' => $cust_pay_void->_date, + 'desc' => "Payment $info voided ". + time2str("%D", $cust_pay_void->void_date). + " by ". $cust_pay_void->otaker. '', + 'void_payment' => $cust_pay_void->paid, + }; + + } + #credits (some false laziness w/payments) foreach my $cust_credit ($cust_main->cust_credit) { @@ -740,18 +748,20 @@ function cust_credit_areyousure(href) { # my $delete = ''; if ( $cust_credit->closed !~ /^Y/i && $conf->exists('deletecredits') ) { - $delete = qq! (crednum. - qq!')">delete)!; + qq!', 'Are you sure you want to delete this credit?')">!. + qq!delete)!; } my $unapply = ''; if ( $cust_credit->closed !~ /^Y/i && $conf->exists('unapplycredits') && scalar(@cust_credit_bill) ) { - $unapply = qq! (crednum. - qq!')">unapply)!; + qq!', 'Are you sure you want to unapply this credit?')">!. + qq!unapply)!; } push @history, { @@ -809,6 +819,8 @@ function cust_credit_areyousure(href) { my $payment = exists($item->{'payment'}) ? sprintf('- $%.2f', $item->{'payment'}) : ''; + $payment ||= sprintf('- $%.2f', $item->{'void_payment'}) + if exists($item->{'void_payment'}); my $credit = exists($item->{'credit'}) ? sprintf('- $%.2f', $item->{'credit'}) : ''; @@ -976,7 +988,8 @@ sub svc_provision_link { sub svc_unprovision_link { my $svc = shift or return ''; - return qq!Unprovision!; + qq!Unprovision!; } # This should be generalized to use config options to determine order. @@ -1004,7 +1017,8 @@ sub pkg_datestr { sub pkg_change_link { my $pkg = shift or return ''; - return qq!Change package!; + return qq!!. + qq!Change package!; } sub pkg_suspend_link { @@ -1019,19 +1033,22 @@ sub pkg_unsuspend_link { sub pkg_cancel_link { my $pkg = shift or return ''; - qq!Cancel now | !. + qq!!. + qq!Cancel now | !. qq!Cancel later!; } sub pkg_dates_link { my $pkg = shift or return ''; - return qq!Edit dates!; + qq!Edit dates!; } sub pkg_customize_link { my $pkg = shift or return ''; my $custnum = shift; - return qq!Customize!; + qq!Customize!; } %>