From 251d07aa41b6830a0a2f2a51c14fa94586d843c2 Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Mon, 16 Feb 2015 13:53:20 -0600 Subject: [PATCH] RT#27710: Credit voiding --- FS/FS/AccessRight.pm | 3 + FS/FS/Schema.pm | 5 + FS/FS/access_right.pm | 3 + FS/FS/cust_credit.pm | 13 +- FS/FS/cust_credit_void.pm | 81 +++++++++++- FS/FS/reason_type.pm | 4 +- httemplate/elements/menu.html | 1 + httemplate/elements/tr-select-reason.html | 6 +- httemplate/misc/unvoid-cust_credit_void.cgi | 21 +++ httemplate/misc/void-cust_credit.html | 74 +++++++++++ httemplate/search/cust_credit_void.html | 141 +++++++++++++++++++++ httemplate/search/report_cust_credit_void.html | 50 ++++++++ httemplate/view/cust_main/payment_history.html | 2 +- .../view/cust_main/payment_history/credit.html | 14 +- .../cust_main/payment_history/voided_credit.html | 12 +- 15 files changed, 419 insertions(+), 11 deletions(-) create mode 100755 httemplate/misc/unvoid-cust_credit_void.cgi create mode 100755 httemplate/misc/void-cust_credit.html create mode 100755 httemplate/search/cust_credit_void.html create mode 100644 httemplate/search/report_cust_credit_void.html diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm index 121f83c40..f395dead3 100644 --- a/FS/FS/AccessRight.pm +++ b/FS/FS/AccessRight.pm @@ -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 ], diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 54a468067..5333b1af0 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -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' ], diff --git a/FS/FS/access_right.pm b/FS/FS/access_right.pm index d5e3b8b51..1ea6e4951 100644 --- a/FS/FS/access_right.pm +++ b/FS/FS/access_right.pm @@ -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 ) { diff --git a/FS/FS/cust_credit.pm b/FS/FS/cust_credit.pm index deebe2764..76fdecbdf 100644 --- a/FS/FS/cust_credit.pm +++ b/FS/FS/cust_credit.pm @@ -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; diff --git a/FS/FS/cust_credit_void.pm b/FS/FS/cust_credit_void.pm index f76f79442..9c92068eb 100644 --- a/FS/FS/cust_credit_void.pm +++ b/FS/FS/cust_credit_void.pm @@ -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). @@ -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) 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 diff --git a/FS/FS/reason_type.pm b/FS/FS/reason_type.pm index 00ac9a87e..17a716712 100644 --- a/FS/FS/reason_type.pm +++ b/FS/FS/reason_type.pm @@ -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 diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html index 7d5d4f398..fe59ec540 100644 --- a/httemplate/elements/menu.html +++ b/httemplate/elements/menu.html @@ -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', diff --git a/httemplate/elements/tr-select-reason.html b/httemplate/elements/tr-select-reason.html index 14bb6e8d0..356597553 100755 --- a/httemplate/elements/tr-select-reason.html +++ b/httemplate/elements/tr-select-reason.html @@ -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 index 000000000..5f8d9d548 --- /dev/null +++ b/httemplate/misc/unvoid-cust_credit_void.cgi @@ -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; + + diff --git a/httemplate/misc/void-cust_credit.html b/httemplate/misc/void-cust_credit.html new file mode 100755 index 000000000..1e71f0030 --- /dev/null +++ b/httemplate/misc/void-cust_credit.html @@ -0,0 +1,74 @@ +%if ( $success ) { +<& /elements/header-popup.html, mt("Credit voided") &> + + + +%} else { +<& /elements/header-popup.html, mt('Void credit') &> + +<& /elements/error.html &> + +

<% mt('Void this credit?') |h %> + +

+ + + +<& /elements/tr-select-reason.html, + 'field' => 'reasonnum', + 'reason_class' => 'X', + 'cgi' => $cgi +&> +
+ +
+

+ +         +" onClick="parent.cClick();"> + +

+ + + +%} +<%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; + } +} + + diff --git a/httemplate/search/cust_credit_void.html b/httemplate/search/cust_credit_void.html new file mode 100755 index 000000000..18731d144 --- /dev/null +++ b/httemplate/search/cust_credit_void.html @@ -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') +}; + + diff --git a/httemplate/search/report_cust_credit_void.html b/httemplate/search/report_cust_credit_void.html new file mode 100644 index 000000000..e96708090 --- /dev/null +++ b/httemplate/search/report_cust_credit_void.html @@ -0,0 +1,50 @@ +<& /elements/header.html, mt($title) &> + +
+ + + + + <& /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', + &> + +
+ +
+ + +
+ +<& /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'; + + diff --git a/httemplate/view/cust_main/payment_history.html b/httemplate/view/cust_main/payment_history.html index 0aacb0a8d..04e427f9d 100644 --- a/httemplate/view/cust_main/payment_history.html +++ b/httemplate/view/cust_main/payment_history.html @@ -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', ) diff --git a/httemplate/view/cust_main/payment_history/credit.html b/httemplate/view/cust_main/payment_history/credit.html index 941180e80..3eed833d3 100644 --- a/httemplate/view/cust_main/payment_history/credit.html +++ b/httemplate/view/cust_main/payment_history/credit.html @@ -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?'), diff --git a/httemplate/view/cust_main/payment_history/voided_credit.html b/httemplate/view/cust_main/payment_history/voided_credit.html index 9ff4c1b1b..3515d856d 100644 --- a/httemplate/view/cust_main/payment_history/voided_credit.html +++ b/httemplate/view/cust_main/payment_history/voided_credit.html @@ -8,16 +8,26 @@ % } <% $void_reason |h %> +<% $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; + -- 2.11.0