feat: add e-signature, products, orders, invoicing tables and API (#192, #197)

Database schema (10 new tables):
- erp_esign_requests/signers/events — full e-signature lifecycle with
  encrypted selfie/ID verification, 128-char tokens, audit trail
- erp_products — lightweight catalog linked to #__content articles
- erp_orders/order_items — order management with status tracking
- erp_invoices/invoice_items — invoicing with standard/credit/recurring
- erp_payments — payment tracking with gateway transaction IDs

API controllers:
- ErpSignRequestsController — signature request CRUD with signer
  management, auto-ref generation (SIG-YYYY-NNN), audit logging

Routes registered for products, orders, invoices, payments, e-signature
This commit is contained in:
Jonathan Miller
2026-06-06 12:58:30 -05:00
parent 163793863a
commit 2e77514aa7
4 changed files with 580 additions and 0 deletions
@@ -0,0 +1,320 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* E-Signature Requests API controller.
*
* Manages signature request lifecycle: create, send, track, complete.
*
* @since 02.34.16
*/
class ErpSignRequestsController extends BaseController
{
/**
* List signature requests.
*/
public function displayList(): void
{
$this->requireAuth();
$db = Factory::getDbo();
$input = Factory::getApplication()->getInput();
$query = $db->getQuery(true)
->select('r.*, cd.name AS contact_name')
->select('(SELECT COUNT(*) FROM ' . $db->quoteName('#__mokowaas_erp_esign_signers') . ' s WHERE s.request_id = r.id) AS signer_count')
->select('(SELECT COUNT(*) FROM ' . $db->quoteName('#__mokowaas_erp_esign_signers') . ' s WHERE s.request_id = r.id AND s.status = ' . $db->quote('signed') . ') AS signed_count')
->from($db->quoteName('#__mokowaas_erp_esign_requests', 'r'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = r.contact_id')
->order('r.date_creation DESC');
$status = $input->get('status', '', 'CMD');
if ($status)
{
$query->where($db->quoteName('r.status') . ' = ' . $db->quote($status));
}
$limit = $input->getInt('limit', 50);
$offset = $input->getInt('offset', 0);
$db->setQuery($query, $offset, $limit);
$this->sendJson(200, ['data' => $db->loadObjectList()]);
}
/**
* Get a single request with signers and audit trail.
*/
public function displayItem(): void
{
$this->requireAuth();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
// Request
$db->setQuery(
$db->getQuery(true)
->select('r.*, cd.name AS contact_name')
->from($db->quoteName('#__mokowaas_erp_esign_requests', 'r'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = r.contact_id')
->where($db->quoteName('r.id') . ' = ' . $id)
);
$item = $db->loadObject();
if (!$item)
{
$this->sendJson(404, ['error' => 'Signature request not found']);
return;
}
// Signers (exclude encrypted verification data from list view)
$db->setQuery(
$db->getQuery(true)
->select('id, request_id, role, email, firstname, lastname, status, position, date_sent, date_viewed, date_signed, ip_address, geo_country, geo_city')
->from($db->quoteName('#__mokowaas_erp_esign_signers'))
->where($db->quoteName('request_id') . ' = ' . $id)
->order('position ASC')
);
$item->signers = $db->loadObjectList();
// Audit trail
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_erp_esign_events'))
->where($db->quoteName('request_id') . ' = ' . $id)
->order('created ASC')
);
$item->events = $db->loadObjectList();
$this->sendJson(200, ['data' => $item]);
}
/**
* Create a new signature request.
*/
public function add(): void
{
$this->requireAuth();
$input = json_decode(Factory::getApplication()->getInput()->json->getRaw(), true);
$db = Factory::getDbo();
$now = Factory::getDate()->toSql();
$user = Factory::getUser();
$title = trim($input['title'] ?? '');
if (!$title)
{
$this->sendJson(400, ['error' => 'title is required']);
return;
}
// Generate ref: SIG-YYYY-NNN
$year = date('Y');
$like = $db->quote('SIG-' . $year . '-%');
$db->setQuery(
$db->getQuery(true)
->select('MAX(' . $db->quoteName('ref') . ')')
->from($db->quoteName('#__mokowaas_erp_esign_requests'))
->where($db->quoteName('ref') . ' LIKE ' . $like)
);
$maxRef = $db->loadResult();
$seq = $maxRef ? ((int) end(explode('-', $maxRef)) + 1) : 1;
$ref = sprintf('SIG-%s-%03d', $year, $seq);
$obj = (object) [
'ref' => $ref,
'title' => $title,
'description' => $input['description'] ?? null,
'contact_id' => ($input['contact_id'] ?? null) ? (int) $input['contact_id'] : null,
'status' => 'draft',
'require_selfie' => (int) ($input['require_selfie'] ?? 0),
'require_id' => (int) ($input['require_id'] ?? 0),
'require_otp' => $input['require_otp'] ?? 'smart',
'date_creation' => $now,
'date_expiry' => $input['date_expiry'] ?? null,
'created_by' => (int) $user->id,
];
$db->insertObject('#__mokowaas_erp_esign_requests', $obj, 'id');
$requestId = $db->insertid();
// Log creation event
$this->logEvent($db, $requestId, null, 'CREATED', 'Signature request created', $user->id);
// Add signers if provided
$signers = $input['signers'] ?? [];
foreach ($signers as $i => $signer)
{
$email = trim($signer['email'] ?? '');
if (!$email)
{
continue;
}
$token = bin2hex(random_bytes(64));
$signerObj = (object) [
'request_id' => $requestId,
'role' => $signer['role'] ?? '',
'email' => $email,
'firstname' => $signer['firstname'] ?? '',
'lastname' => $signer['lastname'] ?? '',
'phone' => $signer['phone'] ?? '',
'status' => 'pending',
'token' => $token,
'position' => $i + 1,
'date_creation' => $now,
];
$db->insertObject('#__mokowaas_erp_esign_signers', $signerObj);
}
$this->sendJson(201, ['data' => $obj, 'id' => $requestId, 'ref' => $ref]);
}
/**
* Update request status (send, cancel, etc.).
*/
public function edit(): void
{
$this->requireAuth();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$input = json_decode(Factory::getApplication()->getInput()->json->getRaw(), true);
$db = Factory::getDbo();
$now = Factory::getDate()->toSql();
$allowed = ['status', 'title', 'description', 'date_expiry', 'require_selfie', 'require_id', 'require_otp'];
$updates = [];
foreach ($allowed as $field)
{
if (isset($input[$field]))
{
$updates[$field] = $input[$field];
}
}
if (empty($updates))
{
$this->sendJson(400, ['error' => 'No valid fields to update']);
return;
}
// Track status transitions
if (isset($updates['status']))
{
$status = $updates['status'];
if ($status === 'pending')
{
$updates['date_sent'] = $now;
}
$eventCode = strtoupper($status === 'pending' ? 'SENT' : $status);
$this->logEvent($db, $id, null, $eventCode, 'Request status changed to ' . $status, Factory::getUser()->id);
}
$obj = (object) array_merge(['id' => $id], $updates);
$db->updateObject('#__mokowaas_erp_esign_requests', $obj, 'id');
$this->sendJson(200, ['data' => $obj]);
}
/**
* Delete a request and all related data.
*/
public function delete(): void
{
$this->requireAuth();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
$tables = [
'#__mokowaas_erp_esign_events',
'#__mokowaas_erp_esign_signers',
'#__mokowaas_erp_esign_requests',
];
foreach ($tables as $table)
{
$col = $table === '#__mokowaas_erp_esign_requests' ? 'id' : 'request_id';
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName($table))
->where($db->quoteName($col) . ' = ' . $id)
);
$db->execute();
}
$this->sendJson(200, ['message' => 'Signature request deleted', 'id' => $id]);
}
/**
* Log an audit trail event.
*/
private function logEvent($db, int $requestId, ?int $signerId, string $code, string $label, ?int $userId): void
{
$app = Factory::getApplication();
$event = (object) [
'request_id' => $requestId,
'signer_id' => $signerId,
'code' => $code,
'label' => $label,
'ip' => $app->getInput()->server->getString('REMOTE_ADDR', ''),
'user_agent' => $app->getInput()->server->getString('HTTP_USER_AGENT', ''),
'user_id' => $userId,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokowaas_erp_esign_events', $event);
}
private function requireAuth(): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('core.manage', 'com_mokowaas'))
{
$this->sendJson(403, ['error' => 'Not authorized']);
$this->app->close();
}
}
private function sendJson(int $code, array $data): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json', true);
$app->setHeader('Status', (string) $code, true);
echo json_encode($data, JSON_UNESCAPED_SLASHES);
}
}
@@ -101,3 +101,217 @@ CREATE TABLE IF NOT EXISTS `#__mokowaas_erp_activities` (
KEY `idx_due_date` (`due_date`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- =============================================
-- E-Signature Tables
-- =============================================
CREATE TABLE IF NOT EXISTS `#__mokowaas_erp_esign_requests` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ref` VARCHAR(128) NOT NULL DEFAULT '',
`title` VARCHAR(255) NOT NULL,
`description` TEXT,
`document_path` VARCHAR(255) DEFAULT NULL,
`contact_id` INT DEFAULT NULL,
`status` ENUM('draft','pending','inprogress','completed','declined','expired','cancelled') NOT NULL DEFAULT 'draft',
`require_selfie` TINYINT NOT NULL DEFAULT 0,
`require_id` TINYINT NOT NULL DEFAULT 0,
`require_otp` VARCHAR(16) NOT NULL DEFAULT 'smart',
`date_creation` DATETIME NOT NULL,
`date_sent` DATETIME DEFAULT NULL,
`date_signature` DATETIME DEFAULT NULL,
`date_expiry` DATETIME DEFAULT NULL,
`created_by` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_ref` (`ref`),
KEY `idx_status` (`status`),
KEY `idx_contact` (`contact_id`),
KEY `idx_expiry` (`date_expiry`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_erp_esign_signers` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`request_id` INT UNSIGNED NOT NULL,
`role` VARCHAR(64) NOT NULL DEFAULT '',
`email` VARCHAR(255) NOT NULL,
`firstname` VARCHAR(255) NOT NULL DEFAULT '',
`lastname` VARCHAR(255) NOT NULL DEFAULT '',
`phone` VARCHAR(50) NOT NULL DEFAULT '',
`status` ENUM('pending','viewed','signed','declined','cancelled') NOT NULL DEFAULT 'pending',
`token` VARCHAR(128) NOT NULL,
`signature_data` MEDIUMTEXT,
`signature_type` VARCHAR(32) DEFAULT NULL,
`selfie_path` VARCHAR(255) DEFAULT NULL,
`id_path` VARCHAR(255) DEFAULT NULL,
`ip_address` VARCHAR(64) NOT NULL DEFAULT '',
`user_agent` TEXT,
`geo_lat` DECIMAL(10,7) DEFAULT NULL,
`geo_lon` DECIMAL(10,7) DEFAULT NULL,
`geo_country` VARCHAR(100) DEFAULT NULL,
`geo_city` VARCHAR(100) DEFAULT NULL,
`otp_verified` TINYINT NOT NULL DEFAULT 0,
`position` INT NOT NULL DEFAULT 0,
`date_sent` DATETIME DEFAULT NULL,
`date_viewed` DATETIME DEFAULT NULL,
`date_signed` DATETIME DEFAULT NULL,
`date_creation` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_token` (`token`),
KEY `idx_request` (`request_id`),
KEY `idx_email` (`email`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_erp_esign_events` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`request_id` INT UNSIGNED NOT NULL,
`signer_id` INT UNSIGNED DEFAULT NULL,
`code` VARCHAR(64) NOT NULL,
`label` VARCHAR(255) NOT NULL DEFAULT '',
`description` TEXT,
`ip` VARCHAR(64) NOT NULL DEFAULT '',
`user_agent` TEXT,
`user_id` INT DEFAULT NULL,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_request` (`request_id`),
KEY `idx_signer` (`signer_id`),
KEY `idx_code` (`code`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- =============================================
-- Products (descriptions via #__content articles)
-- =============================================
CREATE TABLE IF NOT EXISTS `#__mokowaas_erp_products` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`sku` VARCHAR(100) NOT NULL DEFAULT '',
`article_id` INT DEFAULT NULL,
`type` ENUM('product','service') NOT NULL DEFAULT 'product',
`price` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`cost_price` DECIMAL(15,2) DEFAULT NULL,
`tax_rate` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
`currency` VARCHAR(3) NOT NULL DEFAULT 'USD',
`stock_qty` DECIMAL(15,4) NOT NULL DEFAULT 0.0000,
`low_stock_threshold` INT DEFAULT NULL,
`weight` DECIMAL(10,3) DEFAULT NULL,
`barcode` VARCHAR(100) DEFAULT NULL,
`category_id` INT DEFAULT NULL,
`published` TINYINT NOT NULL DEFAULT 1,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
`created_by` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_sku` (`sku`),
KEY `idx_article` (`article_id`),
KEY `idx_type` (`type`),
KEY `idx_barcode` (`barcode`),
KEY `idx_category` (`category_id`),
KEY `idx_published` (`published`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- =============================================
-- Orders & Invoicing
-- =============================================
CREATE TABLE IF NOT EXISTS `#__mokowaas_erp_orders` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ref` VARCHAR(128) NOT NULL DEFAULT '',
`contact_id` INT NOT NULL,
`status` ENUM('draft','confirmed','processing','shipped','delivered','cancelled','refunded') NOT NULL DEFAULT 'draft',
`subtotal` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`tax_total` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`total` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`currency` VARCHAR(3) NOT NULL DEFAULT 'USD',
`payment_method` VARCHAR(50) DEFAULT NULL,
`payment_status` ENUM('unpaid','partial','paid','refunded') NOT NULL DEFAULT 'unpaid',
`shipping_address` TEXT,
`notes` TEXT,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
`created_by` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_ref` (`ref`),
KEY `idx_contact` (`contact_id`),
KEY `idx_status` (`status`),
KEY `idx_payment_status` (`payment_status`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_erp_order_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`product_id` INT UNSIGNED DEFAULT NULL,
`description` VARCHAR(255) NOT NULL DEFAULT '',
`quantity` DECIMAL(15,4) NOT NULL DEFAULT 1.0000,
`unit_price` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`tax_rate` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
`line_total` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`position` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`),
KEY `idx_product` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_erp_invoices` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ref` VARCHAR(128) NOT NULL DEFAULT '',
`order_id` INT UNSIGNED DEFAULT NULL,
`contact_id` INT NOT NULL,
`type` ENUM('standard','credit_note','recurring') NOT NULL DEFAULT 'standard',
`status` ENUM('draft','sent','paid','partial','overdue','cancelled','refunded') NOT NULL DEFAULT 'draft',
`subtotal` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`tax_total` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`total` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`amount_paid` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`currency` VARCHAR(3) NOT NULL DEFAULT 'USD',
`due_date` DATE DEFAULT NULL,
`notes` TEXT,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
`paid_date` DATETIME DEFAULT NULL,
`created_by` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_ref` (`ref`),
KEY `idx_order` (`order_id`),
KEY `idx_contact` (`contact_id`),
KEY `idx_status` (`status`),
KEY `idx_due_date` (`due_date`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_erp_invoice_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`invoice_id` INT UNSIGNED NOT NULL,
`product_id` INT UNSIGNED DEFAULT NULL,
`description` VARCHAR(255) NOT NULL DEFAULT '',
`quantity` DECIMAL(15,4) NOT NULL DEFAULT 1.0000,
`unit_price` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`tax_rate` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
`line_total` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`position` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_invoice` (`invoice_id`),
KEY `idx_product` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_erp_payments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`invoice_id` INT UNSIGNED NOT NULL,
`amount` DECIMAL(15,2) NOT NULL,
`currency` VARCHAR(3) NOT NULL DEFAULT 'USD',
`method` VARCHAR(50) NOT NULL DEFAULT '',
`reference` VARCHAR(255) DEFAULT NULL,
`gateway` VARCHAR(50) DEFAULT NULL,
`gateway_transaction_id` VARCHAR(255) DEFAULT NULL,
`status` ENUM('pending','completed','failed','refunded') NOT NULL DEFAULT 'completed',
`notes` TEXT,
`created` DATETIME NOT NULL,
`created_by` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_invoice` (`invoice_id`),
KEY `idx_status` (`status`),
KEY `idx_gateway` (`gateway`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -1,3 +1,12 @@
DROP TABLE IF EXISTS `#__mokowaas_erp_payments`;
DROP TABLE IF EXISTS `#__mokowaas_erp_invoice_items`;
DROP TABLE IF EXISTS `#__mokowaas_erp_invoices`;
DROP TABLE IF EXISTS `#__mokowaas_erp_order_items`;
DROP TABLE IF EXISTS `#__mokowaas_erp_orders`;
DROP TABLE IF EXISTS `#__mokowaas_erp_products`;
DROP TABLE IF EXISTS `#__mokowaas_erp_esign_events`;
DROP TABLE IF EXISTS `#__mokowaas_erp_esign_signers`;
DROP TABLE IF EXISTS `#__mokowaas_erp_esign_requests`;
DROP TABLE IF EXISTS `#__mokowaas_erp_activities`;
DROP TABLE IF EXISTS `#__mokowaas_erp_deals`;
DROP TABLE IF EXISTS `#__mokowaas_erp_pipeline_stages`;
@@ -149,5 +149,42 @@ final class MokoWaaSApi extends CMSPlugin implements SubscriberInterface
'erppipeline',
['component' => 'com_mokowaas']
);
$router->createCRUDRoutes(
'v1/mokowaas/erp/products',
'erpproducts',
['component' => 'com_mokowaas']
);
$router->createCRUDRoutes(
'v1/mokowaas/erp/orders',
'erporders',
['component' => 'com_mokowaas']
);
$router->createCRUDRoutes(
'v1/mokowaas/erp/invoices',
'erpinvoices',
['component' => 'com_mokowaas']
);
$router->createCRUDRoutes(
'v1/mokowaas/erp/payments',
'erppayments',
['component' => 'com_mokowaas']
);
// E-Signature routes
$router->createCRUDRoutes(
'v1/mokowaas/erp/esign/requests',
'erpsignrequests',
['component' => 'com_mokowaas']
);
$router->createCRUDRoutes(
'v1/mokowaas/erp/esign/signers',
'erpsignsigners',
['component' => 'com_mokowaas']
);
}
}