payment voiding part deux & credit card refunds!
authorivan <ivan>
Tue, 6 Jul 2004 17:26:02 +0000 (17:26 +0000)
committerivan <ivan>
Tue, 6 Jul 2004 17:26:02 +0000 (17:26 +0000)
FS/FS/Conf.pm
FS/FS/cust_main.pm
FS/FS/cust_pay.pm
httemplate/edit/cust_refund.cgi [new file with mode: 0755]
httemplate/edit/process/cust_credit.cgi
httemplate/edit/process/cust_refund.cgi [new file with mode: 0755]
httemplate/view/cust_main.cgi

index c553f0c..c4b2148 100644 (file)
@@ -543,7 +543,7 @@ httemplate/docs/config.html
   {
     'key'         => 'invoice_send_receipts',
     'section'     => 'deprecated',q
-    'description' => '<b>DEPRECATED</b>, this used to send an invoice copy on payments and credits.  See the payment_receipt_email and  instead.',
+    'description' => '<b>DEPRECATED</b>, 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',
+  },
 
 );
 
index 740b483..cc91959 100644 (file)
@@ -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<http://420.am/business-onlinepayment> for supported gateways.
+
+Available methods are: I<CC>, I<ECHECK> and I<LEC>
+
+Available options are: I<amount>, I<reason>, I<paynum>
+
+Most gateways require a reference to an original payment transaction to refund,
+so you probably need to specify a I<paynum>.
+
+I<amount> defaults to the original amount of the payment if not specified.
+
+I<reason> specifies a reason for the refund.
+
+Implementation note: If I<amount> 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<payname>, I<address1>, I<address2>, I<city>, I<state>,
+#I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
+#if set, will override the value from the customer record.
+
+#If an I<invnum> is specified, this payment (if sucessful) is applied to the
+#specified invoice.  If you don't specify an I<invnum> you might want to
+#call the B<apply_payments> 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<FS::cust_pay_void>) 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<FS::cust_refund>) for this customer.
index 3317a32..42e3e1e 100644 (file)
@@ -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<FS::cust_pay_refund>).
+
+=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<FS::cust_main>).
diff --git a/httemplate/edit/cust_refund.cgi b/httemplate/edit/cust_refund.cgi
new file mode 100755 (executable)
index 0000000..425c177
--- /dev/null
@@ -0,0 +1,93 @@
+<!-- mason kludge -->
+<%
+
+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!<FONT SIZE="+1" COLOR="#ff0000">Error: !, $cgi->param('error'),
+      "</FONT>"
+  if $cgi->param('error');
+print <<END, small_custview($custnum, $conf->config('countrydefault'));
+    <FORM ACTION="${p1}process/cust_refund.cgi" METHOD=POST>
+    <INPUT TYPE="hidden" NAME="refundnum" VALUE="">
+    <INPUT TYPE="hidden" NAME="custnum" VALUE="$custnum">
+    <INPUT TYPE="hidden" NAME="paynum" VALUE="$paynum">
+    <INPUT TYPE="hidden" NAME="_date" VALUE="$_date">
+    <INPUT TYPE="hidden" NAME="payby" VALUE="$payby">
+    <INPUT TYPE="hidden" NAME="payinfo" VALUE="">
+    <INPUT TYPE="hidden" NAME="paybatch" VALUE="">
+    <INPUT TYPE="hidden" NAME="credited" VALUE="">
+    <BR>
+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 '<BR>Payment'. ntable("#cccccc", 2).
+        '<TR><TD ALIGN="right">Amount</TD><TD BGCOLOR="#ffffff">$'.
+          $cust_pay->paid. '</TD></TR>'.
+        '<TR><TD ALIGN="right">Date</TD><TD BGCOLOR="#ffffff">'.
+          time2str("%D",$cust_pay->_date). '</TD></TR>'.
+        '<TR><TD ALIGN="right">Method</TD><TD BGCOLOR="#ffffff">'.
+          ucfirst(lc($payby)). ' # '. $payinfo. '</TD></TR>';
+  #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 '<TR><TD ALIGN="right">Processor</TD><TD BGCOLOR="#ffffff">'.
+          $processor. '</TD></TR>';
+    print '<TR><TD ALIGN="right">Authorization</TD><TD BGCOLOR="#ffffff">'.
+          $auth. '</TD></TR>'
+      if length($auth);
+    print '<TR><TD ALIGN="right">Order number</TD><TD BGCOLOR="#ffffff">'.
+          $order_number. '</TD></TR>'
+      if length($order_number);
+  }
+  print '</TABLE>';
+}
+
+print '<BR>Refund'. ntable("#cccccc", 2).
+      '<TR><TD ALIGN="right">Date</TD><TD BGCOLOR="#ffffff">'.
+      time2str("%D",$_date). '</TD></TR>';
+
+print qq!<TR><TD ALIGN="right">Amount</TD><TD BGCOLOR="#ffffff">\$<INPUT TYPE="text" NAME="refund" VALUE="$refund" SIZE=8 MAXLENGTH=8></TD></TR>!;
+
+print qq!<TR><TD ALIGN="right">Reason</TD><TD BGCOLOR="#ffffff"><INPUT TYPE="text" NAME="reason" VALUE="$reason"></TD></TR>!;
+
+print <<END;
+</TABLE>
+<BR>
+<INPUT TYPE="submit" VALUE="Post refund">
+    </FORM>
+  </BODY>
+</HTML>
+END
+
+%>
index ac92631..85bfd44 100755 (executable)
@@ -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 (executable)
index 0000000..fc16357
--- /dev/null
@@ -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");
+}
+
+%>
index 125c51a..a34ddc4 100755 (executable)
@@ -36,15 +36,15 @@ print qq!<A HREF="${p}edit/cust_main.cgi?$custnum">Edit this customer</A>!;
 %>
 
 <SCRIPT>
-function cancel_areyousure(href) {
-    if (confirm("Perminantly delete all services and cancel this customer?") == true)
+function areyousure(href, message) {
+    if (confirm(message) == true)
         window.location.href = href;
 }
 </SCRIPT>
 
 <%
 
-print qq! | <A HREF="javascript:cancel_areyousure('${p}misc/cust_main-cancel.cgi?$custnum')">!.
+print qq! | <A HREF="javascript:areyousure('${p}misc/cust_main-cancel.cgi?$custnum', 'Perminantly delete all services and cancel this customer?')">!.
       'Cancel this customer</A>'
   if $cust_main->ncancelled_pkgs;
 
@@ -349,21 +349,6 @@ if ( $conf->config('payby-default') ne 'HIDE' ) {
 
 }
 
-%>
-
-<SCRIPT>
-function cust_pkg_areyousure(href) {
-    if (confirm("Permanently delete included services and cancel this package?") == true)
-        window.location.href = href;
-}
-function svc_areyousure(href) {
-    if (confirm("Permanently unprovision and delete this service?") == true)
-        window.location.href = href;
-}
-</SCRIPT>
-
-<%
-
 print qq!<A NAME="cust_pkg">Packages</A> !,
       qq!( <A HREF="!, popurl(2), qq!edit/cust_pkg.cgi?$custnum">Order and cancel packages</A> (preserves services) )!,
 ;
@@ -544,29 +529,6 @@ print '</TABLE>';
 #end display packages
 %>
 
-<SCRIPT>
-function cust_pay_areyousure(href) {
-    if (confirm("Are you sure you want to delete this payment?")
- == true)
-        window.location.href = href;
-}
-function cust_pay_unapply_areyousure(href) {
-    if (confirm("Are you sure you want to unapply this payment?")
- == true)
-        window.location.href = href;
-}
-function cust_credit_unapply_areyousure(href) {
-    if (confirm("Are you sure you want to unapply this credit?")
- == true)
-        window.location.href = href;
-}
-function cust_credit_areyousure(href) {
-    if (confirm("Are you sure you want to delete this credit?")
- == true)
-        window.location.href = href;
-}
-</SCRIPT>
-
 <% if ( $conf->config('payby-default') ne 'HIDE' ) { %>
   
   <BR><BR><A NAME="history"><FONT SIZE="+2">Payment History</FONT></A><BR>
@@ -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! (<A HREF="!. qq!${p}edit/cust_refund.cgi?payby=CARD;!.
+                qq!paynum=!. $cust_pay->paynum. qq!">refund</A>)!;
+    }
+
+    my $void = '';
+    if (    $cust_pay->closed !~ /^Y/i
+         && $cust_pay->payby ne 'CARD'
+       ) {
+      $void = qq! (<A HREF="javascript:areyousure('!.
+              qq!${p}misc/void-cust_pay.cgi?!. $cust_pay->paynum.
+              qq!', 'Are you sure you want to void this payment?')">!.
+              qq!void</A>)!;
+    }
+
     my $delete = '';
     if ( $cust_pay->closed !~ /^Y/i && $conf->exists('deletepayments') ) {
-      $delete = qq! (<A HREF="javascript:cust_pay_areyousure('!.
+      $delete = qq! (<A HREF="javascript:areyousure('!.
                 qq!${p}misc/delete-cust_pay.cgi?!. $cust_pay->paynum.
-                qq!')">delete</A>)!;
+                qq!', 'Are you sure you want to delete this payment?')">!.
+                qq!delete</A>)!;
     }
 
     my $unapply = '';
     if (    $cust_pay->closed !~ /^Y/i
          && $conf->exists('unapplypayments')
          && scalar(@cust_bill_pay)           ) {
-      $unapply = qq! (<A HREF="javascript:cust_pay_unapply_areyousure('!.
+      $unapply = qq! (<A HREF="javascript:areyousure('!.
                  qq!${p}misc/unapply-cust_pay.cgi?!. $cust_pay->paynum.
-                 qq!')">unapply</A>)!;
+                 qq!', 'Are you sure you want to unapply this payment?')">!.
+                 qq!unapply</A>)!;
     }
 
     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 <I>voided ".
+                  time2str("%D", $cust_pay_void->void_date).
+                  " by ". $cust_pay_void->otaker. '</i>',
+      '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! (<A HREF="javascript:cust_credit_areyousure('!.
+      $delete = qq! (<A HREF="javascript:areyousure('!.
                 qq!${p}misc/delete-cust_credit.cgi?!. $cust_credit->crednum.
-                qq!')">delete</A>)!;
+                qq!', 'Are you sure you want to delete this credit?')">!.
+                qq!delete</A>)!;
     }
     
     my $unapply = '';
     if (    $cust_credit->closed !~ /^Y/i
          && $conf->exists('unapplycredits')
          && scalar(@cust_credit_bill)       ) {
-      $unapply = qq! (<A HREF="javascript:cust_credit_unapply_areyousure('!.
+      $unapply = qq! (<A HREF="javascript:areyousure('!.
                  qq!${p}misc/unapply-cust_credit.cgi?!. $cust_credit->crednum.
-                 qq!')">unapply</A>)!;
+                 qq!', 'Are you sure you want to unapply this credit?')">!.
+                 qq!unapply</A>)!;
     }
     
     push @history, {
@@ -809,6 +819,8 @@ function cust_credit_areyousure(href) {
     my $payment = exists($item->{'payment'})
                     ? sprintf('-&nbsp;$%.2f', $item->{'payment'})
                     : '';
+    $payment ||= sprintf('<DEL>-&nbsp;$%.2f</DEL>', $item->{'void_payment'})
+      if exists($item->{'void_payment'});
     my $credit  = exists($item->{'credit'})
                     ? sprintf('-&nbsp;$%.2f', $item->{'credit'})
                     : '';
@@ -976,7 +988,8 @@ sub svc_provision_link {
 
 sub svc_unprovision_link {
   my $svc = shift or return '';
-  return qq!<A HREF="javascript:svc_areyousure('${p}misc/unprovision.cgi?$svc->{svcnum}')">Unprovision</A>!;
+  qq!<A HREF="javascript:areyousure('${p}misc/unprovision.cgi?$svc->{svcnum}',!.
+  qq!'Permanently unprovision and delete this service?')">Unprovision</A>!;
 }
 
 # 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!<a href="${p}misc/change_pkg.cgi?$pkg->{pkgnum}">Change&nbsp;package</a>!;
+  return qq!<a href="${p}misc/change_pkg.cgi?$pkg->{pkgnum}">!.
+         qq!Change&nbsp;package</a>!;
 }
 
 sub pkg_suspend_link {
@@ -1019,19 +1033,22 @@ sub pkg_unsuspend_link {
 
 sub pkg_cancel_link {
   my $pkg = shift or return '';
-  qq!<A HREF="javascript:cust_pkg_areyousure('${p}misc/cancel_pkg.cgi?$pkg->{pkgnum}')">Cancel now</A> | !.
+  qq!<A HREF="javascript:areyousure('${p}misc/cancel_pkg.cgi?$pkg->{pkgnum}', !.
+  qq!'Permanently delete included services and cancel this package?')">!.
+  qq!Cancel now</A> | !.
   qq!<A HREF="${p}misc/expire_pkg.cgi?$pkg->{pkgnum}">Cancel later</A>!;
 }
 
 sub pkg_dates_link {
   my $pkg = shift or return '';
-  return qq!<A HREF="${p}edit/REAL_cust_pkg.cgi?$pkg->{pkgnum}">Edit&nbsp;dates</A>!;
+  qq!<A HREF="${p}edit/REAL_cust_pkg.cgi?$pkg->{pkgnum}">Edit&nbsp;dates</A>!;
 }
 
 sub pkg_customize_link {
   my $pkg = shift or return '';
   my $custnum = shift;
-  return qq!<A HREF="${p}edit/part_pkg.cgi?keywords=$custnum;clone=$pkg->{pkgpart};pkgnum=$pkg->{pkgnum}">Customize</A>!;
+  qq!<A HREF="${p}edit/part_pkg.cgi?keywords=$custnum;clone=$pkg->{pkgpart};!.
+  qq!pkgnum=$pkg->{pkgnum}">Customize</A>!;
 }
 
 %>