From b649140060793a2bed8ff9dbedc7c25851c2fe06 Mon Sep 17 00:00:00 2001
From: Dan Wells <dbw2@calvin.edu>
Date: Mon, 12 Feb 2018 14:47:21 -0500
Subject: [PATCH] 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>
---
 .../perlmods/lib/OpenILS/Application/Circ/Money.pm | 136 +++++++++++++++++++++
 .../templates/staff/circ/patron/t_xact_details.tt2 |  71 +++--------
 .../staff/circ/patron/t_xact_details_details.tt2   |  59 +++++++++
 .../staff/circ/patron/t_xact_details_statement.tt2 |  72 +++++++++++
 .../web/js/ui/default/staff/circ/patron/app.js     |   2 +-
 .../web/js/ui/default/staff/circ/patron/bills.js   |  25 +++-
 6 files changed, 304 insertions(+), 61 deletions(-)
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_xact_details_details.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_xact_details_statement.tt2

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() {
-- 
2.11.0