RT#27710: Credit voiding
authorJonathan Prykop <jonathan@freeside.biz>
Mon, 16 Feb 2015 19:53:20 +0000 (13:53 -0600)
committerJonathan Prykop <jonathan@freeside.biz>
Mon, 16 Feb 2015 19:53:20 +0000 (13:53 -0600)
15 files changed:
FS/FS/AccessRight.pm
FS/FS/Schema.pm
FS/FS/access_right.pm
FS/FS/cust_credit.pm
FS/FS/cust_credit_void.pm
FS/FS/reason_type.pm
httemplate/elements/menu.html
httemplate/elements/tr-select-reason.html
httemplate/misc/unvoid-cust_credit_void.cgi [new file with mode: 0755]
httemplate/misc/void-cust_credit.html [new file with mode: 0755]
httemplate/search/cust_credit_void.html [new file with mode: 0755]
httemplate/search/report_cust_credit_void.html [new file with mode: 0644]
httemplate/view/cust_main/payment_history.html
httemplate/view/cust_main/payment_history/credit.html
httemplate/view/cust_main/payment_history/voided_credit.html

index 121f83c..f395dea 100644 (file)
@@ -221,6 +221,8 @@ tie my %rights, 'Tie::IxHash',
     { rightname=>'Backdate credit', desc=>'Enable credits to be posted for days other than today.' },
     'Credit line items', #NEWNEWNEW
     'Apply credit', #NEWNEW
+    'Void credit', #NEWER than things marked NEWNEWNEW
+    'Unvoid credit', #NEWER than things marked NEWNEWNEW
     { rightname=>'Unapply credit', desc=>'Enable "unapplication" of unclosed credits.' }, #aka unapplycredits
     { rightname=>'Delete credit', desc=>'Enable deletion of unclosed credits. Be very careful!  Only delete credits that were data-entry errors, not adjustments.' }, #aka. deletecredits Optionally specify one or more comma-separated email addresses to be notified when a credit is deleted.
     'View refunds',
@@ -233,6 +235,7 @@ tie my %rights, 'Tie::IxHash',
     'Refund Echeck payment',
     'Delete refund', #NEW
     'Add on-the-fly credit reason', #NEW
+    'Add on-the-fly void credit reason',
     'Add on-the-fly refund reason', #NEW
   ],
   
index 54a4680..5333b1a 100644 (file)
@@ -1423,6 +1423,7 @@ sub tables_hashref {
         #void fields
         'void_date',  @date_type,                  '', '', 
         'void_reason', 'varchar', 'NULL', $char_d, '', '', 
+        'void_reasonnum', 'int', 'NULL', '', '', '', 
         'void_usernum',    'int', 'NULL',      '', '', '',
       ],
       'primary_key'  => 'crednum',
@@ -1458,6 +1459,10 @@ sub tables_hashref {
                             table      => 'cust_pkg',
                             references => [ 'pkgnum' ],
                           },
+                          { columns    => [ 'void_reasonnum' ],
+                            table      => 'reason',
+                            references => [ 'reasonnum' ],
+                          },
                           { columns    => [ 'void_usernum' ],
                             table      => 'access_user',
                             references => [ 'usernum' ],
index d5e3b8b..1ea6e49 100644 (file)
@@ -251,6 +251,9 @@ sub _upgrade_data { # class method
     'List customers' => 'List contacts',
     'Backdate payment' => 'Backdate credit',
     'Generate quotation' => 'Disable quotation',
+    'Void credit' => 'Void credit',
+    'Unvoid credit' => 'Unvoid credit',
+    'Add on-the-fly void credit reason' => 'Add on-the-fly void credit reason',
   );
 
 #  foreach my $old_acl ( keys %onetime ) {
index deebe27..76fdecb 100644 (file)
@@ -382,13 +382,18 @@ adds a record of the voided credit to the cust_credit_void table.
 
 =cut
 
-# yes, false laziness with cust_pay and cust_bill
-# but frankly I don't have time to fix it now
-
 sub void {
   my $self = shift;
   my $reason = shift;
 
+  unless (ref($reason) || !$reason) {
+    $reason = FS::reason->new_or_existing(
+      'class'  => 'X',
+      'type'   => 'Void credit',
+      'reason' => $reason
+    );
+  }
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -403,7 +408,7 @@ sub void {
   my $cust_credit_void = new FS::cust_credit_void ( {
       map { $_ => $self->get($_) } $self->fields
     } );
-  $cust_credit_void->set('void_reason', $reason);
+  $cust_credit_void->set('void_reasonnum', $reason->reasonnum);
   my $error = $cust_credit_void->insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
index f76f794..9c92068 100644 (file)
@@ -1,11 +1,12 @@
 package FS::cust_credit_void; 
-use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::Record );
+use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::reason_Mixin FS::Record );
 
 use strict;
 use FS::Record qw(qsearchs); # qsearch qsearchs);
 use FS::CurrentUser;
 use FS::access_user;
 use FS::cust_credit;
+use FS::UID qw( dbh );
 
 =head1 NAME
 
@@ -85,6 +86,7 @@ sub check {
     || $self->ut_numbern('void_date')
     || $self->ut_textn('void_reason')
     || $self->ut_foreign_keyn('void_usernum', 'access_user', 'usernum')
+    || $self->ut_foreign_keyn('void_reasonnum', 'reason', 'reasonnum')
   ;
   return $error if $error;
 
@@ -96,6 +98,49 @@ sub check {
   $self->SUPER::check;
 }
 
+=item unvoid 
+
+"Un-void"s this credit: Deletes the voided credit from the database and adds
+back (but does not re-apply) a normal credit.
+
+=cut
+
+sub unvoid {
+  my $self = shift;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $cust_credit = new FS::cust_credit ( {
+    map { $_ => $self->get($_) } grep { $_ !~ /void/ } $self->fields
+  } );
+  my $error = $cust_credit->insert;
+
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $error ||= $self->delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
+
 =item cust_main
 
 Returns the parent customer object (see L<FS::cust_main>).
@@ -111,6 +156,40 @@ sub void_access_user {
   qsearchs('access_user', { 'usernum' => $self->void_usernum } );
 }
 
+=item void_access_user_name
+
+Returns the voiding employee name.
+
+=cut
+
+sub void_access_user_name {
+  my $self = shift;
+  my $user = $self->void_access_user;
+  return unless $user;
+  return $user->name;
+}
+
+=item void_reason
+
+Returns the text of the associated void credit reason (see L<FS::reason>) for this voided credit.
+
+The reason for the original credit remains accessible through the reason method.
+
+=cut
+
+sub void_reason {
+  my ($self, $value, %options) = @_;
+  my $reason_text;
+  if ( $self->void_reasonnum ) {
+    my $reason = FS::reason->by_key($self->void_reasonnum);
+    $reason_text = $reason->reason;
+  } else { # in case one of these somehow still exists
+    $reason_text = $self->get('void_reason');
+  }
+
+  return $reason_text;
+}
+
 =back
 
 =head1 BUGS
index 00ac9a8..17a7167 100644 (file)
@@ -11,6 +11,7 @@ our %class_name = (
   'R' => 'credit',
   'S' => 'suspend',
   'F' => 'refund',
+  'X' => 'void credit',
 );
 
 our %class_purpose = (  
@@ -18,6 +19,7 @@ our %class_purpose = (
   'R' => 'explain why a customer was credited',
   'S' => 'explain why a customer package was suspended',
   'F' => 'explain why a customer was refunded',
+  'X' => 'explain why a credit was voided',
 );
 
 =head1 NAME
@@ -48,7 +50,7 @@ inherits from FS::Record.  The following fields are currently supported:
 
 =item typenum - primary key
 
-=item class - currently 'C', 'R',  or 'S' for cancel, credit, or suspend 
+=item class - currently 'C', 'R', 'S', 'F' or 'X' for cancel, credit, suspend, refund or void credit 
 
 =item type - name of the type of reason
 
index 7d5d4f3..fe59ec5 100644 (file)
@@ -340,6 +340,7 @@ tie my %report_credits, 'Tie::IxHash',
   'Credit package source detail' => [ $fsurl.'search/report_cust_credit_source_bill_pkg.html', 'Line-item detail for triggered package credits' ],
   'Credit application detail' => [ $fsurl.'search/report_cust_credit_bill_pkg.html', 'Line item application detail' ],
   'Unapplied Credits' => [ $fsurl.'search/report_cust_credit.html?unapplied=1', 'Unapplied credit report (by type and/or date range)' ],
+  'Voided Credits' => [ $fsurl.'search/report_cust_credit_void.html', 'Voided credit report (by employee and/or date range)' ],
 ;
 
 tie my %report_refunds, 'Tie::IxHash',
index 14bb6e8..3565975 100755 (executable)
@@ -6,8 +6,8 @@ Example:
 
     #required 
     'field'         => 'reasonnum',
-    'reason_class'  => 'C', # currently 'C', 'R', 'F',  or 'S'
-                           # for cancel, credit, refund, or suspend
+    'reason_class'  => 'C', # currently 'C', 'R', 'F', 'S' or 'X'
+                           # for cancel, credit, refund, suspend or void credit
 
     #recommended
     'cgi' => $cgi, #easiest way for things to be properly "sticky" on errors
@@ -173,6 +173,8 @@ if ($class eq 'C') {
   $add_access_right = 'Add on-the-fly credit reason';
 } elsif ($class eq 'F') {
   $add_access_right = 'Add on-the-fly refund reason';
+} elsif ($class eq 'X') {
+  $add_access_right = 'Add on-the-fly void credit reason';
 } else {
   die "illegal class: $class";
 }
diff --git a/httemplate/misc/unvoid-cust_credit_void.cgi b/httemplate/misc/unvoid-cust_credit_void.cgi
new file mode 100755 (executable)
index 0000000..5f8d9d5
--- /dev/null
@@ -0,0 +1,21 @@
+%if ( $error ) {
+%  errorpage($error);
+%} else {
+<% $cgi->redirect($p. "view/cust_main.cgi?custnum=". $custnum .";show=payment_history") %>
+%}
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Unvoid credit');
+
+#untaint crednum
+my($query) = $cgi->keywords;
+$query =~ /^(\d+)$/ || die "Illegal crednum";
+my $crednum = $1;
+
+my $cust_credit_void = qsearchs('cust_credit_void', { 'crednum' => $crednum } );
+my $custnum = $cust_credit_void->custnum;
+
+my $error = $cust_credit_void->unvoid;
+
+</%init>
diff --git a/httemplate/misc/void-cust_credit.html b/httemplate/misc/void-cust_credit.html
new file mode 100755 (executable)
index 0000000..1e71f00
--- /dev/null
@@ -0,0 +1,74 @@
+%if ( $success ) {
+<& /elements/header-popup.html, mt("Credit voided") &>
+  <SCRIPT TYPE="text/javascript">
+    window.top.location.reload();
+  </SCRIPT>
+  </BODY>
+</HTML>
+%} else {
+<& /elements/header-popup.html, mt('Void credit')  &>
+
+<& /elements/error.html &>
+
+<P ALIGN="center"><B><% mt('Void this credit?') |h %></B>
+
+<FORM action="<% ${p} %>misc/void-cust_credit.html">
+<INPUT TYPE="hidden" NAME="crednum" VALUE="<% $crednum %>">
+
+<TABLE BGCOLOR="#cccccc" BORDER="0" CELLSPACING="2" STYLE="margin-left:auto; margin-right:auto">
+<& /elements/tr-select-reason.html,
+             'field'          => 'reasonnum',
+             'reason_class'   => 'X',
+             'cgi'            => $cgi
+&>
+</TABLE>
+
+<BR>
+<P ALIGN="CENTER">
+<INPUT TYPE="submit" NAME="confirm_void_credit" VALUE="<% mt('Void credit') |h %>"> 
+&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+<INPUT TYPE="BUTTON" VALUE="<% mt("Don't void credit") |h %>" onClick="parent.cClick();"> 
+
+</FORM>
+</BODY>
+</HTML>
+
+%}
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Void credit');
+
+#untaint crednum
+my $crednum = $cgi->param('crednum');
+if ($crednum) {
+  $crednum =~ /^(\d+)$/ || die "Illegal crednum";
+} else {
+  my($query) = $cgi->keywords;
+  $query =~ /^(\d+)/ || die "Illegal crednum";
+  $crednum = $1;
+}
+
+my $cust_credit = qsearchs('cust_credit',{'crednum'=>$crednum}) || die "Credit not found";
+
+my $success = 0;
+if ($cgi->param('confirm_void_credit')) {
+
+  #untaint reasonnum / create new reason
+  my ($reasonnum, $error) = $m->comp('process/elements/reason');
+  if (!$reasonnum) {
+    $error = 'Reason required';
+  } else {
+    my $reason = qsearchs('reason', { 'reasonnum' => $reasonnum })
+      || die "Reason num $reasonnum not found in database";
+       $error = $cust_credit->void($reason) unless $error;
+  }
+
+  if ($error) {
+    $cgi->param('error',$error);
+  } else {
+    $success = 1;
+  }
+}
+
+</%init>
diff --git a/httemplate/search/cust_credit_void.html b/httemplate/search/cust_credit_void.html
new file mode 100755 (executable)
index 0000000..18731d1
--- /dev/null
@@ -0,0 +1,141 @@
+<& elements/search.html,
+                 'title'       => $title,
+                 'name'        => emt('credits'),
+                 'query'       => $sql_query,
+                 'count_query' => $count_query,
+                 'header'      => \@header,
+                 'fields'      => \@fields,
+                 'sort_fields' => \@sort_fields,
+                 'align' => $align,
+                 'links' => \@links,
+                 'color' => \@color,
+                 'style' => \@style,
+&>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+
+my $money_char = FS::Conf->new->config('money_char') || '$';
+
+my $title = emt('Voided Credit Search Results');
+
+my $clink = sub {
+  my $cust_bill = shift;
+  $cust_bill->cust_main_custnum
+    ? [ "${p}view/cust_main.cgi?", 'custnum' ]
+    : '';
+};
+
+my %void_access_users;
+
+my (@header, @fields, @sort_fields, $align, @links, @color, @style);
+$align = '';
+
+#amount
+push @header, emt('Amount');
+push @fields, sub { $money_char .sprintf('%.2f', shift->amount) };
+push @sort_fields, 'amount';
+$align .= 'r';
+push @links, '';
+push @color, '';
+push @style, '';
+
+push @header, emt('Void Date'),
+              emt('Void By'),
+              emt('Void Reason'),
+              emt('Date'), 
+              emt('By'),
+              emt('Reason'),
+              FS::UI::Web::cust_header(),
+              ;
+push @fields, sub { time2str('%b %d %Y', shift->void_date ) },
+              'void_access_user_name',
+              'void_reason',
+              sub { time2str('%b %d %Y', shift->_date ) },
+              'otaker',
+              'reason',
+              \&FS::UI::Web::cust_fields,
+              ;
+push @sort_fields, 'void_date',
+                   'void_usernum',                #not ideal, but at least groups them together
+                   'void_reasonnum, void_reason', #ditto
+                   '_date',
+                   'usernum',                     #ditto
+                   'reasonnum, reason',           #ditto 
+                   FS::UI::Web::cust_sort_fields();
+$align .= 'rllrll'.FS::UI::Web::cust_aligns();
+push @links,  '',
+              '',
+              '',
+              '',
+              '',
+              '',
+              ( map { $_ ne 'Cust. Status' ? $clink : '' }
+                         FS::UI::Web::cust_header()
+                   ),
+              ;
+push @color,  '',
+              '',
+              '',
+              '',
+              '',
+              '',
+              FS::UI::Web::cust_colors(),
+              ;
+push @style,  '',
+              '',
+              '',
+              '',
+              '',
+              '',
+              FS::UI::Web::cust_styles(),
+              ;
+
+my @search = ();
+my $addl_from = '';
+
+# note that cgi field is usernum, but we're actually searching void_usernum
+# because true laziness with tr-select-user in report_cust_credit_void.html
+if ( $cgi->param('usernum') =~ /^(\d+)$/ ) {
+  push @search, "cust_credit_void.void_usernum = $1";
+}
+
+if ( $cgi->param('agentnum') && $cgi->param('agentnum') =~ /^(\d+)$/ ) {
+  push @search, "agentnum = $1";
+  my $agent = qsearchs('agent', { 'agentnum' => $1 } );
+  die "unknown agentnum $1" unless $agent;
+  $title = $agent->agent. " $title";
+}
+
+my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
+push @search, "void_date >= $beginning ",
+              "void_date <= $ending";
+
+push @search, FS::UI::Web::parse_lt_gt($cgi, 'amount' );
+
+#here is the agent virtualization
+push @search, $FS::CurrentUser::CurrentUser->agentnums_sql(table=>'cust_main');
+
+my @select = (
+    'cust_credit_void.*',
+    'cust_main.custnum as cust_main_custnum',
+    FS::UI::Web::cust_sql_fields(),
+);
+
+my $where = 'WHERE '. join(' AND ', @search);
+
+my $count_query = 'SELECT COUNT(*), SUM(amount) ';
+$count_query .= 'FROM cust_credit_void'. 
+                  $addl_from. FS::UI::Web::join_cust_main('cust_credit_void').
+                  $where;
+
+my $sql_query   = {
+  'table'     => 'cust_credit_void',
+  'select'    => join(', ',@select),
+  'hashref'   => {},
+  'extra_sql' => $where,
+  'addl_from' => $addl_from. FS::UI::Web::join_cust_main('cust_credit_void')
+};
+
+</%init>
diff --git a/httemplate/search/report_cust_credit_void.html b/httemplate/search/report_cust_credit_void.html
new file mode 100644 (file)
index 0000000..e967080
--- /dev/null
@@ -0,0 +1,50 @@
+<& /elements/header.html, mt($title) &>
+
+<FORM ACTION="cust_credit_void.html" METHOD="GET">
+<INPUT TYPE="hidden" NAME="magic" VALUE="_date">
+
+<TABLE>
+
+  <& /elements/tr-select-user.html,
+                'label'       => emt('Credit voids by employee: '),
+                'access_user' => \%access_user,
+  &>
+
+  <& /elements/tr-select-agent.html,
+                 'curr_value'    => scalar( $cgi->param('agentnum') ),
+                 'label'         => emt('for agent: '),
+                 'disable_empty' => 0,
+  &>
+
+  <& /elements/tr-input-beginning_ending.html &>
+
+  <& /elements/tr-input-lessthan_greaterthan.html,
+                'label' => emt('Amount'),
+                'field' => 'amount',
+  &>
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="<% mt('Get Report') |h %>">
+
+</FORM>
+
+<& /elements/footer.html &>
+
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+
+my $sth = dbh->prepare("SELECT DISTINCT usernum FROM cust_credit_void")
+  or die dbh->errstr;
+$sth->execute or die $sth->errstr;
+my @usernum = map $_->[0], @{$sth->fetchall_arrayref};
+my %access_user =
+  map { $_ => qsearchs('access_user',{'usernum'=>$_})->username }
+      @usernum;
+
+my $title = 'Voided credit report';
+
+</%init>
index 0aacb0a..04e427f 100644 (file)
@@ -425,7 +425,7 @@ my %opt = (
         'Apply payment', 'Refund credit card payment', 'Refund Echeck payment',
         'Credit card void', 'Echeck void', 'Void payments', 'Unvoid payments',
         'Delete payment', 'Unapply payment',
-        'Apply credit', 'Delete credit', 'Unapply credit',
+        'Apply credit', 'Delete credit', 'Unapply credit', 'Void credit', 'Unvoid credit',
         'Delete refund',
         'Billing event reports', 'View customer billing events',
       )
index 941180e..3eed833 100644 (file)
@@ -1,4 +1,4 @@
-<% $credit. ' '. $reason. $desc. $change_pkg. $apply. $delete. $unapply %>
+<% $credit. ' '. $reason. $desc. $change_pkg. $apply. $delete. $unapply. $void %>
 <%init>
 
 my( $cust_credit, %opt ) = @_;
@@ -126,6 +126,18 @@ if ( $apply && $opt{'pkg-balances'} && $cust_credit->pkgnum ) {
    ')';
 }
 
+my $void = '';
+$void = ' ('.
+               include( '/elements/popup_link.html',
+                    'label'    => emt('void'),
+                    'action'   => "${p}misc/void-cust_credit.html?".
+                                  $cust_credit->crednum,
+                    'actionlabel' => emt('Void credit'),
+                ).
+          ')'
+  if $cust_credit->closed !~ /^Y/i
+  && $opt{'Void credit'};
+
 my $delete = '';
 $delete = areyousure_link("${p}misc/delete-cust_credit.cgi?".$cust_credit->crednum,
                             emt('Are you sure you want to delete this credit?'),
index 9ff4c1b..3515d85 100644 (file)
@@ -8,16 +8,26 @@
 % }
 <% $void_reason |h %>
 </I>
+<% $unvoid %>
 <%init>
 
 my( $cust_credit_void, %opt ) = @_;
 
 my $date_format = $opt{'date_format'} || '%m/%d/%Y';
 
-#my $unvoid = ''; # not yet available
+my $unvoid = '';
+$unvoid = areyousure_link("${p}misc/unvoid-cust_credit_void.cgi?".$cust_credit_void->crednum,
+                            emt('Are you sure you want to unvoid this credit?'),
+                            '',
+                            emt('unvoid')
+                          )
+  if $cust_credit_void->closed !~ /^Y/i
+  && $opt{'Unvoid credit'};
+
 my $reason = $cust_credit_void->reason;
 $reason = " ($reason)" if $reason;
 
 my $void_reason = $cust_credit_void->void_reason;
 $void_reason = " ($void_reason)" if $void_reason;
 </%init>
+