diff --git a/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.ini b/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.ini
new file mode 100644
index 0000000..77b0768
--- /dev/null
+++ b/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.ini
@@ -0,0 +1,2 @@
+PLG_SYSTEM_MOKOSUITELIBRARY="System - MokoSuite Library"
+PLG_SYSTEM_MOKOSUITELIBRARY_DESC="Library and resource lending — catalog, checkout, returns, reservations, overdue tracking, fines."
diff --git a/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.sys.ini b/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.sys.ini
new file mode 100644
index 0000000..77b0768
--- /dev/null
+++ b/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.sys.ini
@@ -0,0 +1,2 @@
+PLG_SYSTEM_MOKOSUITELIBRARY="System - MokoSuite Library"
+PLG_SYSTEM_MOKOSUITELIBRARY_DESC="Library and resource lending — catalog, checkout, returns, reservations, overdue tracking, fines."
diff --git a/source/packages/plg_system_mokosuitelibrary/mokosuitelibrary.xml b/source/packages/plg_system_mokosuitelibrary/mokosuitelibrary.xml
new file mode 100644
index 0000000..75e9ae9
--- /dev/null
+++ b/source/packages/plg_system_mokosuitelibrary/mokosuitelibrary.xml
@@ -0,0 +1,39 @@
+
+
+ System - MokoSuite Library
+ mokosuitelibrary
+ Moko Consulting
+ 2026-06-23
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GPL-3.0-or-later
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ 01.00.00
+ 8.3
+ PLG_SYSTEM_MOKOSUITELIBRARY_DESC
+ Moko\Plugin\System\MokoSuiteLibrary
+
+ src
+ services
+ language
+ sql
+
+
+ en-GB/plg_system_mokosuitelibrary.ini
+ en-GB/plg_system_mokosuitelibrary.sys.ini
+
+ sql/install.mysql.sql
+ sql/uninstall.mysql.sql
+
+
+
+
+
+
diff --git a/source/packages/plg_system_mokosuitelibrary/services/provider.php b/source/packages/plg_system_mokosuitelibrary/services/provider.php
new file mode 100644
index 0000000..ebc2fc6
--- /dev/null
+++ b/source/packages/plg_system_mokosuitelibrary/services/provider.php
@@ -0,0 +1,27 @@
+set(
+ PluginInterface::class,
+ function (Container $container) {
+ $dispatcher = $container->get(DispatcherInterface::class);
+ $plugin = new Library($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuitelibrary'));
+ $plugin->setApplication(Factory::getApplication());
+ return $plugin;
+ }
+ );
+ }
+};
diff --git a/source/packages/plg_system_mokosuitelibrary/sql/install.mysql.sql b/source/packages/plg_system_mokosuitelibrary/sql/install.mysql.sql
new file mode 100644
index 0000000..af3f273
--- /dev/null
+++ b/source/packages/plg_system_mokosuitelibrary/sql/install.mysql.sql
@@ -0,0 +1,122 @@
+--
+-- MokoSuite Library Tables
+--
+
+CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_items` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `title` VARCHAR(255) NOT NULL,
+ `subtitle` VARCHAR(255) NOT NULL DEFAULT '',
+ `isbn` VARCHAR(20) NOT NULL DEFAULT '',
+ `barcode` VARCHAR(50) NOT NULL DEFAULT '',
+ `item_type` ENUM('book','dvd','equipment','tool','game','periodical','digital') NOT NULL DEFAULT 'book',
+ `category` VARCHAR(100) NOT NULL DEFAULT '',
+ `author` VARCHAR(255) NOT NULL DEFAULT '',
+ `publisher` VARCHAR(255) NOT NULL DEFAULT '',
+ `publish_year` SMALLINT UNSIGNED DEFAULT NULL,
+ `description` TEXT,
+ `cover_image` VARCHAR(500) NOT NULL DEFAULT '',
+ `total_copies` INT UNSIGNED NOT NULL DEFAULT 1,
+ `available_copies` INT UNSIGNED NOT NULL DEFAULT 1,
+ `published` TINYINT NOT NULL DEFAULT 1,
+ `created` DATETIME NOT NULL,
+ `modified` DATETIME DEFAULT NULL,
+ `created_by` INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ KEY `idx_isbn` (`isbn`),
+ KEY `idx_barcode` (`barcode`),
+ KEY `idx_type` (`item_type`),
+ KEY `idx_category` (`category`),
+ KEY `idx_author` (`author`(100)),
+ FULLTEXT `ft_search` (`title`, `subtitle`, `author`, `description`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_copies` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `item_id` INT UNSIGNED NOT NULL,
+ `copy_number` INT UNSIGNED NOT NULL DEFAULT 1,
+ `barcode` VARCHAR(50) NOT NULL DEFAULT '',
+ `condition_grade` ENUM('new','good','fair','poor','withdrawn') NOT NULL DEFAULT 'good',
+ `location` VARCHAR(100) NOT NULL DEFAULT '',
+ `status` ENUM('available','checked_out','on_hold','in_repair','lost','withdrawn') NOT NULL DEFAULT 'available',
+ `notes` TEXT,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_barcode` (`barcode`),
+ KEY `idx_item` (`item_id`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_patrons` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `contact_id` INT DEFAULT NULL,
+ `card_number` VARCHAR(50) NOT NULL DEFAULT '',
+ `name` VARCHAR(255) NOT NULL,
+ `email` VARCHAR(255) NOT NULL DEFAULT '',
+ `phone` VARCHAR(50) NOT NULL DEFAULT '',
+ `max_items` INT UNSIGNED NOT NULL DEFAULT 10,
+ `status` ENUM('active','suspended','expired') NOT NULL DEFAULT 'active',
+ `suspended_reason` VARCHAR(255) NOT NULL DEFAULT '',
+ `membership_expires` DATE DEFAULT NULL,
+ `created` DATETIME NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_card` (`card_number`),
+ KEY `idx_contact` (`contact_id`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_checkouts` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `copy_id` INT UNSIGNED NOT NULL,
+ `patron_id` INT UNSIGNED NOT NULL,
+ `checked_out_at` DATETIME NOT NULL,
+ `due_date` DATE NOT NULL,
+ `returned_at` DATETIME DEFAULT NULL,
+ `renewals` INT UNSIGNED NOT NULL DEFAULT 0,
+ `status` ENUM('active','returned','overdue','lost') NOT NULL DEFAULT 'active',
+ `created_by` INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ KEY `idx_copy` (`copy_id`),
+ KEY `idx_patron` (`patron_id`),
+ KEY `idx_due` (`due_date`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_reservations` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `item_id` INT UNSIGNED NOT NULL,
+ `patron_id` INT UNSIGNED NOT NULL,
+ `position` INT UNSIGNED NOT NULL DEFAULT 1,
+ `status` ENUM('waiting','ready','fulfilled','cancelled','expired') NOT NULL DEFAULT 'waiting',
+ `notified_at` DATETIME DEFAULT NULL,
+ `expires_at` DATETIME DEFAULT NULL,
+ `created` DATETIME NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_item` (`item_id`, `position`),
+ KEY `idx_patron` (`patron_id`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_fines` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `patron_id` INT UNSIGNED NOT NULL,
+ `checkout_id` INT UNSIGNED DEFAULT NULL,
+ `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ `paid` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ `reason` VARCHAR(255) NOT NULL DEFAULT 'overdue',
+ `status` ENUM('outstanding','paid','waived') NOT NULL DEFAULT 'outstanding',
+ `waived_reason` VARCHAR(255) NOT NULL DEFAULT '',
+ `created` DATETIME NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_patron` (`patron_id`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_fine_payments` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `fine_id` INT UNSIGNED NOT NULL,
+ `amount` DECIMAL(10,2) NOT NULL,
+ `method` ENUM('cash','card','online','waiver') NOT NULL DEFAULT 'cash',
+ `paid_at` DATETIME NOT NULL,
+ `received_by` INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ KEY `idx_fine` (`fine_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/source/packages/plg_system_mokosuitelibrary/sql/uninstall.mysql.sql b/source/packages/plg_system_mokosuitelibrary/sql/uninstall.mysql.sql
new file mode 100644
index 0000000..bd6253d
--- /dev/null
+++ b/source/packages/plg_system_mokosuitelibrary/sql/uninstall.mysql.sql
@@ -0,0 +1,11 @@
+--
+-- MokoSuite Library — Uninstall
+--
+
+DROP TABLE IF EXISTS `#__mokosuitelibrary_fine_payments`;
+DROP TABLE IF EXISTS `#__mokosuitelibrary_fines`;
+DROP TABLE IF EXISTS `#__mokosuitelibrary_reservations`;
+DROP TABLE IF EXISTS `#__mokosuitelibrary_checkouts`;
+DROP TABLE IF EXISTS `#__mokosuitelibrary_copies`;
+DROP TABLE IF EXISTS `#__mokosuitelibrary_patrons`;
+DROP TABLE IF EXISTS `#__mokosuitelibrary_items`;
diff --git a/source/packages/plg_system_mokosuitelibrary/src/Extension/Library.php b/source/packages/plg_system_mokosuitelibrary/src/Extension/Library.php
new file mode 100644
index 0000000..ada50d1
--- /dev/null
+++ b/source/packages/plg_system_mokosuitelibrary/src/Extension/Library.php
@@ -0,0 +1,18 @@
+get(DatabaseInterface::class);
+
+ $q = $db->getQuery(true)
+ ->select('i.*, i.available_copies > 0 AS is_available')
+ ->from($db->quoteName('#__mokosuitelibrary_items', 'i'))
+ ->where($db->quoteName('i.published') . ' = 1')
+ ->order('i.title ASC');
+
+ if (!empty($query)) {
+ $q->where('MATCH(i.title, i.subtitle, i.author, i.description) AGAINST (' . $db->quote($query) . ' IN BOOLEAN MODE)');
+ }
+
+ if (!empty($filters['item_type'])) {
+ $q->where($db->quoteName('i.item_type') . ' = ' . $db->quote($filters['item_type']));
+ }
+
+ if (!empty($filters['category'])) {
+ $q->where($db->quoteName('i.category') . ' = ' . $db->quote($filters['category']));
+ }
+
+ if (isset($filters['available_only']) && $filters['available_only']) {
+ $q->where($db->quoteName('i.available_copies') . ' > 0');
+ }
+
+ $db->setQuery($q, $offset, $limit);
+
+ return $db->loadAssocList() ?: [];
+ }
+
+ public static function getItem(int $itemId): ?object
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->select('i.*')
+ ->from($db->quoteName('#__mokosuitelibrary_items', 'i'))
+ ->where($db->quoteName('i.id') . ' = ' . (int) $itemId);
+
+ $db->setQuery($query);
+ $item = $db->loadObject();
+
+ if ($item) {
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuitelibrary_copies'))
+ ->where($db->quoteName('item_id') . ' = ' . (int) $itemId)
+ ->order('copy_number ASC');
+
+ $db->setQuery($query);
+ $item->copies = $db->loadObjectList() ?: [];
+ }
+
+ return $item;
+ }
+
+ public static function getAvailableCopies(int $itemId): array
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuitelibrary_copies'))
+ ->where($db->quoteName('item_id') . ' = ' . (int) $itemId)
+ ->where($db->quoteName('status') . ' = ' . $db->quote('available'));
+
+ $db->setQuery($query);
+
+ return $db->loadObjectList() ?: [];
+ }
+}
diff --git a/source/packages/plg_system_mokosuitelibrary/src/Helper/CheckoutHelper.php b/source/packages/plg_system_mokosuitelibrary/src/Helper/CheckoutHelper.php
new file mode 100644
index 0000000..400cf94
--- /dev/null
+++ b/source/packages/plg_system_mokosuitelibrary/src/Helper/CheckoutHelper.php
@@ -0,0 +1,211 @@
+get(DatabaseInterface::class);
+ $now = Factory::getDate()->toSql();
+ $dueDate = Factory::getDate('+' . $loanDays . ' days')->format('Y-m-d');
+ $userId = Factory::getApplication()->getIdentity()->id;
+
+ $db->transactionStart();
+
+ try {
+ // Lock copy row to prevent double-checkout
+ $query = $db->getQuery(true)
+ ->select('status')
+ ->from($db->quoteName('#__mokosuitelibrary_copies'))
+ ->where($db->quoteName('id') . ' = ' . (int) $copyId)
+ ->forUpdate();
+
+ $db->setQuery($query);
+ $status = $db->loadResult();
+
+ if ($status !== 'available') {
+ throw new \RuntimeException('Copy is not available for checkout');
+ }
+
+ // Create checkout record
+ $checkout = (object) [
+ 'copy_id' => (int) $copyId,
+ 'patron_id' => (int) $patronId,
+ 'checked_out_at' => $now,
+ 'due_date' => $dueDate,
+ 'status' => 'active',
+ 'created_by' => $userId,
+ ];
+
+ $db->insertObject('#__mokosuitelibrary_checkouts', $checkout, 'id');
+
+ // Update copy status
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__mokosuitelibrary_copies'))
+ ->set($db->quoteName('status') . ' = ' . $db->quote('checked_out'))
+ ->where($db->quoteName('id') . ' = ' . (int) $copyId);
+
+ $db->setQuery($query);
+ $db->execute();
+
+ // Decrement available count on item
+ $query = $db->getQuery(true)
+ ->select('item_id')
+ ->from($db->quoteName('#__mokosuitelibrary_copies'))
+ ->where($db->quoteName('id') . ' = ' . (int) $copyId);
+
+ $db->setQuery($query);
+ $itemId = $db->loadResult();
+
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__mokosuitelibrary_items'))
+ ->set($db->quoteName('available_copies') . ' = ' . $db->quoteName('available_copies') . ' - 1')
+ ->where($db->quoteName('id') . ' = ' . (int) $itemId)
+ ->where($db->quoteName('available_copies') . ' > 0');
+
+ $db->setQuery($query);
+ $db->execute();
+
+ $db->transactionCommit();
+
+ return $checkout;
+ } catch (\Exception $e) {
+ $db->transactionRollback();
+ throw $e;
+ }
+ }
+
+ public static function checkin(int $copyId): ?object
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+ $now = Factory::getDate()->toSql();
+
+ $db->transactionStart();
+
+ try {
+ // Find active checkout for this copy
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuitelibrary_checkouts'))
+ ->where($db->quoteName('copy_id') . ' = ' . (int) $copyId)
+ ->where($db->quoteName('status') . ' IN (' . $db->quote('active') . ', ' . $db->quote('overdue') . ')')
+ ->forUpdate();
+
+ $db->setQuery($query);
+ $checkout = $db->loadObject();
+
+ if (!$checkout) {
+ $db->transactionRollback();
+ return null;
+ }
+
+ // Mark returned
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__mokosuitelibrary_checkouts'))
+ ->set($db->quoteName('returned_at') . ' = ' . $db->quote($now))
+ ->set($db->quoteName('status') . ' = ' . $db->quote('returned'))
+ ->where($db->quoteName('id') . ' = ' . (int) $checkout->id);
+
+ $db->setQuery($query);
+ $db->execute();
+
+ // Update copy status
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__mokosuitelibrary_copies'))
+ ->set($db->quoteName('status') . ' = ' . $db->quote('available'))
+ ->where($db->quoteName('id') . ' = ' . (int) $copyId);
+
+ $db->setQuery($query);
+ $db->execute();
+
+ // Increment available count
+ $query = $db->getQuery(true)
+ ->select('item_id')
+ ->from($db->quoteName('#__mokosuitelibrary_copies'))
+ ->where($db->quoteName('id') . ' = ' . (int) $copyId);
+
+ $db->setQuery($query);
+ $itemId = $db->loadResult();
+
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__mokosuitelibrary_items'))
+ ->set($db->quoteName('available_copies') . ' = ' . $db->quoteName('available_copies') . ' + 1')
+ ->where($db->quoteName('id') . ' = ' . (int) $itemId);
+
+ $db->setQuery($query);
+ $db->execute();
+
+ // Calculate fine if overdue
+ if ($checkout->due_date < Factory::getDate()->format('Y-m-d')) {
+ FineHelper::calculate((int) $checkout->id);
+ }
+
+ $db->transactionCommit();
+
+ return $checkout;
+ } catch (\Exception $e) {
+ $db->transactionRollback();
+ throw $e;
+ }
+ }
+
+ public static function renew(int $checkoutId, int $additionalDays = 14): bool
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuitelibrary_checkouts'))
+ ->where($db->quoteName('id') . ' = ' . (int) $checkoutId)
+ ->where($db->quoteName('status') . ' = ' . $db->quote('active'));
+
+ $db->setQuery($query);
+ $checkout = $db->loadObject();
+
+ if (!$checkout || $checkout->renewals >= 2) {
+ return false;
+ }
+
+ $newDue = Factory::getDate($checkout->due_date . ' +' . $additionalDays . ' days')->format('Y-m-d');
+
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__mokosuitelibrary_checkouts'))
+ ->set($db->quoteName('due_date') . ' = ' . $db->quote($newDue))
+ ->set($db->quoteName('renewals') . ' = ' . $db->quoteName('renewals') . ' + 1')
+ ->where($db->quoteName('id') . ' = ' . (int) $checkoutId);
+
+ $db->setQuery($query);
+ $db->execute();
+
+ return true;
+ }
+
+ public static function getOverdue(): array
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+ $today = Factory::getDate()->format('Y-m-d');
+
+ $query = $db->getQuery(true)
+ ->select('c.*, p.name AS patron_name, p.email AS patron_email, i.title AS item_title')
+ ->from($db->quoteName('#__mokosuitelibrary_checkouts', 'c'))
+ ->join('INNER', $db->quoteName('#__mokosuitelibrary_copies', 'cp') . ' ON cp.id = c.copy_id')
+ ->join('INNER', $db->quoteName('#__mokosuitelibrary_items', 'i') . ' ON i.id = cp.item_id')
+ ->join('INNER', $db->quoteName('#__mokosuitelibrary_patrons', 'p') . ' ON p.id = c.patron_id')
+ ->where($db->quoteName('c.status') . ' = ' . $db->quote('active'))
+ ->where($db->quoteName('c.due_date') . ' < ' . $db->quote($today))
+ ->order('c.due_date ASC');
+
+ $db->setQuery($query);
+
+ return $db->loadAssocList() ?: [];
+ }
+}
diff --git a/source/packages/plg_system_mokosuitelibrary/src/Helper/FineHelper.php b/source/packages/plg_system_mokosuitelibrary/src/Helper/FineHelper.php
new file mode 100644
index 0000000..faf3bd9
--- /dev/null
+++ b/source/packages/plg_system_mokosuitelibrary/src/Helper/FineHelper.php
@@ -0,0 +1,135 @@
+get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuitelibrary_checkouts'))
+ ->where($db->quoteName('id') . ' = ' . (int) $checkoutId);
+
+ $db->setQuery($query);
+ $checkout = $db->loadObject();
+
+ if (!$checkout) {
+ return null;
+ }
+
+ $dueDate = new \DateTime($checkout->due_date);
+ $returnDate = $checkout->returned_at
+ ? new \DateTime($checkout->returned_at)
+ : new \DateTime();
+
+ $daysOverdue = max(0, (int) $dueDate->diff($returnDate)->days);
+
+ if ($daysOverdue <= 0) {
+ return null;
+ }
+
+ $amount = round($daysOverdue * $ratePerDay, 2);
+
+ $fine = (object) [
+ 'patron_id' => (int) $checkout->patron_id,
+ 'checkout_id' => (int) $checkoutId,
+ 'amount' => $amount,
+ 'reason' => 'overdue',
+ 'status' => 'outstanding',
+ 'created' => Factory::getDate()->toSql(),
+ ];
+
+ $db->insertObject('#__mokosuitelibrary_fines', $fine, 'id');
+
+ return $fine;
+ }
+
+ public static function getPatronBalance(int $patronId): float
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->select('COALESCE(SUM(amount - paid), 0)')
+ ->from($db->quoteName('#__mokosuitelibrary_fines'))
+ ->where($db->quoteName('patron_id') . ' = ' . (int) $patronId)
+ ->where($db->quoteName('status') . ' = ' . $db->quote('outstanding'));
+
+ $db->setQuery($query);
+
+ return (float) $db->loadResult();
+ }
+
+ public static function recordPayment(int $fineId, float $amount, string $method = 'cash'): bool
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+ $now = Factory::getDate()->toSql();
+ $userId = Factory::getApplication()->getIdentity()->id;
+
+ $payment = (object) [
+ 'fine_id' => (int) $fineId,
+ 'amount' => $amount,
+ 'method' => $method,
+ 'paid_at' => $now,
+ 'received_by' => $userId,
+ ];
+
+ $db->insertObject('#__mokosuitelibrary_fine_payments', $payment, 'id');
+
+ // Update paid amount on fine
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__mokosuitelibrary_fines'))
+ ->set($db->quoteName('paid') . ' = ' . $db->quoteName('paid') . ' + ' . (float) $amount)
+ ->where($db->quoteName('id') . ' = ' . (int) $fineId);
+
+ $db->setQuery($query);
+ $db->execute();
+
+ // Check if fully paid
+ $query = $db->getQuery(true)
+ ->select('amount, paid')
+ ->from($db->quoteName('#__mokosuitelibrary_fines'))
+ ->where($db->quoteName('id') . ' = ' . (int) $fineId);
+
+ $db->setQuery($query);
+ $fine = $db->loadObject();
+
+ if ($fine && $fine->paid >= $fine->amount) {
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__mokosuitelibrary_fines'))
+ ->set($db->quoteName('status') . ' = ' . $db->quote('paid'))
+ ->where($db->quoteName('id') . ' = ' . (int) $fineId);
+
+ $db->setQuery($query);
+ $db->execute();
+ }
+
+ return true;
+ }
+
+ public static function waive(int $fineId, string $reason): bool
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__mokosuitelibrary_fines'))
+ ->set($db->quoteName('status') . ' = ' . $db->quote('waived'))
+ ->set($db->quoteName('waived_reason') . ' = ' . $db->quote($reason))
+ ->where($db->quoteName('id') . ' = ' . (int) $fineId);
+
+ $db->setQuery($query);
+ $db->execute();
+
+ return true;
+ }
+}
diff --git a/source/packages/plg_system_mokosuitelibrary/src/Helper/ReservationHelper.php b/source/packages/plg_system_mokosuitelibrary/src/Helper/ReservationHelper.php
new file mode 100644
index 0000000..cf3cb28
--- /dev/null
+++ b/source/packages/plg_system_mokosuitelibrary/src/Helper/ReservationHelper.php
@@ -0,0 +1,93 @@
+get(DatabaseInterface::class);
+ $now = Factory::getDate()->toSql();
+
+ // Get next position in queue
+ $query = $db->getQuery(true)
+ ->select('COALESCE(MAX(position), 0) + 1')
+ ->from($db->quoteName('#__mokosuitelibrary_reservations'))
+ ->where($db->quoteName('item_id') . ' = ' . (int) $itemId)
+ ->where($db->quoteName('status') . ' IN (' . $db->quote('waiting') . ', ' . $db->quote('ready') . ')');
+
+ $db->setQuery($query);
+ $position = (int) $db->loadResult();
+
+ $reservation = (object) [
+ 'item_id' => (int) $itemId,
+ 'patron_id' => (int) $patronId,
+ 'position' => $position,
+ 'status' => 'waiting',
+ 'created' => $now,
+ ];
+
+ $db->insertObject('#__mokosuitelibrary_reservations', $reservation, 'id');
+
+ return $reservation;
+ }
+
+ public static function fulfillNext(int $itemId, int $holdDays = 3): ?object
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+ $now = Factory::getDate()->toSql();
+ $expires = Factory::getDate('+' . $holdDays . ' days')->toSql();
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuitelibrary_reservations'))
+ ->where($db->quoteName('item_id') . ' = ' . (int) $itemId)
+ ->where($db->quoteName('status') . ' = ' . $db->quote('waiting'))
+ ->order('position ASC')
+ ->setLimit(1);
+
+ $db->setQuery($query);
+ $reservation = $db->loadObject();
+
+ if (!$reservation) {
+ return null;
+ }
+
+ $query = $db->getQuery(true)
+ ->update($db->quoteName('#__mokosuitelibrary_reservations'))
+ ->set($db->quoteName('status') . ' = ' . $db->quote('ready'))
+ ->set($db->quoteName('notified_at') . ' = ' . $db->quote($now))
+ ->set($db->quoteName('expires_at') . ' = ' . $db->quote($expires))
+ ->where($db->quoteName('id') . ' = ' . (int) $reservation->id);
+
+ $db->setQuery($query);
+ $db->execute();
+
+ return $reservation;
+ }
+
+ public static function getQueue(int $itemId): array
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->select('r.*, p.name AS patron_name')
+ ->from($db->quoteName('#__mokosuitelibrary_reservations', 'r'))
+ ->join('INNER', $db->quoteName('#__mokosuitelibrary_patrons', 'p') . ' ON p.id = r.patron_id')
+ ->where($db->quoteName('r.item_id') . ' = ' . (int) $itemId)
+ ->where($db->quoteName('r.status') . ' IN (' . $db->quote('waiting') . ', ' . $db->quote('ready') . ')')
+ ->order('r.position ASC');
+
+ $db->setQuery($query);
+
+ return $db->loadAssocList() ?: [];
+ }
+}
diff --git a/source/pkg_mokosuitelibrary.xml b/source/pkg_mokosuitelibrary.xml
new file mode 100644
index 0000000..36bd91b
--- /dev/null
+++ b/source/pkg_mokosuitelibrary.xml
@@ -0,0 +1,22 @@
+
+
+ Package - MokoSuite Library
+ mokosuitelibrary
+ 01.00.00
+ 2026-06-23
+ Moko Consulting
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GNU General Public License version 3 or later; see LICENSE
+ Library and resource lending management
+ 8.3
+
+ true
+
+ plg_system_mokosuitelibrary.zip
+
+
+ https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteLibrary/updates.xml
+
+