From: Dan Wells <dbw2@calvin.edu> Date: Mon, 12 Feb 2018 19:47:21 +0000 (-0500) Subject: LP#1748986 Billing Statement View X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=b649140060793a2bed8ff9dbedc7c25851c2fe06;p=evergreen%2Fjoelewis.git LP#1748986 Billing Statement View Over time, Evergreen has developed a rich set of features to support various billing scenarios. Unfortunately, our interface has not kept up in some ways, and this leads to some confusion at times, particularly for front line staff. One way to bring clarity is to apply better grouping, labeling, and ordering of the various transactional events. By doing so, we can generate a billing "statement" with similarities to the statements we regularly encounter when dealing with other financial institutions. This branch does so for the staff client view, but it also seems viable to carry over the same idea to an eventual patron (or print) view as well. Signed-off-by: Dan Wells <dbw2@calvin.edu> Signed-off-by: Kathy Lussier <klussier@masslnc.org> --- diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Money.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Money.pm index 17c496bdd9..f177e5d120 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Money.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Money.pm @@ -32,6 +32,9 @@ use OpenILS::Utils::Penalty; use Business::Stripe; $Data::Dumper::Indent = 0; use OpenILS::Const qw/:const/; +use OpenSRF::Utils qw/:datetime/; +use DateTime::Format::ISO8601; +my $parser = DateTime::Format::ISO8601->new; sub get_processor_settings { my $e = shift; @@ -1273,4 +1276,137 @@ sub retrieve_credit_payable_balance { } +__PACKAGE__->register_method( + method => "retrieve_statement", + authoritative => 1, + api_name => "open-ils.circ.money.statement.retrieve", + notes => "Returns an organized summary of a billable transaction, including all bills, payments, adjustments, and voids." + ); + +sub _to_epoch { + my $ts = shift @_; + + return $parser->parse_datetime(cleanse_ISO8601($ts))->epoch; +} + +my %_statement_sort = ( + 'billing' => 0, + 'account_adjustment' => 1, + 'void' => 2, + 'payment' => 3 +); + +sub retrieve_statement { + my ( $self, $client, $auth, $xact_id ) = @_; + + my $e = new_editor(authtoken=>$auth); + return $e->event unless $e->checkauth; + return $e->event unless $e->allowed('VIEW_TRANSACTION'); + + # XXX: move this lookup login into a DB query? + my @line_prep; + + # collect all payments/adjustments + my $payments = $e->search_money_payment({ xact => $xact_id }); + foreach my $payment (@$payments) { + my $type = $payment->payment_type; + $type = 'payment' if $type ne 'account_adjustment'; + push(@line_prep, [$type, _to_epoch($payment->payment_ts), $payment->payment_ts, $payment->id, $payment]); + } + + # collect all billings + my $billings = $e->search_money_billing({ xact => $xact_id }); + foreach my $billing (@$billings) { + if ($U->is_true($billing->voided)){ + push(@line_prep, ['void', _to_epoch($billing->void_time), $billing->void_time, $billing->id, $billing]); # voids get two entries, one to represent the bill event, one for the void event + } + push(@line_prep, ['billing', _to_epoch($billing->billing_ts), $billing->billing_ts, $billing->id, $billing]); + } + + # order every event by timestamp, then bills/adjustments/voids/payments order, then id + my @ordered_line_prep = sort { + $a->[1] <=> $b->[1] + || + $_statement_sort{$a->[0]} <=> $_statement_sort{$b->[0]} + || + $a->[3] <=> $b->[3] + } @line_prep; + + # let's start building the statement structure + my (@lines, %current_line, $running_balance); + foreach my $event (@ordered_line_prep) { + my $obj = $event->[4]; + my $type = $event->[0]; + my $ts = $event->[2]; + my $billing_type = $type =~ /billing|void/ ? $obj->billing_type : ''; # TODO: get non-legacy billing type + my $note = $obj->note || ''; + # last line should be void information, try to isolate it + if ($type eq 'billing' and $obj->voided) { + $note =~ s/\n.*$//; + } elsif ($type eq 'void') { + $note = (split(/\n/, $note))[-1]; + } + + # if we have new details, start a new line + if ($current_line{amount} and ( + $type ne $current_line{type} + or ($note ne $current_line{note}) + or ($billing_type ne $current_line{billing_type}) + ) + ) { + push(@lines, {%current_line}); # push a copy of the hash, not the real thing + %current_line = (); + } + if (!$current_line{type}) { + $current_line{type} = $type; + $current_line{billing_type} = $billing_type; + $current_line{note} = $note; + } + if (!$current_line{start_date}) { + $current_line{start_date} = $ts; + } elsif ($ts ne $current_line{start_date}) { + $current_line{end_date} = $ts; + } + $current_line{amount} += $obj->amount; + if ($current_line{details}) { + push(@{$current_line{details}}, $obj); + } else { + $current_line{details} = [$obj]; + } + } + push(@lines, {%current_line}); # push last one on + + # get/update totals, format notes + my %totals = ( + billing => 0, + payment => 0, + account_adjustment => 0, + void => 0 + ); + foreach my $line (@lines) { + $totals{$line->{type}} += $line->{amount}; + if ($line->{type} eq 'billing') { + $running_balance += $line->{amount}; + } else { # not a billing; balance goes down for everything else + $running_balance -= $line->{amount}; + } + $line->{running_balance} = $running_balance; + $line->{note} = $line->{note} ? [split(/\n/, $line->{note})] : []; + } + + return { + xact_id => $xact_id, + summary => { + balance_due => $totals{billing} - ($totals{payment} + $totals{account_adjustment} + $totals{void}), + billing_total => $totals{billing}, + credit_total => $totals{payment} + $totals{account_adjustment}, + payment_total => $totals{payment}, + account_adjustment_total => $totals{account_adjustment}, + void_total => $totals{void} + }, + lines => \@lines + } +} + + 1; diff --git a/Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2 index fdab723cd8..c7c5d1bac7 100644 --- a/Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2 +++ b/Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2 @@ -105,61 +105,18 @@ </div> </div> - -<!-- set a lower default page size (limit) to allow for more space --> <hr/> -<eg-grid - main-label="[% l('Bills') %]" - idl-class="mb" - id-field="id" - grid-controls="xactGridControls" - auto-fields="true" - page-size="10" - dateformat="{{$root.egDateAndTimeFormat}}"> - - <eg-grid-action - label="[% l('Void Billings') %]" handler="voidBillings"></eg-grid-action> - - <eg-grid-action - label="[% l('Edit Note') %]" handler="editBillNotes"></eg-grid-action> - -</eg-grid> - -<!-- TODO: this grid may contain objects (payments) of different types.. - apply manual columns, see xul --> -<!-- NOTE: sorting disabled since payments are fetched via non-sortable API --> -<br/> -<eg-grid - main-label="[% l('Payments') %]" - idl-class="mbp" - id-field="id" - grid-controls="paymentGridControls" - page-size="10" - dateformat="{{$root.egDateAndTimeFormat}}"> - <eg-grid-action - label="[% l('Edit Note') %]" handler="editPaymentNotes"></eg-grid-action> - - <eg-grid-field path="cash_payment.cash_drawer.name" parent-idl-class="mbp" - label="[% l('Cash Drawer') %]"></eg-grid-field> - - <eg-grid-field path="accepting_usr">{{item.staff_name}} ({{item.staff_barcode}}) @ {{item.staff_org}}</eg-grid-field> - - <eg-grid-field path="amount"></eg-grid-field> - <eg-grid-field path="id"></eg-grid-field> - <eg-grid-field path="note"></eg-grid-field> - <eg-grid-field path="payment_ts"></eg-grid-field> - <eg-grid-field path="payment_type"></eg-grid-field> - <eg-grid-field path="xact"></eg-grid-field> - <eg-grid-field path="voided"></eg-grid-field> - - <eg-grid-field path="accepting_usr.family_name" name="staff_name" - label="[% l('Staff Last Name') %]" hidden required></eg-grid-field> - - <eg-grid-field path="accepting_usr.card.barcode" name="staff_barcode" - label="[% l('Staff Barcode') %]" hidden required></eg-grid-field> - - <eg-grid-field path="accepting_usr.home_ou.shortname" name="staff_org" - label="[% l('Staff Org Unit') %]" hidden required></eg-grid-field> - -</eg-grid> - +<ul class="nav nav-tabs"> + <li ng-class="{active : xact_tab == 'statement'}"> + <a href="./circ/patron/{{patron().id()}}/bill/{{xact.id()}}/statement"> + [% l('Statement') %] + </a> + </li> + <li ng-class="{active : xact_tab == 'details'}"> + <a href="./circ/patron/{{patron().id()}}/bill/{{xact.id()}}/details"> + [% l('Details') %] + </a> + </li> +</ul> +[% INCLUDE 'staff/circ/patron/t_xact_details_statement.tt2' %] +[% INCLUDE 'staff/circ/patron/t_xact_details_details.tt2' %] diff --git a/Open-ILS/src/templates/staff/circ/patron/t_xact_details_details.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_xact_details_details.tt2 new file mode 100644 index 0000000000..6cd026a801 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_xact_details_details.tt2 @@ -0,0 +1,59 @@ +<div ng-if="xact_tab == 'details'"> + +<!-- set a lower default page size (limit) to allow for more space --> +<eg-grid + main-label="[% l('Bills') %]" + idl-class="mb" + id-field="id" + grid-controls="xactGridControls" + auto-fields="true" + page-size="10" + dateformat="{{$root.egDateAndTimeFormat}}"> + + <eg-grid-action + label="[% l('Void Billings') %]" handler="voidBillings"></eg-grid-action> + + <eg-grid-action + label="[% l('Edit Note') %]" handler="editBillNotes"></eg-grid-action> + +</eg-grid> + +<!-- TODO: this grid may contain objects (payments) of different types.. + apply manual columns, see xul --> +<!-- NOTE: sorting disabled since payments are fetched via non-sortable API --> +<br/> +<eg-grid + main-label="[% l('Payments') %]" + idl-class="mbp" + id-field="id" + grid-controls="paymentGridControls" + page-size="10" + dateformat="{{$root.egDateAndTimeFormat}}"> + <eg-grid-action + label="[% l('Edit Note') %]" handler="editPaymentNotes"></eg-grid-action> + + <eg-grid-field path="cash_payment.cash_drawer.name" parent-idl-class="mbp" + label="[% l('Cash Drawer') %]"></eg-grid-field> + + <eg-grid-field path="accepting_usr">{{item.staff_name}} ({{item.staff_barcode}}) @ {{item.staff_org}}</eg-grid-field> + + <eg-grid-field path="amount"></eg-grid-field> + <eg-grid-field path="id"></eg-grid-field> + <eg-grid-field path="note"></eg-grid-field> + <eg-grid-field path="payment_ts"></eg-grid-field> + <eg-grid-field path="payment_type"></eg-grid-field> + <eg-grid-field path="xact"></eg-grid-field> + <eg-grid-field path="voided"></eg-grid-field> + + <eg-grid-field path="accepting_usr.family_name" name="staff_name" + label="[% l('Staff Last Name') %]" hidden required></eg-grid-field> + + <eg-grid-field path="accepting_usr.card.barcode" name="staff_barcode" + label="[% l('Staff Barcode') %]" hidden required></eg-grid-field> + + <eg-grid-field path="accepting_usr.home_ou.shortname" name="staff_org" + label="[% l('Staff Org Unit') %]" hidden required></eg-grid-field> + +</eg-grid> + +</div> diff --git a/Open-ILS/src/templates/staff/circ/patron/t_xact_details_statement.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_xact_details_statement.tt2 new file mode 100644 index 0000000000..7dc5cead6c --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_xact_details_statement.tt2 @@ -0,0 +1,72 @@ +<div ng-if="xact_tab == 'statement'"> + +<h4>[% l('Billing Statement') %]</h4> +<div class="panel panel-default"> + <div class="panel-heading"> + <div class="row"> + <div class="col-md-2">[% l('Type') %]</div> + <div class="col-md-6">[% l('Description') %]</div> + <div class="col-md-2">[% l('Amount') %]</div> + <div class="col-md-2">[% l('Balance') %]</div> + </div> + </div> + <div class="panel-body flex-container-striped"> + <div class="row" ng-repeat="line in statement_data.lines"> + <div class="col-md-2" ng-switch="line.type"> + <span ng-switch-when="billing">[% l('Billing') %]</span> + <span ng-switch-when="payment">[% l('Payment') %]</span> + <span ng-switch-when="account_adjustment">[% l('Adjustment') %]</span> + <span ng-switch-when="void">[% l('Void') %]</span> + </div> + <div class="col-md-6"> + <span ng-if="line.billing_type" class="strong-text">{{line.billing_type}}<br/></span> + <span ng-if="line.note.length" ng-class="{'strong-text' : !line.billing_type}">{{line.note | join:', '}}<br/></span> + <span class="small">{{line.start_date | date:$root.egDateAndTimeFormat}}</span><span ng-if="line.end_date" class="small"> - {{line.end_date | date:$root.egDateAndTimeFormat}}</span> + </div> + <div ng-style="line.type != 'billing' && {'color':'red'}" class="col-md-2"> + <span ng-if="line.type != 'billing'">-</span>{{line.amount | currency}} + </div> + <div class="col-md-2"> + {{line.running_balance | currency}} + </div> + </div> + <hr/> + <div class="row"> + <div class="col-md-4 col-md-offset-8"> + <div class="row"> + <div class="col-md-6 strong-text">[% l('Total Charges') %]</div> + <div class="col-md-6 text-right"> + {{statement_data.summary.billing_total | currency}} + </div> + </div> + <div class="row"> + <div class="col-md-6 strong-text">[% l('Total Payments') %]</div> + <div class="col-md-6 text-right" style="color:red"> + -{{statement_data.summary.payment_total || 0 | currency}} + </div> + </div> + <div ng-if="statement_data.summary.account_adjustment_total > 0" class="row"> + <div class="col-md-6 strong-text">[% l('Total Adjustments') %]</div> + <div class="col-md-6 text-right" style="color:red"> + -{{statement_data.summary.account_adjustment_total | currency}} + </div> + </div> + <div ng-if="statement_data.summary.void_total > 0" class="row"> + <div class="col-md-6 strong-text">[% l('Total Voids') %]</div> + <div class="col-md-6 text-right" style="color:red"> + -{{statement_data.summary.void_total | currency}} + </div> + </div> + <hr/> + <div class="row"> + <div class="col-md-6 strong-text">[% l('Balance Due') %]</div> + <div class="col-md-6 text-right"> + {{statement_data.summary.balance_due | currency}} + </div> + </div> + </div> + </div> + </div> +</div> + +</div> diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js index a52c8b8a05..580b430e0b 100644 --- a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js +++ b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js @@ -138,7 +138,7 @@ angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', 'egUserBucketMod', resolve : resolver }); - $routeProvider.when('/circ/patron/:id/bill/:xact_id', { + $routeProvider.when('/circ/patron/:id/bill/:xact_id/:xact_tab', { templateUrl: './circ/patron/t_xact_details', controller: 'XactDetailsCtrl', resolve : resolver diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/bills.js b/Open-ILS/web/js/ui/default/staff/circ/patron/bills.js index d9e1bf79c9..392b696179 100644 --- a/Open-ILS/web/js/ui/default/staff/circ/patron/bills.js +++ b/Open-ILS/web/js/ui/default/staff/circ/patron/bills.js @@ -87,6 +87,17 @@ function($q , egCore , egWorkLog , patronSvc) { ); } + service.fetchStatement = function(xact_id) { + return egCore.net.request( + 'open-ils.circ', + 'open-ils.circ.money.statement.retrieve', + egCore.auth.token(), xact_id + ).then(function(resp) { + if (evt = egCore.evt.parse(resp)) return alert(evt); + return resp; + }); + } + // TODO: no longer needed? service.fetchPayments = function(xact_id) { return egCore.net.request( @@ -723,7 +734,7 @@ function($scope , $q , $routeParams , egCore , egConfirmDialog , $location, $scope.showFullDetails = function(all) { if (all[0]) $location.path('/circ/patron/' + - patronSvc.current.id() + '/bill/' + all[0].id); + patronSvc.current.id() + '/bill/' + all[0].id + '/statement'); } $scope.activateBill = function(xact) { @@ -741,6 +752,7 @@ function($scope, $q , $routeParams , egCore , egGridDataProvider , patronSvc , $scope.initTab('bills', $routeParams.id); var xact_id = $routeParams.xact_id; + $scope.xact_tab = $routeParams.xact_tab; var xactGrid = $scope.xactGridControls = { setQuery : function() { return {xact : xact_id} }, @@ -827,6 +839,13 @@ function($scope, $q , $routeParams , egCore , egGridDataProvider , patronSvc , } // -- retrieve our data + if ($scope.xact_tab == 'statement') { + //fetch combined billing statement data + billSvc.fetchStatement(xact_id).then(function(statement) { + //console.log(statement); + $scope.statement_data = statement; + }); + } $scope.total_circs = 0; // start with 0 instead of undefined egBilling.fetchXact(xact_id).then(function(xact) { $scope.xact = xact; @@ -940,7 +959,7 @@ function($scope, $q , egCore , patronSvc , billSvc , egPromptDialog , $location $scope.showFullDetails = function(all) { if (all[0]) $location.path('/circ/patron/' + - patronSvc.current.id() + '/bill/' + all[0].id); + patronSvc.current.id() + '/bill/' + all[0].id + '/statement'); } // For now, only adds billing to first selected item. @@ -1043,7 +1062,7 @@ function($scope, $q , egCore , patronSvc , billSvc , $location) { $scope.showFullDetails = function(all) { if (all[0]) $location.path('/circ/patron/' + - patronSvc.current.id() + '/bill/' + all[0]['xact.id']); + patronSvc.current.id() + '/bill/' + all[0]['xact.id'] + '/statement'); } $scope.totals.selected_paid = function() {