From edd21cd809d8e9e7e0fe5de739ad81eee36a5c83 Mon Sep 17 00:00:00 2001
From: Jason Cooper <scrapheap@heckrothindustries.co.uk>
Date: Wed, 25 Oct 2017 14:34:05 +0100
Subject: [PATCH] Add feature to retrieve historic loans from ILS (#1031)

---
 config/vufind/Aleph.ini                       |   4 +
 config/vufind/Demo.ini                        |  11 +-
 config/vufind/Koha.ini                        |   4 +
 config/vufind/KohaILSDI.ini                   |   6 +-
 config/vufind/SierraRest.ini                  |   4 +
 config/vufind/config.ini                      |   4 +
 languages/en.ini                              |  12 ++
 languages/fi.ini                              |  12 ++
 languages/sv.ini                              |  12 ++
 module/VuFind/config/module.config.php        |   2 +-
 .../Controller/MyResearchController.php       | 126 +++++++++++++
 module/VuFind/src/VuFind/ILS/Connection.php   |  23 +++
 module/VuFind/src/VuFind/ILS/Driver/Aleph.php | 167 ++++++++++++++---
 module/VuFind/src/VuFind/ILS/Driver/Demo.php  | 156 ++++++++++++++++
 module/VuFind/src/VuFind/ILS/Driver/Koha.php  | 115 ++++++++++++
 .../src/VuFind/ILS/Driver/KohaILSDI.php       | 174 +++++++++++++++++-
 .../src/VuFind/ILS/Driver/MultiBackend.php    |  24 +++
 .../src/VuFind/ILS/Driver/SierraRest.php      |  98 +++++++++-
 .../templates/myresearch/controls/sort.phtml  |  11 ++
 .../templates/myresearch/historicloans.phtml  | 131 +++++++++++++
 .../templates/myresearch/menu.phtml           |   5 +
 21 files changed, 1067 insertions(+), 34 deletions(-)
 create mode 100644 themes/bootstrap3/templates/myresearch/controls/sort.phtml
 create mode 100644 themes/bootstrap3/templates/myresearch/historicloans.phtml

diff --git a/config/vufind/Aleph.ini b/config/vufind/Aleph.ini
index 363e84d95b3..5d24bb17825 100644
--- a/config/vufind/Aleph.ini
+++ b/config/vufind/Aleph.ini
@@ -95,3 +95,7 @@ extraHoldFields = comments:requiredByDate:pickUpLocation
 ; \VuFind\Cache\Manager cache you would like to use for storing data ("object" is recommended).
 [Cache]
 ;type = object
+
+[TransactionHistory]
+; By default the loan history is disabled. Uncomment the following line to enable it.
+;enabled = true
diff --git a/config/vufind/Demo.ini b/config/vufind/Demo.ini
index 6cb15e25585..7934cbdd1e6 100644
--- a/config/vufind/Demo.ini
+++ b/config/vufind/Demo.ini
@@ -28,6 +28,11 @@ services[] = 'custom'
 ; driver.
 ;transactions = '[{"id":"1234", ... "renewable": true}]';
 
+; This setting can be used to create fake historic loan items for specific records.
+; The value is a JSON document representing the status information returned by the
+; driver.
+;historicTransactions = '[{"id":"1234", ... "dueDate": "01/01/2017"}]';
+
 ; This section can be used to create a set of fake users recognized by the
 ; Demo driver. If it is uncommented, only usernames and passwords listed here
 ; will be recognized for ILS login. If it is commented out, all username/password
@@ -80,4 +85,8 @@ renewMyItems = 50
 ;minLength = 4
 ;maxLength = 20
 ;pattern = "alphanumeric"
-;hint = "Your optional custom hint can go here."
\ No newline at end of file
+;hint = "Your optional custom hint can go here."
+
+[TransactionHistory]
+; By default the loan history is disabled. Uncomment the following line to enable it.
+;enabled = true
diff --git a/config/vufind/Koha.ini b/config/vufind/Koha.ini
index 46830be98f1..aff0187e84e 100644
--- a/config/vufind/Koha.ini
+++ b/config/vufind/Koha.ini
@@ -41,3 +41,7 @@ STAFF       = "Staff Office"
 ;OVERDUES = false
 ;MANUAL = false
 ;DISCHARGE = false
+
+[TransactionHistory]
+; By default the loan history is disabled. Uncomment the following line to enable it.
+;enabled = true
diff --git a/config/vufind/KohaILSDI.ini b/config/vufind/KohaILSDI.ini
index e83ad8f4fc9..be92848d7a6 100755
--- a/config/vufind/KohaILSDI.ini
+++ b/config/vufind/KohaILSDI.ini
@@ -59,7 +59,7 @@ extraHoldFields = pickUpLocation
 ; location. By setting this to a Koha location code (e.g. '"MAIN"'),
 ; Vufind will default to that location.
 ; If no defaultPickUpLocation and no pickupLocations are defined,
-; the driver will try to use the actual holdingbranch(es) of the item/title 
+; the driver will try to use the actual holdingbranch(es) of the item/title
 ; as a fallback.
 defaultPickUpLocation = "MAIN"
 
@@ -89,3 +89,7 @@ pickupLocations[] = MAIN
 ;OVERDUES = false
 ;MANUAL = false
 ;DISCHARGE = false
+
+[TransactionHistory]
+; By default the loan history is disabled. Uncomment the following line to enable it.
+;enabled = true
diff --git a/config/vufind/SierraRest.ini b/config/vufind/SierraRest.ini
index 74463f14f68..4c9460a7c34 100644
--- a/config/vufind/SierraRest.ini
+++ b/config/vufind/SierraRest.ini
@@ -85,3 +85,7 @@ title_hold_bib_levels = a:b:m:d
 ; config.ini defaults when Sierra is used for authentication.
 ;pattern = "numeric"
 ;hint = "Your optional custom hint can go here."
+
+[TransactionHistory]
+; By default the loan history is disabled. Uncomment the following line to enable it.
+;enabled = true
diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index 433914987ff..f185d63568f 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -300,6 +300,10 @@ title_level_holds_mode = "disabled"
 ; memory problems for users with huge numbers of items). Default = 50.
 ;checked_out_page_size = 50
 
+; The number of historic loans to display per page; 0 for no limit (may cause
+; memory problems for users with a large number of historic loans). Default = 50
+;historic_loan_page_size = 50
+
 ; Whether to display the item barcode for each loan. Default is false.
 ;display_checked_out_item_barcode = true
 
diff --git a/languages/en.ini b/languages/en.ini
index ce747a8b8da..1312796757f 100644
--- a/languages/en.ini
+++ b/languages/en.ini
@@ -183,6 +183,7 @@ Check Recall = "Check Recall"
 Checked Out = "Checked Out"
 Checked Out Items = "Checked Out Items"
 Checkedout = "Checked Out"
+Checkout Date = "Checkout Date"
 Chicago Citation = "Chicago Style Citation"
 child_record_count = "%%count%% records"
 child_records = "Contents/pieces"
@@ -501,12 +502,14 @@ ill_request_processed = "Processed"
 ill_request_profile_html = "For interlibrary loan request information, please establish your <a href="%%url%%">Library Catalog Profile</a>."
 ill_request_submit_text = "Place Request"
 Illustrated = "Illustrated"
+ils_action_unavailable = "The requested function is not available with the active library card."
 ils_connection_failed = "Connection to the library management system failed. Information related to your library account cannot be displayed. If the problem persists, please contact your library."
 ils_offline_holdings_message = "Holdings and item availability information is currently unavailable. Please accept our apologies for any inconvenience this may cause and contact us for further assistance:"
 ils_offline_home_message = "Your account details and live item information will be unavailable during this time. Please accept our apologies for any inconvenience this may cause and contact us for further assistance:"
 ils_offline_login_message = "Your account details will be unavailable during this time. Please accept our apologies for any inconvenience this may cause and contact us for further assistance:"
 ils_offline_status = "Our Library Management System is currently under maintenance."
 ils_offline_title = "System Under Maintenance"
+ils_transaction_history_disabled = "Loan history is not enabled for the active library card."
 Import Record = "Import Record"
 in = "in"
 In This Collection = "In This Collection"
@@ -581,6 +584,8 @@ list_access_denied = "You do not have permission to view this list."
 list_edit_name_required = "List name is required."
 load_tag_error = "Error: Could Not Load Tags"
 Loading = "Loading"
+Loan History = "Loan History"
+loan_history_empty = "You do not have any loans in the loan history."
 Local Login = "Local Login"
 local_login_desc = "Enter the username and password you created for this site."
 Located = "Located"
@@ -840,6 +845,7 @@ results = "results"
 Results for = "Results for"
 Results per page = "Results per page"
 Resumption Token = "Resumption Token"
+Return Date = "Return Date"
 Review by = "Review by"
 Reviews = "Reviews"
 Save = "Save"
@@ -911,8 +917,14 @@ sort_author = "Author"
 sort_author_author = "Alphabetical"
 sort_author_relevance = "Popularity"
 sort_callnumber = "Call Number"
+sort_checkout_date_asc = "Checkout Date (oldest first)"
+sort_checkout_date_desc = "Checkout Date (newest first)"
 sort_count = "Result Count"
+sort_due_date_asc = "Due Date (oldest first)"
+sort_due_date_desc = "Due Date (newest first)"
 sort_relevance = "Relevance"
+sort_return_date_asc = "Return Date (oldest first)"
+sort_return_date_desc = "Return Date (newest first)"
 sort_title = "Title"
 sort_year = "Date Descending"
 sort_year asc = "Date Ascending"
diff --git a/languages/fi.ini b/languages/fi.ini
index e0d332f2b9b..438ca2a41d9 100644
--- a/languages/fi.ini
+++ b/languages/fi.ini
@@ -182,6 +182,7 @@ Check Recall = "Tarkista varaus"
 Checked Out = "Lainat"
 Checked Out Items = "Lainat"
 Checkedout = "Lainat"
+Checkout Date = "Lainauspäivä"
 Chicago Citation = "Chicago-tyylinen lähdeviittaus"
 child_record_count = "%%count%% tietuetta"
 child_records = "Sisältö/kappaleet"
@@ -506,12 +507,14 @@ ill_request_processed = "Käsitelty"
 ill_request_profile_html = "Kirjaudu <a href="%%url%%">kirjastokortilla</a> nähdäksesi kaukolainatilaukset."
 ill_request_submit_text = "Tee kaukolainatilaus"
 Illustrated = "Kuvitus"
+ils_action_unavailable = "Toiminto ei ole saatavissa käytössä olevalla kirjastokortilla."
 ils_connection_failed = "Kirjastojärjestelmään ei saatu yhteyttä. Tietoja, jotka liittyvät tiliisi kirjastossa, ei voida näyttää. Jos ongelma jatkuu, ota yhteyttä kirjastoon."
 ils_offline_holdings_message = "Saatavuustiedot eivät ole juuri nyt käytettävissä. Pahoittelemme tästä aiheutunutta vaivaa. Voitte ottaa yhteyttä:"
 ils_offline_home_message = "Tilitietosi ja ajantasaiset saatavuustiedot ovat poissa käytöstä tämän ajan. Pahoittelemme tästä aiheutunutta vaivaa. Voitte ottaa yhteyttä:"
 ils_offline_login_message = "Tilitietosi ovat poissa käytöstä tämän ajan. Pahoittelemme tästä aiheutunutta vaivaa. Voitte ottaa yhteyttä:"
 ils_offline_status = "Kirjastojärjestelmä on juuri nyt pois käytöstä."
 ils_offline_title = "Järjestelmä pois käytöstä"
+ils_transaction_history_disabled = "Lainaushistoriaa ei ole otettu käyttöön valitulla kirjastokortilla."
 Import Record = "Tuo tietue"
 in = "kentästä"
 In This Collection = "Tässä kokoelmassa"
@@ -586,6 +589,8 @@ list_access_denied = "Sinulla ei ole oikeuksia katsoa tätä listaa."
 list_edit_name_required = "Listan nimi tarvitaan."
 load_tag_error = "Virhe: Tagien lataaminen epäonnistui"
 Loading = "Lataa"
+Loan History = "Lainaushistoria"
+loan_history_empty = "Ei tietoja lainaushistoriassa."
 Local Login = "Paikallinen kirjautuminen"
 local_login_desc = "Syötä käyttäjätunnus ja salasana, jotka loit tätä sivustoa varten."
 Located = "Sijainti"
@@ -845,6 +850,7 @@ results = "tuloksesta"
 Results for = "Tulokset haulle"
 Results per page = "Tuloksia sivulla"
 Resumption Token = "Resumption Token"
+Return Date = "Palautuspäivä"
 Review by = "Arvostellut"
 Reviews = "Arvostelut"
 Save = "Tallenna"
@@ -916,8 +922,14 @@ sort_author = "Tekijä"
 sort_author_author = "Aakkosellinen"
 sort_author_relevance = "Relevanssi"
 sort_callnumber = "Luokka"
+sort_checkout_date_asc = "Lainauspäivä (vanhin ensin)"
+sort_checkout_date_desc = "Lainauspäivä (uusin ensin)"
 sort_count = "Lukumäärä"
+sort_due_date_asc = "Eräpäivä (vanhin ensin)"
+sort_due_date_desc = "Eräpäivä (uusin ensin)"
 sort_relevance = "Relevanssi"
+sort_return_date_asc = "Palautuspäivä (vanhin ensin)"
+sort_return_date_desc = "Palautuspäivä (uusin ensin)"
 sort_title = "Nimeke"
 sort_year = "Aika (uusimmat ensin)"
 sort_year asc = "Aika (vanhimmat ensin)"
diff --git a/languages/sv.ini b/languages/sv.ini
index 4fb42fb7113..41341c82f73 100644
--- a/languages/sv.ini
+++ b/languages/sv.ini
@@ -182,6 +182,7 @@ Check Recall = "Kolla återkallelse"
 Checked Out = "LÃ¥n"
 Checked Out Items = "LÃ¥n"
 Checkedout = "LÃ¥n"
+Checkout Date = "Utlåningsdag"
 Chicago Citation = "Chicago-stil citat"
 child_record_count = "%%count%% poster"
 child_records = "Innehåll/delar"
@@ -501,12 +502,14 @@ ill_request_processed = "Behandlad"
 ill_request_profile_html = "Logga in med din <a href="%%url%%">bibliotekskort</a> för att se fjärrlånbeställningar."
 ill_request_submit_text = "Beställ"
 Illustrated = "Illustrerad"
+ils_action_unavailable = "Den begärda åtgärden är inte tillgänglig med det aktiva bibliotekskortet."
 ils_connection_failed = "Anslutning till bibliotekssystemet misslyckades. Information relaterad till ditt bibliotekskonto kan inte visas. Kontakta kundtjänst om problemet kvarstår."
 ils_offline_holdings_message = "Holdings and item availability information is currently unavailable. Please accept our apologies for any inconvenience this may cause and contact us for further assistance:"
 ils_offline_home_message = "Your account details and live item information will be unavailable during this time. Please accept our apologies for any inconvenience this may cause and contact us for further assistance:"
 ils_offline_login_message = "Your account details will be unavailable during this time. Please accept our apologies for any inconvenience this may cause and contact us for further assistance:"
 ils_offline_status = "Our Library Management System is currently under maintenance."
 ils_offline_title = "System Under Maintenance"
+ils_transaction_history_disabled = "Utlåningshistoriken är inte aktiverat för det aktiva bibliotekskortet."
 Import Record = "Importera posten"
 in = "i fältet"
 In This Collection = "I denna samling"
@@ -581,6 +584,8 @@ list_access_denied = "Du har inte rättigheter att se denna lista."
 list_edit_name_required = "Namn på lista behövs."
 load_tag_error = "Fel: taggar kunde inte hämtas."
 Loading = "Laddar"
+Loan History = "Utlåningshistorik"
+loan_history_empty = "Du har inga lån i utlåningshistoriken."
 Local Login = "Lokal login"
 local_login_desc = "Ange användarnamn och lösenord du skapade för den här webbplatsen."
 Located = "Placering"
@@ -840,6 +845,7 @@ results = "resultat"
 Results for = "Resultat för sökningen"
 Results per page = "Resultat per sida"
 Resumption Token = "Resumption Token"
+Return Date = "Återlämningsdag"
 Review by = "Recensent"
 Reviews = "Recensioner"
 Save = "Spara"
@@ -911,8 +917,14 @@ sort_author = "Upphovsman"
 sort_author_author = "Alfabetiskt"
 sort_author_relevance = "Relevans"
 sort_callnumber = "Signum"
+sort_checkout_date_asc = "Utlåningsdag (äldst först)"
+sort_checkout_date_desc = "Utlåningsdag (nyast först)"
 sort_count = "Antal"
+sort_due_date_asc = "Förfallodag (äldst först)"
+sort_due_date_desc = "Förfallodag (nyast först)"
 sort_relevance = "Relevans"
+sort_return_date_asc = "Returneringsdag (äldst först)"
+sort_return_date_desc = "Returneringsdag (nyast först)"
 sort_title = "Titel"
 sort_year = "Tid (nyaste först)"
 sort_year asc = "Tid (äldsta först)"
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index f08f0503826..e62fbeb6145 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -848,7 +848,7 @@ $staticRoutes = [
     'MyResearch/Account', 'MyResearch/ChangePassword', 'MyResearch/CheckedOut',
     'MyResearch/Delete', 'MyResearch/DeleteList', 'MyResearch/Edit',
     'MyResearch/Email', 'MyResearch/Favorites', 'MyResearch/Fines',
-    'MyResearch/Holds', 'MyResearch/Home',
+    'MyResearch/HistoricLoans', 'MyResearch/Holds', 'MyResearch/Home',
     'MyResearch/ILLRequests', 'MyResearch/Logout',
     'MyResearch/NewPassword', 'MyResearch/Profile',
     'MyResearch/Recover', 'MyResearch/SaveSearch',
diff --git a/module/VuFind/src/VuFind/Controller/MyResearchController.php b/module/VuFind/src/VuFind/Controller/MyResearchController.php
index 86e18221c1d..9f2240f8bea 100644
--- a/module/VuFind/src/VuFind/Controller/MyResearchController.php
+++ b/module/VuFind/src/VuFind/Controller/MyResearchController.php
@@ -1211,6 +1211,132 @@ class MyResearchController extends AbstractBase
         );
     }
 
+    /**
+     * Send list of historic loans to view
+     *
+     * @return mixed
+     */
+    public function historicloansAction()
+    {
+        // Stop now if the user does not have valid catalog credentials available:
+        if (!is_array($patron = $this->catalogLogin())) {
+            return $patron;
+        }
+
+        // Connect to the ILS:
+        $catalog = $this->getILS();
+
+        // Check function config
+        $functionConfig = $catalog->checkFunction(
+            'getMyTransactionHistory', $patron
+        );
+        if (false === $functionConfig) {
+            $this->flashMessenger()->addErrorMessage('ils_action_unavailable');
+            return $this->createViewModel();
+        }
+
+        // Get page and page size:
+        $page = (int)$this->params()->fromQuery('page', 1);
+        $config = $this->getConfig();
+        $limit = isset($config->Catalog->historic_loan_page_size)
+            ? $config->Catalog->historic_loan_page_size : 50;
+        $ilsPaging = true;
+        if (isset($functionConfig['max_results'])) {
+            $limit = min([$functionConfig['max_results'], $limit]);
+        } elseif (isset($functionConfig['page_size'])) {
+            if (!in_array($limit, $functionConfig['page_size'])) {
+                $limit = isset($functionConfig['default_page_size'])
+                    ? $functionConfig['default_page_size']
+                    : $functionConfig['page_size'][0];
+            }
+        } else {
+            $ilsPaging = false;
+        }
+
+        // Get sort settings
+        $sort = false;
+        if (!empty($functionConfig['sort'])) {
+            $sort = $this->params()->fromQuery('sort');
+            if (!isset($functionConfig['sort'][$sort])) {
+                if (isset($functionConfig['default_sort'])) {
+                    $sort = $functionConfig['default_sort'];
+                } else {
+                    reset($functionConfig['sort']);
+                    $sort = key($functionConfig['sort']);
+                }
+            }
+        }
+
+        // Configure call params
+        $params = [
+            'sort' => $sort
+        ];
+        if ($ilsPaging) {
+            $params['page'] = $page;
+            $params['limit'] = $limit;
+        }
+
+        // Get checked out item details:
+        $result = $catalog->getMyTransactionHistory($patron, $params);
+
+        if (isset($result['success']) && !$result['success']) {
+            $this->flashMessenger()->addErrorMessage($result['status']);
+            return $this->createViewModel();
+        }
+
+        // Build paginator if needed:
+        if ($ilsPaging && $limit < $result['count']) {
+            $adapter = new \Zend\Paginator\Adapter\NullFill($result['count']);
+            $paginator = new \Zend\Paginator\Paginator($adapter);
+            $paginator->setItemCountPerPage($limit);
+            $paginator->setCurrentPageNumber($page);
+            $pageStart = $paginator->getAbsoluteItemNumber(1) - 1;
+            $pageEnd = $paginator->getAbsoluteItemNumber($limit) - 1;
+        } elseif ($limit > 0 && $limit < $result['count']) {
+            $adapter = new \Zend\Paginator\Adapter\ArrayAdapter(
+                $result['transactions']
+            );
+            $paginator = new \Zend\Paginator\Paginator($adapter);
+            $paginator->setItemCountPerPage($limit);
+            $paginator->setCurrentPageNumber($page);
+            $pageStart = $paginator->getAbsoluteItemNumber(1) - 1;
+            $pageEnd = $paginator->getAbsoluteItemNumber($limit) - 1;
+        } else {
+            $paginator = false;
+            $pageStart = 0;
+            $pageEnd = $result['count'];
+        }
+
+        $transactions = $hiddenTransactions = [];
+        foreach ($result['transactions'] as $i => $current) {
+            // Build record driver (only for the current visible page):
+            if ($ilsPaging || ($i >= $pageStart && $i <= $pageEnd)) {
+                $transactions[] = $this->getDriverForILSRecord($current);
+            } else {
+                $hiddenTransactions[] = $current;
+            }
+        }
+
+        // Handle view params for sorting
+        $sortList = [];
+        if (!empty($functionConfig['sort'])) {
+            foreach ($functionConfig['sort'] as $key => $value) {
+                $sortList[$key] = [
+                    'desc' => $value,
+                    'url' => '?sort=' . urlencode($key),
+                    'selected' => $sort == $key
+                ];
+            }
+        }
+
+        return $this->createViewModel(
+            compact(
+                'transactions', 'paginator', 'params',
+                'hiddenTransactions', 'sortList', 'functionConfig'
+            )
+        );
+    }
+
     /**
      * Send list of fines to view
      *
diff --git a/module/VuFind/src/VuFind/ILS/Connection.php b/module/VuFind/src/VuFind/ILS/Connection.php
index bdb1227d25a..3cf0ece8ce4 100644
--- a/module/VuFind/src/VuFind/ILS/Connection.php
+++ b/module/VuFind/src/VuFind/ILS/Connection.php
@@ -612,6 +612,29 @@ class Connection implements TranslatorAwareInterface, LoggerAwareInterface
         return false;
     }
 
+    /**
+     * Check Historic Loans
+     *
+     * A support method for checkFunction(). This is responsible for checking
+     * the driver configuration to determine if the system supports historic
+     * loans.
+     *
+     * @param array $functionConfig Function configuration
+     * @param array $params         Patron data
+     *
+     * @return mixed On success, an associative array with specific function keys
+     * and values; on failure, false.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function checkMethodgetMyTransactionHistory($functionConfig, $params)
+    {
+        if ($this->checkCapability('getMyTransactionHistory', [$params ?: []])) {
+            return $functionConfig;
+        }
+        return false;
+    }
+
     /**
      * Get proper help text from the function config
      *
diff --git a/module/VuFind/src/VuFind/ILS/Driver/Aleph.php b/module/VuFind/src/VuFind/ILS/Driver/Aleph.php
index d2aecbb189d..bc84ca594bf 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/Aleph.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/Aleph.php
@@ -842,17 +842,106 @@ class Aleph extends AbstractBase implements \Zend\Log\LoggerAwareInterface,
     }
 
     /**
-     * Get Patron Transaction History
+     * Get Patron Loan History
      *
-     * @param array $user The patron array from patronLogin
+     * @param array $user   The patron array from patronLogin
+     * @param array $params Parameters
      *
      * @throws \VuFind\Exception\Date
      * @throws ILSException
-     * @return array      Array of the patron's transactions on success.
+     * @return array      Array of the patron's historic loans on success.
      */
-    public function getMyHistory($user)
+    public function getMyTransactionHistory($user, $params = null)
     {
-        return $this->getMyTransactions($user, true);
+        $userId = $user['id'];
+        $historicLoans = [];
+        $requestParams = [
+            "view" => "full",
+            "type" => "history",
+        ];
+
+        $xml = $this->doRestDLFRequest(
+            ['patron', $userId, 'circulationActions', 'loans'], $requestParams
+        );
+
+        foreach ($xml->xpath('//loan') as $item) {
+            $z36h = $item->z36h;
+            $z13 = $item->z13;
+            $z30 = $item->z30;
+            $group = $item->xpath('@href');
+            $group = substr(strrchr($group[0], "/"), 1);
+            $location = (string)$z36h->{'z36_pickup_location'};
+            $reqnum = (string)$z36h->{'z36-doc-number'}
+                . (string)$z36h->{'z36-item-sequence'}
+                . (string)$z36h->{'z36-sequence'};
+
+            $due = (string)$z36h->{'z36h-due-date'};
+            $returned = (string)$z36h->{'z36h-returned-date'};
+            $issued = (string)$z36h->{'z36h-loan-date'};
+            $title = (string)$z13->{'z13-title'};
+            $author = (string)$z13->{'z13-author'};
+            $isbn = (string)$z13->{'z13-isbn-issn'};
+            $barcode = (string)$z30->{'z30-barcode'};
+
+            $historicLoans[] = [
+                'id' => (string)$z30->{'z30-doc-number'},
+                'item_id' => $group,
+                'location' => $location,
+                'title' => $title,
+                'author' => $author,
+                'isbn' => [$isbn],
+                'reqnum' => $reqnum,
+                'barcode' => $barcode,
+                'checkoutDate' => $this->parseDate($issued),
+                'dueDate' => $this->parseDate($due),
+                'returnDate' => $this->parseDate($returned),
+                '_checkoutDate' => $issued,
+                '_dueDate' => $due,
+                '_returnDate' => $returned,
+            ];
+        }
+
+        if (isset($params['sort'])) {
+            switch ($params['sort']) {
+            case 'checkout asc':
+                $sorter = function ($a, $b) {
+                    return strcmp($a['_checkoutDate'], $b['_checkoutDate']);
+                };
+                break;
+            case 'return desc':
+                $sorter = function ($a, $b) {
+                    return strcmp($b['_returnDate'], $a['_returnDate']);
+                };
+                break;
+            case 'return asc':
+                $sorter = function ($a, $b) {
+                    return strcmp($a['_returnDate'], $b['_returnDate']);
+                };
+                break;
+            case 'due desc':
+                $sorter = function ($a, $b) {
+                    return strcmp($b['_dueDate'], $a['_dueDate']);
+                };
+                break;
+            case 'due asc':
+                $sorter = function ($a, $b) {
+                    return strcmp($a['_dueDate'], $b['_dueDate']);
+                };
+                break;
+            default:
+                $sorter = function ($a, $b) {
+                    return strcmp($b['_checkoutDate'], $a['_checkoutDate']);
+                };
+                break;
+            }
+
+            usort($historicLoans, $sorter);
+        }
+
+        return [
+            'count' => count($historicLoans),
+            'transactions' => $historicLoans,
+        ];
     }
 
     /**
@@ -861,25 +950,21 @@ class Aleph extends AbstractBase implements \Zend\Log\LoggerAwareInterface,
      * This is responsible for retrieving all transactions (i.e. checked out items)
      * by a specific patron.
      *
-     * @param array $user    The patron array from patronLogin
-     * @param bool  $history Include history of transactions (true) or just get
-     * current ones (false).
+     * @param array $user The patron array from patronLogin
      *
      * @throws \VuFind\Exception\Date
      * @throws ILSException
      * @return array        Array of the patron's transactions on success.
      */
-    public function getMyTransactions($user, $history = false)
+    public function getMyTransactions($user)
     {
         $userId = $user['id'];
         $transList = [];
         $params = ["view" => "full"];
-        if ($history) {
-            $params["type"] = "history";
-        }
         $xml = $this->doRestDLFRequest(
             ['patron', $userId, 'circulationActions', 'loans'], $params
         );
+
         foreach ($xml->xpath('//loan') as $item) {
             $z36 = $item->z36;
             $z13 = $item->z13;
@@ -890,25 +975,22 @@ class Aleph extends AbstractBase implements \Zend\Log\LoggerAwareInterface,
             //$docno = (string) $z36->{'z36-doc-number'};
             //$itemseq = (string) $z36->{'z36-item-sequence'};
             //$seq = (string) $z36->{'z36-sequence'};
+
             $location = (string)$z36->{'z36_pickup_location'};
             $reqnum = (string)$z36->{'z36-doc-number'}
                 . (string)$z36->{'z36-item-sequence'}
                 . (string)$z36->{'z36-sequence'};
-            $due = $returned = null;
-            if ($history) {
-                $due = $item->z36h->{'z36h-due-date'};
-                $returned = $item->z36h->{'z36h-returned-date'};
-            } else {
-                $due = (string)$z36->{'z36-due-date'};
-            }
-            //$loaned = (string) $z36->{'z36-loan-date'};
+
+            $due = (string)$z36->{'z36-due-date'};
+            $issued = (string)$z36->{'z36-loan-date'};
             $title = (string)$z13->{'z13-title'};
             $author = (string)$z13->{'z13-author'};
             $isbn = (string)$z13->{'z13-isbn-issn'};
             $barcode = (string)$z30->{'z30-barcode'};
+
             $transList[] = [
                 //'type' => $type,
-                'id' => ($history) ? null : $this->barcodeToID($barcode),
+                'id' => $this->barcodeToID($barcode),
                 'item_id' => $group,
                 'location' => $location,
                 'title' => $title,
@@ -916,14 +998,15 @@ class Aleph extends AbstractBase implements \Zend\Log\LoggerAwareInterface,
                 'isbn' => [$isbn],
                 'reqnum' => $reqnum,
                 'barcode' => $barcode,
+                'issuedate' => $this->parseDate($issued),
                 'duedate' => $this->parseDate($due),
-                'returned' => $this->parseDate($returned),
                 //'holddate' => $holddate,
                 //'delete' => $delete,
                 'renewable' => true,
                 //'create' => $this->parseDate($create)
             ];
         }
+
         return $transList;
     }
 
@@ -1584,8 +1667,29 @@ class Aleph extends AbstractBase implements \Zend\Log\LoggerAwareInterface,
     }
 
     /**
-     * Public Function which retrieves renew, hold and cancel settings from the
-     * driver ini file.
+     * Helper method to determine whether or not a certain method can be
+     * called on this driver.  Required method for any smart drivers.
+     *
+     * @param string $method The name of the called method.
+     * @param array  $params Array of passed parameters
+     *
+     * @return bool True if the method can be called with the given parameters,
+     * false otherwise.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function supportsMethod($method, $params)
+    {
+        // Loan history is only available if properly configured
+        if ($method == 'getMyTransactionHistory') {
+            return !empty($this->config['TransactionHistory']['enabled']);
+        }
+        return is_callable([$this, $method]);
+    }
+
+    /**
+     * Public Function which retrieves historic loan, renew, hold and cancel
+     * settings from the driver ini file.
      *
      * @param string $func   The name of the feature to be checked
      * @param array  $params Optional feature-specific parameters (array)
@@ -1605,6 +1709,21 @@ class Aleph extends AbstractBase implements \Zend\Log\LoggerAwareInterface,
                 "extraHoldFields" => "comments:requiredByDate:pickUpLocation",
                 "defaultRequiredDate" => "0:1:0"
             ];
+        } elseif ('getMyTransactionHistory' === $func) {
+            if (empty($this->config['TransactionHistory']['enabled'])) {
+                return false;
+            }
+            return [
+                'sort' => [
+                    'checkout desc' => 'sort_checkout_date_desc',
+                    'checkout asc' => 'sort_checkout_date_asc',
+                    'return desc' => 'sort_return_date_desc',
+                    'return asc' => 'sort_return_date_asc',
+                    'due desc' => 'sort_due_date_desc',
+                    'due asc' => 'sort_due_date_asc'
+                ],
+                'default_sort' => 'checkout desc',
+            ];
         } else {
             return [];
         }
diff --git a/module/VuFind/src/VuFind/ILS/Driver/Demo.php b/module/VuFind/src/VuFind/ILS/Driver/Demo.php
index 9ce6eeb8007..87d83b20416 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/Demo.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/Demo.php
@@ -972,6 +972,145 @@ class Demo extends AbstractBase
         return $session->transactions;
     }
 
+    /**
+     * Construct a historic transaction list for getMyTransactionHistory; may be
+     * random or pre-set depending on Demo.ini settings.
+     *
+     * @return array
+     */
+    protected function getHistoricTransactionList()
+    {
+        $this->checkIntermittentFailure();
+        // If Demo.ini includes a fixed set of transactions, load those; otherwise
+        // build some random ones.
+        return isset($this->config['Records']['historicTransactions'])
+            ? json_decode($this->config['Records']['historicTransactions'], true)
+            : $this->getRandomHistoricTransactionList();
+    }
+
+    /**
+     * Construct a random set of transactions for getMyTransactionHistory().
+     *
+     * @return array
+     */
+    protected function getRandomHistoricTransactionList()
+    {
+        // How many items are there?  %10 - 1 = 10% chance of none,
+        // 90% of 1-150 (give or take some odd maths)
+        $trans = rand() % 10 - 1 > 0 ? rand() % 15 : 0;
+
+        $transList = [];
+        for ($i = 0; $i < $trans; $i++) {
+            // Checkout date
+            $relative = rand() % 300;
+            $checkoutDate = strtotime("now -$relative days");
+            // Due date (7-30 days from checkout)
+            $dueDate = $checkoutDate + 60 * 60 * 24 * (rand() % 23 + 7);
+            // Return date (1-40 days from checkout and < now)
+            $returnDate = min(
+                [$checkoutDate + 60 * 60 * 24 * (rand() % 39 + 1), time()]
+            );
+
+            // Create a generic transaction:
+            $transList[] = $this->getRandomItemIdentifier() + [
+                'checkoutDate' => $this->dateConverter->convertToDisplayDate(
+                    'U', $checkoutDate
+                ),
+                'dueDate' => $this->dateConverter->convertToDisplayDate(
+                    'U', $dueDate
+                ),
+                'returnDate' => $this->dateConverter->convertToDisplayDate(
+                    'U', $returnDate
+                ),
+                // Raw dates for sorting
+                '_checkoutDate' => $checkoutDate,
+                '_dueDate' => $dueDate,
+                '_returnDate' => $returnDate,
+                'barcode' => sprintf("%08d", rand() % 50000),
+                'item_id' => $i,
+            ];
+            if ($this->idsInMyResearch) {
+                $transList[$i]['id'] = $this->getRandomBibId();
+                $transList[$i]['source'] = $this->getRecordSource();
+            } else {
+                $transList[$i]['title'] = 'Demo Title ' . $i;
+            }
+        }
+        return $transList;
+    }
+
+    /**
+     * Get Patron Loan History
+     *
+     * This is responsible for retrieving all historic transactions for a specific
+     * patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     * @param array $params Parameters
+     *
+     * @return mixed        Array of the patron's historic transactions on success.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getMyTransactionHistory($patron, $params)
+    {
+        $this->checkIntermittentFailure();
+        $session = $this->getSession();
+        if (!isset($session->historicLoans)) {
+            $session->historicLoans = $this->getHistoricTransactionList();
+        }
+
+        // Sort and splice the list
+        $historicLoans = $session->historicLoans;
+        if (isset($params['sort'])) {
+            switch ($params['sort']) {
+            case 'checkout asc':
+                $sorter = function ($a, $b) {
+                    return strcmp($a['_checkoutDate'], $b['_checkoutDate']);
+                };
+                break;
+            case 'return desc':
+                $sorter = function ($a, $b) {
+                    return strcmp($b['_returnDate'], $a['_returnDate']);
+                };
+                break;
+            case 'return asc':
+                $sorter = function ($a, $b) {
+                    return strcmp($a['_returnDate'], $b['_returnDate']);
+                };
+                break;
+            case 'due desc':
+                $sorter = function ($a, $b) {
+                    return strcmp($b['_dueDate'], $a['_dueDate']);
+                };
+                break;
+            case 'due asc':
+                $sorter = function ($a, $b) {
+                    return strcmp($a['_dueDate'], $b['_dueDate']);
+                };
+                break;
+            default:
+                $sorter = function ($a, $b) {
+                    return strcmp($b['_checkoutDate'], $a['_checkoutDate']);
+                };
+                break;
+            }
+
+            usort($historicLoans, $sorter);
+        }
+
+        $limit = isset($params['limit']) ? (int)$params['limit'] : 50;
+        $start = isset($params['page'])
+            ? ((int)$params['page'] - 1) * $limit : 0;
+
+        $historicLoans = array_splice($historicLoans, $start, $limit);
+
+        return [
+            'count' => count($session->historicLoans),
+            'transactions' => $historicLoans
+        ];
+    }
+
     /**
      * Get Pick Up Locations
      *
@@ -2036,6 +2175,23 @@ class Demo extends AbstractBase
                 ? $this->config['changePassword']
                 : ['minLength' => 4, 'maxLength' => 20];
         }
+        if ($function == 'getMyTransactionHistory') {
+            if (empty($this->config['TransactionHistory']['enabled'])) {
+                return false;
+            }
+            return [
+                'max_results' => 100,
+                'sort' => [
+                    'checkout desc' => 'sort_checkout_date_desc',
+                    'checkout asc' => 'sort_checkout_date_asc',
+                    'return desc' => 'sort_return_date_desc',
+                    'return asc' => 'sort_return_date_asc',
+                    'due desc' => 'sort_due_date_desc',
+                    'due asc' => 'sort_due_date_asc'
+                ],
+                'default_sort' => 'checkout desc'
+            ];
+        }
         return [];
     }
 }
diff --git a/module/VuFind/src/VuFind/ILS/Driver/Koha.php b/module/VuFind/src/VuFind/ILS/Driver/Koha.php
index 71728dba21f..32b0840a388 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/Koha.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/Koha.php
@@ -484,6 +484,89 @@ class Koha extends AbstractBase
         return count($blocks) ? $blocks : false;
     }
 
+    /**
+     * Get Patron Loan History
+     *
+     * This is responsible for retrieving all historic loans (i.e. items previously
+     * checked out and then returned), for a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     * @param array $params Parameters
+     *
+     * @throws \VuFind\Exception\Date
+     * @throws ILSException
+     * @return array        Array of the patron's transactions on success.
+     */
+    public function getMyTransactionHistory($patron, $params)
+    {
+        $id = 0;
+        $historicLoans = [];
+        $row = $sql = $sqlStmt = '';
+        try {
+            if (!$this->db) {
+                $this->initDb();
+            }
+            $id = $patron['id'];
+
+            // Get total count first
+            $sql = "select count(*) as cnt from old_issues " .
+                "where old_issues.borrowernumber = :id";
+            $sqlStmt = $this->db->prepare($sql);
+            $sqlStmt->execute([':id' => $id]);
+            $totalCount = $sqlStmt->fetch()['cnt'];
+
+            // Get rows
+            $limit = isset($params['limit']) ? (int)$params['limit'] : 50;
+            $start = isset($params['page'])
+                ? ((int)$params['page'] - 1) * $limit : 0;
+            if (isset($params['sort'])) {
+                $parts = explode(' ', $params['sort'], 2);
+                switch ($parts[0]) {
+                case 'return':
+                    $sort = 'RETURNED';
+                    break;
+                case 'due':
+                    $sort = 'DUEDATE';
+                    break;
+                default:
+                    $sort = 'ISSUEDATE';
+                    break;
+                }
+                $sort .= isset($parts[1]) && 'asc' === $parts[1] ? ' asc' : ' desc';
+            } else {
+                $sort = 'ISSUEDATE desc';
+            }
+            $sql = "select old_issues.issuedate as ISSUEDATE, " .
+                "old_issues.date_due as DUEDATE, items.biblionumber as " .
+                "BIBNO, items.barcode BARCODE, old_issues.returndate as RETURNED, " .
+                "biblio.title as TITLE " .
+                "from old_issues join items " .
+                "on old_issues.itemnumber = items.itemnumber " .
+                "join biblio on items.biblionumber = biblio.biblionumber " .
+                "where old_issues.borrowernumber = :id " .
+                "order by $sort limit $start,$limit";
+            $sqlStmt = $this->db->prepare($sql);
+
+            $sqlStmt->execute([':id' => $id]);
+            foreach ($sqlStmt->fetchAll() as $row) {
+                $historicLoans[] = [
+                    'title' => $row['TITLE'],
+                    'checkoutDate' => $this->displayDateTime($row['ISSUEDATE']),
+                    'dueDate' => $this->displayDateTime($row['DUEDATE']),
+                    'id' => $row['BIBNO'],
+                    'barcode' => $row['BARCODE'],
+                    'returnDate' => $this->displayDateTime($row['RETURNED']),
+                ];
+            }
+            return [
+                'count' => $totalCount,
+                'transactions' => $historicLoans
+            ];
+        } catch (PDOException $e) {
+            throw new ILSException($e->getMessage());
+        }
+    }
+
     /**
      * Get Purchase History
      *
@@ -681,4 +764,36 @@ class Koha extends AbstractBase
             return $date;
         }
     }
+
+    /**
+     * Public Function which retrieves renew, hold and cancel settings from the
+     * driver ini file.
+     *
+     * @param string $function The name of the feature to be checked
+     *
+     * @return array An array with key-value pairs.
+     */
+    public function getConfig($function)
+    {
+        if ('getMyTransactionHistory' === $function) {
+            if (empty($this->config['TransactionHistory']['enabled'])) {
+                return false;
+            }
+            return [
+                'max_results' => 100,
+                'sort' => [
+                    'checkout desc' => 'sort_checkout_date_desc',
+                    'checkout asc' => 'sort_checkout_date_asc',
+                    'return desc' => 'sort_return_date_desc',
+                    'return asc' => 'sort_return_date_asc',
+                    'due desc' => 'sort_due_date_desc',
+                    'due asc' => 'sort_due_date_asc'
+                ],
+                'default_sort' => 'checkout desc'
+            ];
+        }
+        return isset($this->config[$function])
+            ? $this->config[$function]
+            : false;
+    }
 }
diff --git a/module/VuFind/src/VuFind/ILS/Driver/KohaILSDI.php b/module/VuFind/src/VuFind/ILS/Driver/KohaILSDI.php
index 9e868034153..685a22e09b2 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/KohaILSDI.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/KohaILSDI.php
@@ -511,13 +511,26 @@ class KohaILSDI extends \VuFind\ILS\Driver\AbstractBase implements
      */
     public function getConfig($function)
     {
-        $functionConfig = "";
-        if (isset($this->config[$function])) {
-            $functionConfig = $this->config[$function];
-        } else {
-            $functionConfig = false;
+        if ('getMyTransactionHistory' === $function) {
+            if (empty($this->config['TransactionHistory']['enabled'])) {
+                return false;
+            }
+            return [
+                'max_results' => 100,
+                'sort' => [
+                    'checkout desc' => 'sort_checkout_date_desc',
+                    'checkout asc' => 'sort_checkout_date_asc',
+                    'return desc' => 'sort_return_date_desc',
+                    'return asc' => 'sort_return_date_asc',
+                    'due desc' => 'sort_due_date_desc',
+                    'due asc' => 'sort_due_date_asc'
+                ],
+                'default_sort' => 'checkout desc'
+            ];
         }
-        return $functionConfig;
+        return isset($this->config[$function])
+            ? $this->config[$function]
+            : false;
     }
 
     /**
@@ -1382,6 +1395,89 @@ class KohaILSDI extends \VuFind\ILS\Driver\AbstractBase implements
         return count($blocks) ? $blocks : false;
     }
 
+    /**
+     * Get Patron Loan History
+     *
+     * This is responsible for retrieving all historic loans (i.e. items previously
+     * checked out and then returned), for a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     * @param array $params Parameters
+     *
+     * @throws \VuFind\Exception\Date
+     * @throws ILSException
+     * @return array        Array of the patron's transactions on success.
+     */
+    public function getMyTransactionHistory($patron, $params)
+    {
+        $id = 0;
+        $historicLoans = [];
+        $row = $sql = $sqlStmt = '';
+        try {
+            if (!$this->db) {
+                $this->initDb();
+            }
+            $id = $patron['id'];
+
+            // Get total count first
+            $sql = "select count(*) as cnt from old_issues " .
+                "where old_issues.borrowernumber = :id";
+            $sqlStmt = $this->db->prepare($sql);
+            $sqlStmt->execute([':id' => $id]);
+            $totalCount = $sqlStmt->fetch()['cnt'];
+
+            // Get rows
+            $limit = isset($params['limit']) ? (int)$params['limit'] : 50;
+            $start = isset($params['page'])
+                ? ((int)$params['page'] - 1) * $limit : 0;
+            if (isset($params['sort'])) {
+                $parts = explode(' ', $params['sort'], 2);
+                switch ($parts[0]) {
+                case 'return':
+                    $sort = 'RETURNED';
+                    break;
+                case 'due':
+                    $sort = 'DUEDATE';
+                    break;
+                default:
+                    $sort = 'ISSUEDATE';
+                    break;
+                }
+                $sort .= isset($parts[1]) && 'asc' === $parts[1] ? ' asc' : ' desc';
+            } else {
+                $sort = 'ISSUEDATE desc';
+            }
+            $sql = "select old_issues.issuedate as ISSUEDATE, " .
+                "old_issues.date_due as DUEDATE, items.biblionumber as " .
+                "BIBNO, items.barcode BARCODE, old_issues.returndate as RETURNED, " .
+                "biblio.title as TITLE " .
+                "from old_issues join items " .
+                "on old_issues.itemnumber = items.itemnumber " .
+                "join biblio on items.biblionumber = biblio.biblionumber " .
+                "where old_issues.borrowernumber = :id " .
+                "order by $sort limit $start,$limit";
+            $sqlStmt = $this->db->prepare($sql);
+
+            $sqlStmt->execute([':id' => $id]);
+            foreach ($sqlStmt->fetchAll() as $row) {
+                $historicLoans[] = [
+                    'title' => $row['TITLE'],
+                    'checkoutDate' => $this->displayDateTime($row['ISSUEDATE']),
+                    'dueDate' => $this->displayDateTime($row['DUEDATE']),
+                    'id' => $row['BIBNO'],
+                    'barcode' => $row['BARCODE'],
+                    'returnDate' => $this->displayDateTime($row['RETURNED']),
+                ];
+            }
+            return [
+                'count' => $totalCount,
+                'transactions' => $historicLoans
+            ];
+        } catch (PDOException $e) {
+            throw new ILSException($e->getMessage());
+        }
+    }
+
     /**
      * Get Patron Transactions
      *
@@ -1825,4 +1921,70 @@ class KohaILSDI extends \VuFind\ILS\Driver\AbstractBase implements
             return null;
         }
     }
+
+    /**
+     * Convert a database date to a displayable date.
+     *
+     * @param string $date Date to convert
+     *
+     * @return string
+     */
+    public function displayDate($date)
+    {
+        if (empty($date)) {
+            return "";
+        } elseif (preg_match("/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/", $date) === 1) {
+            // YYYY-MM-DD HH:MM:SS
+            return $this->dateConverter->convertToDisplayDate('Y-m-d H:i:s', $date);
+        } elseif (preg_match("/^\d{4}-\d{2}-\d{2}$/", $date) === 1) { // YYYY-MM-DD
+            return $this->dateConverter->convertToDisplayDate('Y-m-d', $date);
+        } else {
+            error_log("Unexpected date format: $date");
+            return $date;
+        }
+    }
+
+    /**
+     * Convert a database datetime to a displayable date and time.
+     *
+     * @param string $date Datetime to convert
+     *
+     * @return string
+     */
+    public function displayDateTime($date)
+    {
+        if (empty($date)) {
+            return "";
+        } elseif (preg_match("/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/", $date) === 1) {
+            // YYYY-MM-DD HH:MM:SS
+            return
+                $this->dateConverter->convertToDisplayDateAndTime(
+                    'Y-m-d H:i:s', $date
+                );
+        } else {
+            error_log("Unexpected date format: $date");
+            return $date;
+        }
+    }
+
+    /**
+     * Helper method to determine whether or not a certain method can be
+     * called on this driver.  Required method for any smart drivers.
+     *
+     * @param string $method The name of the called method.
+     * @param array  $params Array of passed parameters
+     *
+     * @return bool True if the method can be called with the given parameters,
+     * false otherwise.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function supportsMethod($method, $params)
+    {
+        // Loan history is only available if properly configured
+        if ($method == 'getMyTransactionHistory') {
+            return !empty($this->config['TransactionHistory']['enabled']);
+        }
+        return is_callable([$this, $method]);
+    }
 }
diff --git a/module/VuFind/src/VuFind/ILS/Driver/MultiBackend.php b/module/VuFind/src/VuFind/ILS/Driver/MultiBackend.php
index 2a6c5705629..39f485421a4 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/MultiBackend.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/MultiBackend.php
@@ -455,6 +455,30 @@ class MultiBackend extends AbstractBase implements \Zend\Log\LoggerAwareInterfac
         throw new ILSException('No suitable backend driver found');
     }
 
+    /**
+     * Get Patron Transaction History
+     *
+     * This is responsible for retrieving all historic transactions
+     * (i.e. checked out items) by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     * @param array $params Retrieval params
+     *
+     * @return array        Array of the patron's transactions
+     */
+    public function getMyTransactionHistory($patron, $params)
+    {
+        $source = $this->getSource($patron['cat_username']);
+        $driver = $this->getDriver($source);
+        if ($driver) {
+            $transactions = $driver->getMyTransactionHistory(
+                $this->stripIdPrefixes($patron, $source), $params
+            );
+            return $this->addIdPrefixes($transactions, $source);
+        }
+        throw new ILSException('No suitable backend driver found');
+    }
+
     /**
      * Get Renew Details
      *
diff --git a/module/VuFind/src/VuFind/ILS/Driver/SierraRest.php b/module/VuFind/src/VuFind/ILS/Driver/SierraRest.php
index f8147c42293..051de58e90f 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/SierraRest.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/SierraRest.php
@@ -617,6 +617,85 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
         return $finalResult;
     }
 
+    /**
+     * Get Patron Transaction History
+     *
+     * This is responsible for retrieving all historic transactions (i.e. checked
+     * out items) by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     * @param array $params Parameters
+     *
+     * @throws DateException
+     * @throws ILSException
+     * @return array        Array of the patron's historic transactions on success.
+     */
+    public function getMyTransactionHistory($patron, $params)
+    {
+        $pageSize = isset($params['limit']) ? $params['limit'] : 50;
+        $offset = isset($params['page']) ? ($params['page'] - 1) * $pageSize : 0;
+        $sortOrder = isset($params['sort']) && 'checkout asc' === $params['sort']
+            ? 'asc' : 'desc';
+        $result = $this->makeRequest(
+            ['v3', 'patrons', $patron['id'], 'checkouts', 'history'],
+            [
+                'limit' => $pageSize,
+                'offset' => $offset,
+                'sortField' => 'outDate',
+                'sortOrder' => $sortOrder,
+                'fields' => 'item,outDate'
+            ],
+            'GET',
+            $patron
+        );
+        if (isset($result['code'])) {
+            return [
+                'success' => false,
+                'status' => 146 === $result['code']
+                    ? 'ils_transaction_history_disabled'
+                    : 'ils_connection_failed'
+            ];
+        }
+        $transactions = [];
+        foreach ($result['entries'] as $entry) {
+            $transaction = [
+                'id' => '',
+                'item_id' => $this->extractId($entry['item']),
+                'checkoutDate' => $this->dateConverter->convertToDisplayDate(
+                    'Y-m-d', $entry['outDate']
+                )
+            ];
+            // Fetch item information
+            $item = $this->makeRequest(
+                ['v3', 'items', $transaction['item_id']],
+                ['fields' => 'bibIds,varFields'],
+                'GET',
+                $patron
+            );
+            $transaction['volume'] = $this->extractVolume($item);
+            if (!empty($item['bibIds'])) {
+                $transaction['id'] = $item['bibIds'][0];
+
+                // Fetch bib information
+                $bib = $this->getBibRecord(
+                    $transaction['id'], 'title,publishYear', $patron
+                );
+                if (!empty($bib['title'])) {
+                    $transaction['title'] = $bib['title'];
+                }
+                if (!empty($bib['publishYear'])) {
+                    $transaction['publication_year'] = $bib['publishYear'];
+                }
+            }
+            $transactions[] = $transaction;
+        }
+
+        return [
+            'count' => isset($result['total']) ? $result['total'] : 0,
+            'transactions' => $transactions
+        ];
+    }
+
     /**
      * Get Patron Holds
      *
@@ -1079,6 +1158,19 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
      */
     public function getConfig($function, $params = null)
     {
+        if ('getMyTransactionHistory' === $function) {
+            if (empty($this->config['TransactionHistory']['enabled'])) {
+                return false;
+            }
+            return [
+                'max_results' => 100,
+                'sort' => [
+                    'checkout desc' => 'sort_checkout_date_desc',
+                    'checkout asc' => 'sort_checkout_date_asc'
+                ],
+                'default_sort' => 'checkout desc'
+            ];
+        }
         return isset($this->config[$function])
             ? $this->config[$function] : false;
     }
@@ -1097,10 +1189,14 @@ class SierraRest extends AbstractBase implements TranslatorAwareInterface,
      */
     public function supportsMethod($method, $params)
     {
-        // Special case: change password is only available if properly configured.
+        // Changing password is only available if properly configured.
         if ($method == 'changePassword') {
             return isset($this->config['changePassword']);
         }
+        // Loan history is only available if properly configured
+        if ($method == 'getMyTransactionHistory') {
+            return !empty($this->config['TransactionHistory']['enabled']);
+        }
         return is_callable([$this, $method]);
     }
 
diff --git a/themes/bootstrap3/templates/myresearch/controls/sort.phtml b/themes/bootstrap3/templates/myresearch/controls/sort.phtml
new file mode 100644
index 00000000000..87cf4cb838e
--- /dev/null
+++ b/themes/bootstrap3/templates/myresearch/controls/sort.phtml
@@ -0,0 +1,11 @@
+<div class="search-controls">
+  <form class="search-sort" action="<?=$this->currentPath()?>" method="get" name="sort">
+    <label for="sort_options_1"><?=$this->transEsc('Sort')?></label>
+    <select id="sort_options_1" name="sort" class="jumpMenu form-control">
+      <? foreach ($this->sortList as $sortType => $sortData): ?>
+        <option value="<?=$this->escapeHtmlAttr($sortType)?>"<?=$sortData['selected']?' selected="selected"':''?>><?=$this->transEsc($sortData['desc'])?></option>
+      <? endforeach; ?>
+    </select>
+    <noscript><input type="submit" class="btn btn-default" value="<?=$this->transEsc("Set")?>" /></noscript>
+  </form>
+</div>
diff --git a/themes/bootstrap3/templates/myresearch/historicloans.phtml b/themes/bootstrap3/templates/myresearch/historicloans.phtml
new file mode 100644
index 00000000000..a5a78967b72
--- /dev/null
+++ b/themes/bootstrap3/templates/myresearch/historicloans.phtml
@@ -0,0 +1,131 @@
+<?
+  // Set up page title:
+  $this->headTitle($this->translate('Loan History'));
+
+  // Set up breadcrumbs:
+  $this->layout()->breadcrumbs = '<li><a href="' . $this->url('myresearch-home') . '">' . $this->transEsc('Your Account') . '</a></li> <li class="active">' . $this->transEsc('Loan History') . '</li>';
+?>
+
+<div class="<?=$this->layoutClass('mainbody')?>">
+  <h2><?=$this->transEsc('Loan History')?></h2>
+  <?=$this->flashmessages()?>
+
+  <?=$this->context($this)->renderInContext('librarycards/selectcard.phtml', ['user' => $this->auth()->isLoggedIn()]); ?>
+
+  <? if (!empty($this->transactions)): ?>
+    <nav class="search-header hidden-print">
+      <? if ($this->paginator): ?>
+        <div class="search-stats">
+          <?
+            $end = min(
+              $this->paginator->getAbsoluteItemNumber($this->paginator->getItemCountPerPage()),
+              $this->paginator->getTotalItemCount()
+            );
+            $transParams = [
+              '%%start%%' => $this->localizedNumber($this->paginator->getAbsoluteItemNumber(1)),
+              '%%end%%' => $this->localizedNumber($end),
+              '%%total%%' => $this->localizedNumber($this->paginator->getTotalItemCount())
+            ];
+          ?>
+          <?=$this->translate('showing_items_of_html', $transParams); ?>
+        </div>
+      <? endif; ?>
+      <? if ($this->sortList): ?>
+        <?=$this->context($this)->renderInContext('myresearch/controls/sort.phtml', ['sortList' => $this->sortList]); ?>
+      <? endif; ?>
+    </nav>
+
+    <? $i = 0; foreach ($this->transactions as $resource): ?>
+      <? $ilsDetails = $resource->getExtraDetail('ils_details'); ?>
+      <div id="record<?=$this->escapeHtmlAttr($resource->getUniqueId())?>" class="result">
+        <?
+          $coverDetails = $this->record($resource)->getCoverDetails('checkedout', 'small', $this->recordLink()->getUrl($resource));
+          $cover = $coverDetails['html'];
+          $thumbnail = false;
+          $thumbnailAlignment = $this->record($resource)->getThumbnailAlignment('account');
+          if ($cover):
+            ob_start(); ?>
+            <div class="media-<?=$thumbnailAlignment ?> <?=$this->escapeHtmlAttr($coverDetails['size'])?>">
+              <?=$cover ?>
+            </div>
+            <? $thumbnail = ob_get_contents();
+            ob_end_clean();
+          endif; ?>
+        <div class="media">
+          <? if ($thumbnail && $thumbnailAlignment == 'left'): ?>
+            <?=$thumbnail ?>
+          <? endif ?>
+          <div class="media-body">
+            <?
+              // If this is a non-missing Solr record, we should display a link:
+              if (is_a($resource, 'VuFind\\RecordDriver\\SolrDefault') && !is_a($resource, 'VuFind\\RecordDriver\\Missing')) {
+                $title = $resource->getTitle();
+                $title = empty($title) ? $this->transEsc('Title not available') : $this->escapeHtml($title);
+                echo '<a href="' . $this->recordLink()->getUrl($resource) .
+                  '" class="title">' . $title . '</a>';
+              } elseif (isset($ilsDetails['title']) && !empty($ilsDetails['title'])){
+                // If the record is not available in Solr, perhaps the ILS driver sent us a title we can show...
+                echo $this->escapeHtml($ilsDetails['title']);
+              } else {
+                // Last resort -- indicate that no title could be found.
+                echo $this->transEsc('Title not available');
+              }
+            ?><br/>
+            <? $listAuthors = $resource->getPrimaryAuthors(); if (!empty($listAuthors)): ?>
+              <?=$this->transEsc('by')?>:
+              <a href="<?=$this->record($resource)->getLink('author', $listAuthors[0])?>"><?=$this->escapeHtml($listAuthors[0])?></a><? if (count($listAuthors) > 1): ?>, <?=$this->transEsc('more_authors_abbrev')?><? endif; ?><br/>
+            <? endif; ?>
+            <? if (count($resource->getFormats()) > 0): ?>
+              <?=$this->record($resource)->getFormatList() ?>
+              <br/>
+            <? endif; ?>
+            <? if (!empty($ilsDetails['volume'])): ?>
+              <strong><?=$this->transEsc('Volume')?>:</strong> <?=$this->escapeHtml($ilsDetails['volume'])?>
+              <br />
+            <? endif; ?>
+
+            <? if (!empty($ilsDetails['publication_year'])): ?>
+              <strong><?=$this->transEsc('Year of Publication')?>:</strong> <?=$this->escapeHtml($ilsDetails['publication_year'])?>
+              <br />
+            <? endif; ?>
+
+            <? if (!empty($ilsDetails['institution_name']) && (empty($ilsDetails['borrowingLocation']) || $ilsDetails['institution_name'] != $ilsDetails['borrowingLocation'])): ?>
+              <strong><?=$this->transEsc('location_' . $ilsDetails['institution_name'], [], $ilsDetails['institution_name'])?></strong>
+              <br />
+            <? endif; ?>
+
+            <? if (!empty($ilsDetails['borrowingLocation'])): ?>
+              <strong><?=$this->transEsc('Borrowing Location')?>:</strong> <?=$this->transEsc('location_' . $ilsDetails['borrowingLocation'], [], $ilsDetails['borrowingLocation'])?>
+              <br />
+            <? endif; ?>
+
+            <? if (!empty($ilsDetails['checkoutDate'])): ?>
+              <strong><?=$this->transEsc('Checkout Date')?>:</strong> <?=$this->escapeHtml($ilsDetails['checkoutDate'])?><? if (isset($ilsDetails['checkoutTime'])): ?> <span class="checkout-time"><?=$this->escapeHtml($ilsDetails['checkoutTime'])?><? endif; ?></span><br/>
+            <? endif; ?>
+            <? if (!empty($ilsDetails['returnDate'])): ?>
+              <strong><?=$this->transEsc('Return Date')?>:</strong> <?=$this->escapeHtml($ilsDetails['returnDate'])?><? if (isset($ilsDetails['returnTime'])): ?> <span class="return-time"><?=$this->escapeHtml($ilsDetails['returnTime'])?><? endif; ?></span><br/>
+            <? endif; ?>
+            <? if (!empty($ilsDetails['dueDate'])): ?>
+              <strong><?=$this->transEsc('Due Date')?>:</strong> <?=$this->escapeHtml($ilsDetails['dueDate'])?><? if (isset($ilsDetails['dueTime'])): ?> <span class="due-time"><?=$this->escapeHtml($ilsDetails['dueTime'])?></span><? endif; ?>
+            <? endif; ?>
+
+            <? if (isset($ilsDetails['message']) && !empty($ilsDetails['message'])): ?>
+              <div class="alert alert-info"><?=$this->transEsc($ilsDetails['message'])?></div>
+            <? endif; ?>
+          </div>
+          <? if ($thumbnail && $thumbnailAlignment == 'right'): ?>
+            <?=$thumbnail ?>
+          <? endif ?>
+        </div>
+        <?=$resource->tryMethod('supportsCoinsOpenUrl')?'<span class="Z3988" title="' . $this->escapeHtmlAttr($resource->getCoinsOpenUrl()) . '"></span>':''?>
+      </div>
+    <? endforeach; ?>
+    <?=$this->paginator ? $this->paginationControl($this->paginator, 'Sliding', 'Helpers/pagination.phtml', ['params' => $this->params]) : ''?>
+  <? else: ?>
+    <?=$this->transEsc('loan_history_empty')?>
+  <? endif; ?>
+</div>
+
+<div class="<?=$this->layoutClass('sidebar')?>">
+  <?=$this->context($this)->renderInContext("myresearch/menu.phtml", ['active' => 'historicloans'])?>
+</div>
diff --git a/themes/bootstrap3/templates/myresearch/menu.phtml b/themes/bootstrap3/templates/myresearch/menu.phtml
index 7dc68d9957f..4c1de81a218 100644
--- a/themes/bootstrap3/templates/myresearch/menu.phtml
+++ b/themes/bootstrap3/templates/myresearch/menu.phtml
@@ -16,6 +16,11 @@
         <i class="fa fa-fw fa-book" aria-hidden="true"></i> <?=$this->transEsc('Checked Out Items')?>
       </a>
     <? endif; ?>
+    <? if ($this->ils()->checkFunction('getMyTransactionHistory', $capabilityParams)): ?>
+      <a href="<?=$this->url('myresearch-historicloans')?>"<?=$this->active == 'historicloans' ? ' class="active"' : ''?>">
+        <i class="fa fa-fw fa-history" aria-hidden="true"></i> <?=$this->transEsc('Loan History')?>
+      </a>
+    <? endif; ?>
     <? if ($this->ils()->checkCapability('getMyHolds', $capabilityParams)): ?>
       <a href="<?=$this->url('myresearch-holds')?>"<?=$this->active == 'holds' ? ' class="active"' : ''?>>
         <i class="fa fa-fw fa-flag" aria-hidden="true"></i> <?=$this->transEsc('Holds and Recalls')?>
-- 
GitLab