LP#1748986 Billing Statement View
authorDan Wells <dbw2@calvin.edu>
Mon, 12 Feb 2018 19:47:21 +0000 (14:47 -0500)
committerKathy Lussier <klussier@masslnc.org>
Wed, 28 Feb 2018 15:51:13 +0000 (10:51 -0500)
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>
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Money.pm
Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2
Open-ILS/src/templates/staff/circ/patron/t_xact_details_details.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_xact_details_statement.tt2 [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/app.js
Open-ILS/web/js/ui/default/staff/circ/patron/bills.js

index 17c496b..f177e5d 100644 (file)
@@ -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;
index fdab723..c7c5d1b 100644 (file)
   </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 (file)
index 0000000..6cd026a
--- /dev/null
@@ -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 (file)
index 0000000..7dc5cea
--- /dev/null
@@ -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>
index a52c8b8..580b430 100644 (file)
@@ -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
index d9e1bf7..392b696 100644 (file)
@@ -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() {