Compare commits

..

6 Commits

Author SHA1 Message Date
Ollama
12146275f4 fix: Fix migration foreign key constraint and remove seeder
- Change employee_id to INT(10) to match employees.person_id type
- Remove seeder call from migration (causes issues in tests)
- Add permission/module inserts directly in migration
- Add proper down() method to drop FK and data
- Remove seed reference from tests

The foreign key was failing because:
1. employee_id was defined as unsigned INT(11)
2. employees.person_id is INT(10) NOT NULL (not unsigned)
3. Seeder call in migration caused multiple runs in tests
2026-03-06 15:05:59 +00:00
Ollama
e45af91e2e feat: Add REST API implementation with API key authentication
Implement comprehensive REST API for OSPOS with the following:

Database:
- Migration for ospos_api_keys table
- Seeder for module permissions

Models:
- ApiKey model with key generation, validation, revocation
- SHA-256 hashing for secure key storage
- Support for key expiration

Filters:
- ApiAuth filter for X-API-Key header authentication
- CSRF exemption for API routes

Controllers:
- Api/BaseController with response helpers and field transformation
- Api/Customers (CRUD + batch delete, suggestions)
- Api/Suppliers (CRUD + batch delete, suggestions)
- Api/Items (CRUD + batch delete, quantities endpoint)
- Api/Inventory (adjustments with set/adjust modes, bulk support)
- ApiKeys (UI controller for key management)

Routes:
- /api/v1/* endpoints with apiauth filter
- /office/api-keys/* endpoints for key management UI

Tests:
- ApiKeyTest for model functionality
- ApiAuthTest for authentication filter

Features:
- camelCase JSON field names (API standard)
- Offset/limit pagination
- Soft delete support
- Permission-based authorization
- Key prefix for UI identification
- Last used timestamp tracking

Refs: #2463, #615, #3789, #3809, #1680, #876, #1959, #157
2026-03-06 14:35:27 +00:00
jekkos
31d25e06dc fix(security): whitelist and validate invoice template types (#4393)
- Add whitelist validation for invoice_type to prevent path traversal and LFI
- Validate invoice_type against allowed values in Sale_lib
- Sanitize invoice_type input in Config controller before saving
- Default to 'invoice' template for invalid types

Security: Prevents arbitrary file inclusion via user-controlled invoice_type config
2026-03-06 13:18:47 +01:00
jekkos
b1819b3b36 dd validation for invalid stock locations in CSV import (#4399)
- Add validateCSVStockLocations() method to check CSV columns against allowed locations
- Log error when invalid stock location columns are detected
- Tests for valid, invalid, and mixed stock location columns
- Tests for location name case sensitivity
- Tests for CSV parsing and detecting location columns
- Add error message language string for invalid locations

Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-03-06 13:17:52 +01:00
jekkos
6705420373 Fix incorrect argument types in migration round_number() methods (#4403)
The round_number() method signature declares $amount as string, but the
HALF_FIVE case and other rounding operations pass string values to round()
and other arithmetic operations which expect numeric types. This causes
type errors when strict type checking is enabled.

Fix by casting $amount to float before arithmetic operations in both
migration files:
- 20170502221506_sales_tax_data.php (line 268)
- 20200202000000_taxamount.php (line 244)

Also cast sale_tax_amount to float in round_sales_taxes() method before
passing to round() operations (lines 381 in sales_tax_data.php and 358 in
taxamount.php).

Fixes #4324
2026-03-06 13:07:24 +01:00
dependabot[bot]
d6b767c80a Bump dompurify from 3.3.1 to 3.3.2 (#4402)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.1 to 3.3.2.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.1...3.3.2)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.3.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 21:54:49 +01:00
26 changed files with 2850 additions and 2472 deletions

295
API.md
View File

@@ -1,295 +0,0 @@
# OSPOS REST API Design
This document describes the proposed REST API for Open Source Point of Sale (OSPOS).
## Overview
The OSPOS REST API provides programmatic access to:
- **Customers** - Full CRUD operations
- **Suppliers** - Full CRUD operations
- **Items** - Full CRUD operations
- **Inventory** - Stock adjustments (update only)
- **Sales** - Read-only queries
- **Receivings** - Read-only queries
## Authentication
All API endpoints require authentication via an API Key passed in the `X-API-Key` header.
```
X-API-Key: your-api-key-here
```
> **Note:** API Key authentication implementation will be added in a subsequent phase. The spec documents the intended authentication mechanism.
## Base URL
All API endpoints are relative to `/api/v1`.
```
https://your-domain.com/api/v1/customers
```
## Pagination
List endpoints support pagination using `offset` and `limit` query parameters:
| Parameter | Type | Default | Maximum | Description |
|-----------|---------|---------|---------|------------------------------|
| `offset` | integer | 0 | - | Number of records to skip |
| `limit` | integer | 25 | 100 | Number of records to return |
**Example Request:**
```
GET /api/v1/customers?offset=0&limit=25
```
**Example Response:**
```json
{
"total": 150,
"offset": 0,
"limit": 25,
"rows": [
{ "person_id": 1, "first_name": "John", ... },
{ "person_id": 2, "first_name": "Jane", ... }
]
}
```
## Response Format
### Success Response
```json
{
"success": true,
"message": "Customer created successfully",
"id": 42
}
```
### Error Response
```json
{
"success": false,
"message": "Error description here"
}
```
### HTTP Status Codes
| Status Code | Description |
|-------------|--------------------------------------------------|
| 200 | Success |
| 201 | Resource created successfully |
| 400 | Bad request / Invalid input |
| 401 | Unauthorized / Invalid API key |
| 404 | Resource not found |
| 409 | Conflict (e.g., duplicate unique field) |
| 500 | Internal server error |
## Endpoints Summary
### Customers
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/customers` | List customers | Read |
| POST | `/customers` | Create customer | Write |
| GET | `/customers/{id}` | Get customer by ID | Read |
| PUT | `/customers/{id}` | Update customer | Write |
| DELETE | `/customers/{id}` | Delete customer (soft delete) | Write |
| POST | `/customers/batch-delete` | Delete multiple customers | Write |
| GET | `/customers/suggest` | Autocomplete suggestions | Read |
### Suppliers
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/suppliers` | List suppliers | Read |
| POST | `/suppliers` | Create supplier | Write |
| GET | `/suppliers/{id}` | Get supplier by ID | Read |
| PUT | `/suppliers/{id}` | Update supplier | Write |
| DELETE | `/suppliers/{id}` | Delete supplier (soft delete) | Write |
| POST | `/suppliers/batch-delete` | Delete multiple suppliers | Write |
| GET | `/suppliers/suggest` | Autocomplete suggestions | Read |
### Items
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/items` | List items | Read |
| POST | `/items` | Create item | Write |
| GET | `/items/{id}` | Get item by ID | Read |
| PUT | `/items/{id}` | Update item | Write |
| DELETE | `/items/{id}` | Delete item (soft delete) | Write |
| POST | `/items/batch-delete` | Delete multiple items | Write |
| POST | `/items/batch-update` | Update multiple items | Write |
| GET | `/items/suggest` | Autocomplete suggestions | Read |
| GET | `/items/{id}/quantities` | Get stock quantities | Read |
### Inventory
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/inventory` | List inventory transactions | Read |
| POST | `/inventory` | Create inventory adjustment | Write |
| POST | `/inventory/bulk` | Bulk inventory adjustments | Write |
### Sales (Read-Only)
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/sales` | List sales | Read |
| GET | `/sales/{id}` | Get sale details | Read |
| GET | `/sales/{id}/items` | Get sale items | Read |
| GET | `/sales/{id}/payments` | Get sale payments | Read |
### Receivings (Read-Only)
| Method | Endpoint | Description | Access |
|--------|-------------------------------|--------------------------------|---------|
| GET | `/receivings` | List receivings | Read |
| GET | `/receivings/{id}` | Get receiving details | Read |
| GET | `/receivings/{id}/items` | Get receiving items | Read |
## Schema Reference
### Common Fields
#### Person Fields (base for Customer, Supplier)
| Field | Type | Description |
|---------------|-----------|------------------------------|
| `first_name` | string | First name (required) |
| `last_name` | string | Last name (required) |
| `gender` | integer | Gender (0=male, 1=female) |
| `phone_number`| string | Phone number |
| `email` | string | Email address |
| `address_1` | string | Address line 1 |
| `address_2` | string | Address line 2 |
| `city` | string | City |
| `state` | string | State/Province |
| `zip` | string | Postal/ZIP code |
| `country` | string | Country |
| `comments` | string | Additional notes |
### Customer Fields
Extends Person fields with:
| Field | Type | Description |
|--------------------|-----------|------------------------------------|
| `person_id` | integer | Unique identifier (read-only) |
| `account_number` | string | Customer account number |
| `taxable` | integer | Taxable status (0/1) |
| `tax_id` | string | Tax identification number |
| `sales_tax_code_id`| integer | Sales tax code ID |
| `discount` | decimal | Discount percentage/amount |
| `discount_type` | integer | Discount type (0=percent, 1=fixed) |
| `company_name` | string | Company name |
| `package_id` | integer | Rewards package ID |
| `points` | integer | Rewards points balance |
| `consent` | integer | Consent status (0/1) |
### Supplier Fields
Extends Person fields with:
| Field | Type | Description |
|-----------------|-----------|-------------------------------------|
| `person_id` | integer | Unique identifier (read-only) |
| `company_name` | string | Company name |
| `account_number`| string | Supplier account number |
| `tax_id` | string | Tax identification number |
| `agency_name` | string | Agency name |
| `category` | integer | Category (0=goods, 1=cost) |
### Item Fields
| Field | Type | Required | Description |
|----------------------|-----------|----------|--------------------------------------|
| `item_id` | integer | auto | Unique identifier (read-only) |
| `name` | string | yes | Item name |
| `category` | string | yes | Item category |
| `supplier_id` | integer | no | Supplier ID |
| `item_number` | string | no | Barcode/SKU |
| `description` | string | no | Item description |
| `cost_price` | decimal | no | Cost price |
| `unit_price` | decimal | yes | Selling price |
| `reorder_level` | decimal | no | Reorder threshold |
| `receiving_quantity` | decimal | no | Receiving quantity (default 1) |
| `allow_alt_description`| integer | no | Allow alt description (0/1) |
| `is_serialized` | integer | no | Has serial number (0/1) |
| `stock_type` | integer | no | Stock type (0=stocked, 1=non-stocked)|
| `item_type` | integer | no | Item type (0=standard, 1=kit, 2=temp)|
| `tax_category_id` | integer | no | Tax category ID |
| `qty_per_pack` | decimal | no | Quantity per pack |
| `pack_name` | string | no | Pack name |
| `hsn_code` | string | no | HSN code |
### Inventory Adjustment
| Field | Type | Required | Description |
|------------------|-----------|----------|--------------------------------------|
| `item_id` | integer | yes | Item ID to adjust |
| `trans_inventory`| decimal | yes | Quantity change (+ add, - remove) |
| `trans_location` | integer | no | Stock location ID |
| `trans_comment` | string | no | Reason for adjustment |
## OpenAPI Specification
The complete OpenAPI 3.1.0 specification is available at:
- **YAML format:** `/public/api/openapi.yaml`
This specification can be used with:
- [Swagger UI](https://swagger.io/tools/swagger-ui/) for interactive documentation
- [Swagger Codegen](https://swagger.io/tools/swagger-codegen/) to generate client SDKs
- [OpenAPI Generator](https://openapi-generator.tech/) for code generation
- API testing tools like Postman or Insomnia
## Implementation Notes
### Phase 1: Core Endpoints (Proposed)
1. Customers API (full CRUD)
2. Suppliers API (full CRUD)
3. Items API (full CRUD)
4. Inventory adjustments API (create only)
### Phase 2: Read-Only Endpoints (Proposed)
1. Sales API (read-only)
2. Receivings API (read-only)
### Phase 3: Extended Features (Future)
1. Batch operations for all endpoints
2. Search/filter capabilities
3. Authorization/permissions integration
4. Rate limiting
5. API key management interface
## Discussion Topics
The following aspects of the API design are open for discussion:
1. **Field naming conventions**: Currently following existing database column names. Should we use camelCase for JSON?
2. **Batch operations**: Current design separates batch-delete and batch-update. Should we consolidate?
3. **Date formats**: Using ISO 8601 (date-time). Is timezone handling needed?
4. **Error response structure**: Current format uses `{success, message}`. Should we include error codes?
5. **Relationship representations**: Should nested resources (e.g., sale items) always be included?
6. **Inventory adjustments**: Should we support setting absolute quantities vs. relative changes?
7. **Authorization integration**: How should API access integrate with existing employee permissions?
8. **Stock locations**: Multiple locations per item - do we need location-specific endpoints?

View File

@@ -12,18 +12,10 @@ use CodeIgniter\Filters\InvalidChars;
use CodeIgniter\Filters\PageCache;
use CodeIgniter\Filters\PerformanceMetrics;
use CodeIgniter\Filters\SecureHeaders;
use App\Filters\ApiAuth;
class Filters extends BaseFilters
{
/**
* Configures aliases for Filter classes to
* make reading things nicer and simpler.
*
* @var array<string, class-string|list<class-string>>
*
* [filter_name => classname]
* or [filter_name => [classname1, classname2, ...]]
*/
public array $aliases = [
'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class,
@@ -34,6 +26,7 @@ class Filters extends BaseFilters
'forcehttps' => ForceHTTPS::class,
'pagecache' => PageCache::class,
'performance' => PerformanceMetrics::class,
'apiauth' => ApiAuth::class,
];
/**
@@ -70,7 +63,7 @@ class Filters extends BaseFilters
public array $globals = [
'before' => [
'honeypot',
'csrf' => ['except' => 'login'],
'csrf' => ['except' => ['login', 'api/*']],
'invalidchars',
],
'after' => [

View File

@@ -39,3 +39,50 @@ $routes->add('reports/specific_customers', 'Reports::specific_customer_input');
$routes->add('reports/specific_employees', 'Reports::specific_employee_input');
$routes->add('reports/specific_discounts', 'Reports::specific_discount_input');
$routes->add('reports/specific_suppliers', 'Reports::specific_supplier_input');
$routes->group('office/api-keys', ['filter' => 'session'], static function(RouteCollection $routes): void {
$routes->get('/', 'ApiKeys::index');
$routes->post('generate', 'ApiKeys::generate');
$routes->post('revoke/(:num)', 'ApiKeys::revoke/$1');
$routes->post('regenerate/(:num)', 'ApiKeys::regenerate/$1');
});
$routes->group('api/v1', ['filter' => 'apiauth'], static function(RouteCollection $routes): void {
$routes->get('customers', 'Api\Customers::index');
$routes->get('customers/(:num)', 'Api\Customers::show/$1');
$routes->post('customers', 'Api\Customers::create');
$routes->put('customers/(:num)', 'Api\Customers::update/$1');
$routes->delete('customers/(:num)', 'Api\Customers::delete/$1');
$routes->post('customers/batch-delete', 'Api\Customers::batchDelete');
$routes->get('customers/suggest', 'Api\Customers::suggest');
$routes->get('suppliers', 'Api\Suppliers::index');
$routes->get('suppliers/(:num)', 'Api\Suppliers::show/$1');
$routes->post('suppliers', 'Api\Suppliers::create');
$routes->put('suppliers/(:num)', 'Api\Suppliers::update/$1');
$routes->delete('suppliers/(:num)', 'Api\Suppliers::delete/$1');
$routes->post('suppliers/batch-delete', 'Api\Suppliers::batchDelete');
$routes->get('suppliers/suggest', 'Api\Suppliers::suggest');
$routes->get('items', 'Api\Items::index');
$routes->get('items/(:num)', 'Api\Items::show/$1');
$routes->post('items', 'Api\Items::create');
$routes->put('items/(:num)', 'Api\Items::update/$1');
$routes->delete('items/(:num)', 'Api\Items::delete/$1');
$routes->post('items/batch-delete', 'Api\Items::batchDelete');
$routes->get('items/suggest', 'Api\Items::suggest');
$routes->get('items/(:num)/quantities', 'Api\Items::quantities/$1');
$routes->get('inventory', 'Api\Inventory::index');
$routes->post('inventory', 'Api\Inventory::create');
$routes->post('inventory/bulk', 'Api\Inventory::create');
$routes->get('sales', 'Api\Sales::index');
$routes->get('sales/(:num)', 'Api\Sales::show/$1');
$routes->get('sales/(:num)/items', 'Api\Sales::items/$1');
$routes->get('sales/(:num)/payments', 'Api\Sales::payments/$1');
$routes->get('receivings', 'Api\Receivings::index');
$routes->get('receivings/(:num)', 'Api\Receivings::show/$1');
$routes->get('receivings/(:num)/items', 'Api\Receivings::items/$1');
});

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Controllers\Api;
use App\Models\Employee;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\RESTful\ResourceController;
class BaseController extends ResourceController
{
protected Employee $employee;
protected int $employeeId = 0;
protected $format = 'json';
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
{
parent::initController($request, $response, $logger);
$this->employee = model(Employee::class);
$this->employeeId = $request->employeeId ?? 0;
}
protected function hasPermission(string $moduleId): bool
{
return $this->employee->has_grant($moduleId, $this->employeeId);
}
protected function respondSuccess(array $data = [], int $code = 200, string $message = 'Success'): ResponseInterface
{
$response = ['success' => true];
if ($message) {
$response['message'] = $message;
}
$response = array_merge($response, $data);
return $this->respond($response, $code);
}
protected function respondCreated(array $data = [], string $message = 'Resource created'): ResponseInterface
{
return $this->respondSuccess($data, 201, $message);
}
protected function respondError(string $message, int $code = 400): ResponseInterface
{
return $this->respond([
'success' => false,
'message' => $message
], $code);
}
protected function respondNotFound(string $message = 'Resource not found'): ResponseInterface
{
return $this->respondError($message, 404);
}
protected function respondUnauthorized(string $message = 'Unauthorized'): ResponseInterface
{
return $this->respondError($message, 403);
}
protected function respondValidationError(array $errors): ResponseInterface
{
return $this->respond([
'success' => false,
'message' => 'Validation failed',
'errors' => $errors
], 422);
}
protected function getPagination(): array
{
$offset = (int) ($this->request->getGet('offset') ?? 0);
$limit = (int) ($this->request->getGet('limit') ?? 25);
$limit = min(max($limit, 1), 100);
$offset = max($offset, 0);
return ['offset' => $offset, 'limit' => $limit];
}
protected function getSort(array $allowedFields, string $default = 'id', string $defaultOrder = 'asc'): array
{
$sort = $this->request->getGet('sort') ?? $default;
$order = strtolower($this->request->getGet('order') ?? $defaultOrder);
if (!in_array($sort, $allowedFields)) {
$sort = $default;
}
if (!in_array($order, ['asc', 'desc'])) {
$order = $defaultOrder;
}
return ['sort' => $sort, 'order' => $order];
}
protected function toCamelCase(array $data): array
{
$result = [];
foreach ($data as $key => $value) {
$camelKey = lcfirst(str_replace('_', '', ucwords($key, '_')));
$result[$camelKey] = $value;
}
return $result;
}
protected function toSnakeCase(array $data): array
{
$result = [];
foreach ($data as $key => $value) {
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
$result[$snakeKey] = $value;
}
return $result;
}
protected function transformItem(object|array $item, array $additional = []): array
{
$item = is_object($item) ? (array) $item : $item;
return $this->toCamelCase(array_merge($item, $additional));
}
protected function transformCollection(array $items): array
{
return array_map([$this, 'transformItem'], $items);
}
}

View File

@@ -0,0 +1,251 @@
<?php
namespace App\Controllers\Api;
use App\Models\Customer;
use App\Models\Person;
use CodeIgniter\HTTP\ResponseInterface;
class Customers extends BaseController
{
protected Customer $customerModel;
protected Person $personModel;
protected array $allowedSortFields = ['person_id', 'last_name', 'first_name', 'email', 'company_name'];
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
{
parent::initController($request, $response, $logger);
$this->customerModel = model(Customer::class);
$this->personModel = model(Person::class);
}
public function index(): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$search = $this->request->getGet('search');
$pagination = $this->getPagination();
$sort = $this->getSort($this->allowedSortFields, 'last_name');
$builder = $this->customerModel->builder();
$builder->select('customers.*, people.*');
$builder->join('people', 'people.person_id = customers.person_id');
$builder->where('customers.deleted', 0);
if ($search) {
$builder->groupStart();
$builder->like('people.first_name', $search);
$builder->orLike('people.last_name', $search);
$builder->orLike('people.email', $search);
$builder->orLike('customers.account_number', $search);
$builder->orLike('customers.company_name', $search);
$builder->groupEnd();
}
$total = $builder->countAllResults(false);
$dbSort = $this->mapSortField($sort['sort']);
$builder->orderBy($dbSort, $sort['order']);
$builder->limit($pagination['limit'], $pagination['offset']);
$customers = $builder->get()->getResultArray();
return $this->respondSuccess([
'total' => $total,
'offset' => $pagination['offset'],
'limit' => $pagination['limit'],
'rows' => $this->transformCollection($customers)
]);
}
public function show($id = null): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$customer = $this->customerModel->get_info($id);
if (empty($customer) || $customer->deleted) {
return $this->respondNotFound('Customer not found');
}
$person = (array) $this->personModel->get_info($id);
$customer = (array) $customer;
$data = array_merge($person, $customer);
return $this->respondSuccess($this->transformItem($data));
}
public function create(): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getPost();
}
$data = $this->toSnakeCase($data);
$rules = [
'first_name' => 'required|max_length[255]',
'last_name' => 'required|max_length[255]',
];
$snakeData = [];
foreach ($data as $key => $value) {
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
$snakeData[$snakeKey] = $value;
}
$personData = array_intersect_key($snakeData, array_flip([
'first_name', 'last_name', 'gender', 'phone_number', 'email',
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
]));
$customerData = array_intersect_key($snakeData, array_flip([
'account_number', 'taxable', 'tax_id', 'sales_tax_code_id',
'discount', 'discount_type', 'company_name', 'package_id', 'consent'
]));
$customerData['employee_id'] = $this->employeeId;
$personId = false;
$success = $this->personModel->save_value($personData);
if ($success && isset($personData['person_id'])) {
$personId = $personData['person_id'];
$customerData['person_id'] = $personId;
$success = $this->customerModel->save_value($customerData);
}
if ($success) {
return $this->respondCreated(['id' => $personId], 'Customer created successfully');
}
return $this->respondError('Failed to create customer');
}
public function update($id = null): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$customer = $this->customerModel->get_info($id);
if (empty($customer) || $customer->deleted) {
return $this->respondNotFound('Customer not found');
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getRawInput();
}
$snakeData = [];
foreach ($data as $key => $value) {
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
$snakeData[$snakeKey] = $value;
}
$personData = array_intersect_key($snakeData, array_flip([
'first_name', 'last_name', 'gender', 'phone_number', 'email',
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
]));
$customerData = array_intersect_key($snakeData, array_flip([
'account_number', 'taxable', 'tax_id', 'sales_tax_code_id',
'discount', 'discount_type', 'company_name', 'package_id', 'consent'
]));
if (!empty($personData)) {
$this->personModel->save_value($personData, $id);
}
if (!empty($customerData)) {
$this->customerModel->save_value($customerData, $id);
}
return $this->respondSuccess([], 200, 'Customer updated successfully');
}
public function delete($id = null): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$customer = $this->customerModel->get_info($id);
if (empty($customer) || $customer->deleted) {
return $this->respondNotFound('Customer not found');
}
$success = $this->customerModel->delete($id);
if ($success) {
return $this->respondSuccess([], 200, 'Customer deleted successfully');
}
return $this->respondError('Failed to delete customer');
}
public function batchDelete(): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
$ids = $data['ids'] ?? [];
if (empty($ids)) {
return $this->respondError('No customer IDs provided');
}
$success = $this->customerModel->delete_list($ids);
if ($success) {
return $this->respondSuccess([], 200, 'Customers deleted successfully');
}
return $this->respondError('Failed to delete customers');
}
public function suggest(): ResponseInterface
{
if (!$this->hasPermission('customers')) {
return $this->respondUnauthorized();
}
$term = $this->request->getGet('term');
$limit = (int) ($this->request->getGet('limit') ?? 25);
if (empty($term)) {
return $this->respondSuccess(['suggestions' => []]);
}
$suggestions = $this->customerModel->get_search_suggestions($term, $limit);
return $this->respondSuccess(['suggestions' => $suggestions]);
}
private function mapSortField(string $field): string
{
$map = [
'personId' => 'people.person_id',
'lastName' => 'people.last_name',
'firstName' => 'people.first_name',
'email' => 'people.email',
'companyName' => 'customers.company_name'
];
return $map[$field] ?? 'people.last_name';
}
}

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Controllers\Api;
use App\Models\Inventory as InventoryModel;
use App\Models\Item;
use App\Models\Item_quantity;
use CodeIgniter\HTTP\ResponseInterface;
class Inventory extends BaseController
{
protected InventoryModel $inventory;
protected Item $item;
protected Item_quantity $itemQuantity;
protected array $allowedSortFields = ['trans_id', 'trans_date', 'trans_items'];
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
{
parent::initController($request, $response, $logger);
$this->inventory = model(InventoryModel::class);
$this->item = model(Item::class);
$this->itemQuantity = model(Item_quantity::class);
}
public function index(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$itemId = $this->request->getGet('itemId');
$locationId = $this->request->getGet('locationId');
$pagination = $this->getPagination();
$sort = $this->getSort($this->allowedSortFields, 'trans_date');
$builder = $this->inventory->builder();
if ($itemId) {
$builder->where('trans_items', $itemId);
}
if ($locationId) {
$builder->where('trans_location', $locationId);
}
$total = $builder->countAllResults(false);
$builder->orderBy($sort['sort'], $sort['order']);
$builder->limit($pagination['limit'], $pagination['offset']);
$transactions = $builder->get()->getResultArray();
return $this->respondSuccess([
'total' => $total,
'offset' => $pagination['offset'],
'limit' => $pagination['limit'],
'rows' => $this->transformCollection($transactions)
]);
}
public function create(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
if (isset($data['adjustments']) && is_array($data['adjustments'])) {
return $this->bulkAdjust($data['adjustments']);
}
return $this->singleAdjust($data);
}
private function singleAdjust(array $data): ResponseInterface
{
if (empty($data['itemId'])) {
return $this->respondError('itemId is required');
}
if (!isset($data['quantity'])) {
return $this->respondError('quantity is required');
}
$mode = $data['mode'] ?? 'adjust';
if (!in_array($mode, ['adjust', 'set'])) {
return $this->respondError('mode must be "adjust" or "set"');
}
$item = $this->item->find($data['itemId']);
if (!$item || $item->deleted) {
return $this->respondNotFound('Item not found');
}
$locationId = $data['locationId'] ?? 1;
$comment = $data['comment'] ?? 'API inventory adjustment';
$quantity = (float) $data['quantity'];
if ($mode === 'set') {
$currentQty = $this->itemQuantity->get_item_quantity($data['itemId'], $locationId);
$currentQty = $currentQty ? (float) $currentQty->quantity : 0;
$adjustment = $quantity - $currentQty;
if ($adjustment == 0) {
return $this->respondSuccess([
'itemId' => (int) $data['itemId'],
'locationId' => (int) $locationId,
'newQuantity' => $quantity,
'mode' => $mode
], 200, 'Quantity already at requested level');
}
} else {
$adjustment = $quantity;
}
$invData = [
'trans_date' => date('Y-m-d H:i:s'),
'trans_items' => $data['itemId'],
'trans_user' => $this->employeeId,
'trans_location' => $locationId,
'trans_comment' => $comment,
'trans_inventory' => $adjustment
];
$this->inventory->insert($invData);
$this->itemQuantity->change_quantity($data['itemId'], $locationId, $adjustment);
$newQty = $this->itemQuantity->get_item_quantity($data['itemId'], $locationId);
return $this->respondSuccess([
'itemId' => (int) $data['itemId'],
'locationId' => (int) $locationId,
'adjustment' => $adjustment,
'newQuantity' => $newQty ? (float) $newQty->quantity : 0,
'mode' => $mode
], 200, 'Inventory adjusted successfully');
}
private function bulkAdjust(array $adjustments): ResponseInterface
{
$results = [];
$processed = 0;
$errors = [];
$this->inventory->db->transStart();
foreach ($adjustments as $adjustment) {
$itemId = $adjustment['itemId'] ?? $adjustment['item_id'] ?? null;
if (!$itemId) {
$errors[] = ['itemId' => null, 'success' => false, 'error' => 'itemId is required'];
continue;
}
$item = $this->item->find($itemId);
if (!$item || $item->deleted) {
$errors[] = ['itemId' => $itemId, 'success' => false, 'error' => 'Item not found'];
continue;
}
$mode = $adjustment['mode'] ?? 'adjust';
$locationId = $adjustment['locationId'] ?? $adjustment['location_id'] ?? 1;
$quantity = (float) ($adjustment['quantity'] ?? 0);
$comment = $adjustment['comment'] ?? 'Bulk API inventory adjustment';
if ($mode === 'set') {
$currentQty = $this->itemQuantity->get_item_quantity($itemId, $locationId);
$currentQty = $currentQty ? (float) $currentQty->quantity : 0;
$adjustmentQty = $quantity - $currentQty;
} else {
$adjustmentQty = $quantity;
}
$invData = [
'trans_date' => date('Y-m-d H:i:s'),
'trans_items' => $itemId,
'trans_user' => $this->employeeId,
'trans_location' => $locationId,
'trans_comment' => $comment,
'trans_inventory' => $adjustmentQty
];
$this->inventory->insert($invData);
$this->itemQuantity->change_quantity($itemId, $locationId, $adjustmentQty);
$results[] = ['itemId' => $itemId, 'success' => true];
$processed++;
}
$this->inventory->db->transComplete();
$response = [
'processed' => $processed,
'total' => count($adjustments),
'results' => $results
];
if (!empty($errors)) {
$response['errors'] = $errors;
$response['success'] = false;
$response['message'] = 'Some adjustments failed';
} else {
$response['success'] = true;
$response['message'] = 'All adjustments processed successfully';
}
return $this->respondSuccess($response);
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Controllers\Api;
use App\Models\Item;
use App\Models\Item_quantity;
use CodeIgniter\HTTP\ResponseInterface;
class Items extends BaseController
{
protected Item $itemModel;
protected Item_quantity $itemQuantityModel;
protected array $allowedSortFields = ['item_id', 'name', 'category', 'cost_price', 'unit_price'];
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
{
parent::initController($request, $response, $logger);
$this->itemModel = model(Item::class);
$this->itemQuantityModel = model(Item_quantity::class);
}
public function index(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$search = $this->request->getGet('search');
$pagination = $this->getPagination();
$sort = $this->getSort($this->allowedSortFields, 'name');
$stockLocation = $this->request->getGet('stockLocation');
$builder = $this->itemModel->builder();
$builder->where('deleted', 0);
if ($search) {
$builder->groupStart();
$builder->like('name', $search);
$builder->orLike('item_number', $search);
$builder->orLike('category', $search);
$builder->orLike('description', $search);
$builder->groupEnd();
}
$total = $builder->countAllResults(false);
$dbSort = $this->mapSortField($sort['sort']);
$builder->orderBy($dbSort, $sort['order']);
$builder->limit($pagination['limit'], $pagination['offset']);
$items = $builder->get()->getResultArray();
return $this->respondSuccess([
'total' => $total,
'offset' => $pagination['offset'],
'limit' => $pagination['limit'],
'rows' => $this->transformCollection($items)
]);
}
public function show($id = null): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$item = $this->itemModel->find($id);
if (!$item || $item->deleted) {
return $this->respondNotFound('Item not found');
}
return $this->respondSuccess($this->transformItem($item));
}
public function create(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getPost();
}
$snakeData = $this->toSnakeCase($data);
if (!empty($snakeData['item_number'])) {
if ($this->itemModel->item_number_exists($snakeData['item_number'])) {
return $this->respondError('Item number already exists', 409);
}
}
$itemId = $this->itemModel->save_value($snakeData);
if ($itemId) {
return $this->respondCreated(['id' => $itemId], 'Item created successfully');
}
return $this->respondError('Failed to create item');
}
public function update($id = null): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$item = $this->itemModel->find($id);
if (!$item || $item->deleted) {
return $this->respondNotFound('Item not found');
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getRawInput();
}
$snakeData = $this->toSnakeCase($data);
$snakeData['item_id'] = $id;
$success = $this->itemModel->save_value($snakeData);
if ($success) {
return $this->respondSuccess([], 200, 'Item updated successfully');
}
return $this->respondError('Failed to update item');
}
public function delete($id = null): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$item = $this->itemModel->find($id);
if (!$item || $item->deleted) {
return $this->respondNotFound('Item not found');
}
$success = $this->itemModel->delete($id);
if ($success) {
return $this->respondSuccess([], 200, 'Item deleted successfully');
}
return $this->respondError('Failed to delete item');
}
public function batchDelete(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
$ids = $data['ids'] ?? [];
if (empty($ids)) {
return $this->respondError('No item IDs provided');
}
$success = $this->itemModel->delete_list($ids);
if ($success) {
return $this->respondSuccess([], 200, 'Items deleted successfully');
}
return $this->respondError('Failed to delete items');
}
public function quantities($id = null): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$item = $this->itemModel->find($id);
if (!$item || $item->deleted) {
return $this->respondNotFound('Item not found');
}
$locations = model('App\Models\Stock_location')->get_all();
$quantities = [];
foreach ($locations as $location) {
$qty = $this->itemQuantityModel->get_item_quantity($id, $location->location_id);
$quantities[] = [
'locationId' => (int) $location->location_id,
'locationName' => $location->location_name,
'quantity' => $qty ? (float) $qty->quantity : 0
];
}
return $this->respondSuccess([
'itemId' => (int) $id,
'quantities' => $quantities
]);
}
public function suggest(): ResponseInterface
{
if (!$this->hasPermission('items')) {
return $this->respondUnauthorized();
}
$term = $this->request->getGet('term');
$limit = (int) ($this->request->getGet('limit') ?? 25);
if (empty($term)) {
return $this->respondSuccess(['suggestions' => []]);
}
$suggestions = $this->itemModel->get_search_suggestions($term, $limit);
return $this->respondSuccess(['suggestions' => $suggestions]);
}
private function mapSortField(string $field): string
{
$map = [
'itemId' => 'item_id',
'name' => 'name',
'category' => 'category',
'costPrice' => 'cost_price',
'unitPrice' => 'unit_price'
];
return $map[$field] ?? 'name';
}
}

View File

@@ -0,0 +1,239 @@
<?php
namespace App\Controllers\Api;
use App\Models\Supplier;
use App\Models\Person;
use CodeIgniter\HTTP\ResponseInterface;
class Suppliers extends BaseController
{
protected Supplier $supplierModel;
protected Person $personModel;
protected array $allowedSortFields = ['person_id', 'last_name', 'company_name'];
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
{
parent::initController($request, $response, $logger);
$this->supplierModel = model(Supplier::class);
$this->personModel = model(Person::class);
}
public function index(): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$search = $this->request->getGet('search');
$pagination = $this->getPagination();
$sort = $this->getSort($this->allowedSortFields, 'companyName');
$builder = $this->supplierModel->builder();
$builder->select('suppliers.*, people.*');
$builder->join('people', 'people.person_id = suppliers.person_id');
$builder->where('suppliers.deleted', 0);
if ($search) {
$builder->groupStart();
$builder->like('people.first_name', $search);
$builder->orLike('people.last_name', $search);
$builder->orLike('people.email', $search);
$builder->orLike('suppliers.account_number', $search);
$builder->orLike('suppliers.company_name', $search);
$builder->groupEnd();
}
$total = $builder->countAllResults(false);
$dbSort = $this->mapSortField($sort['sort']);
$builder->orderBy($dbSort, $sort['order']);
$builder->limit($pagination['limit'], $pagination['offset']);
$suppliers = $builder->get()->getResultArray();
return $this->respondSuccess([
'total' => $total,
'offset' => $pagination['offset'],
'limit' => $pagination['limit'],
'rows' => $this->transformCollection($suppliers)
]);
}
public function show($id = null): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$supplier = $this->supplierModel->get_info($id);
if (empty($supplier) || $supplier->deleted) {
return $this->respondNotFound('Supplier not found');
}
$person = (array) $this->personModel->get_info($id);
$supplier = (array) $supplier;
$data = array_merge($person, $supplier);
return $this->respondSuccess($this->transformItem($data));
}
public function create(): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getPost();
}
$snakeData = [];
foreach ($data as $key => $value) {
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
$snakeData[$snakeKey] = $value;
}
$personData = array_intersect_key($snakeData, array_flip([
'first_name', 'last_name', 'gender', 'phone_number', 'email',
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
]));
$supplierData = array_intersect_key($snakeData, array_flip([
'company_name', 'account_number', 'tax_id', 'agency_name', 'category'
]));
$personId = false;
$success = $this->personModel->save_value($personData);
if ($success && isset($personData['person_id'])) {
$personId = $personData['person_id'];
$supplierData['person_id'] = $personId;
$success = $this->supplierModel->save_value($supplierData);
}
if ($success) {
return $this->respondCreated(['id' => $personId], 'Supplier created successfully');
}
return $this->respondError('Failed to create supplier');
}
public function update($id = null): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$supplier = $this->supplierModel->get_info($id);
if (empty($supplier) || $supplier->deleted) {
return $this->respondNotFound('Supplier not found');
}
$data = $this->request->getJSON(true);
if (empty($data)) {
$data = $this->request->getRawInput();
}
$snakeData = [];
foreach ($data as $key => $value) {
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
$snakeData[$snakeKey] = $value;
}
$personData = array_intersect_key($snakeData, array_flip([
'first_name', 'last_name', 'gender', 'phone_number', 'email',
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
]));
$supplierData = array_intersect_key($snakeData, array_flip([
'company_name', 'account_number', 'tax_id', 'agency_name', 'category'
]));
if (!empty($personData)) {
$this->personModel->save_value($personData, $id);
}
if (!empty($supplierData)) {
$this->supplierModel->save_value($supplierData, $id);
}
return $this->respondSuccess([], 200, 'Supplier updated successfully');
}
public function delete($id = null): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$supplier = $this->supplierModel->get_info($id);
if (empty($supplier) || $supplier->deleted) {
return $this->respondNotFound('Supplier not found');
}
$success = $this->supplierModel->delete($id);
if ($success) {
return $this->respondSuccess([], 200, 'Supplier deleted successfully');
}
return $this->respondError('Failed to delete supplier');
}
public function batchDelete(): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$data = $this->request->getJSON(true);
$ids = $data['ids'] ?? [];
if (empty($ids)) {
return $this->respondError('No supplier IDs provided');
}
$success = $this->supplierModel->delete_list($ids);
if ($success) {
return $this->respondSuccess([], 200, 'Suppliers deleted successfully');
}
return $this->respondError('Failed to delete suppliers');
}
public function suggest(): ResponseInterface
{
if (!$this->hasPermission('suppliers')) {
return $this->respondUnauthorized();
}
$term = $this->request->getGet('term');
$limit = (int) ($this->request->getGet('limit') ?? 25);
if (empty($term)) {
return $this->respondSuccess(['suggestions' => []]);
}
$suggestions = $this->supplierModel->get_search_suggestions($term, $limit);
return $this->respondSuccess(['suggestions' => $suggestions]);
}
private function mapSortField(string $field): string
{
$map = [
'personId' => 'people.person_id',
'lastName' => 'people.last_name',
'companyName' => 'suppliers.company_name'
];
return $map[$field] ?? 'suppliers.company_name';
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Controllers;
use App\Models\ApiKey;
use App\Models\Employee;
class ApiKeys extends Secure_Controller
{
protected ApiKey $apiKeyModel;
public function __construct()
{
parent::__construct('api_keys');
$this->apiKeyModel = model(ApiKey::class);
}
public function index(): void
{
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
$keys = $this->apiKeyModel->getKeysForEmployee($employeeId);
echo view('api_keys/manage', [
'keys' => $keys,
'employee_info' => $this->employee->get_logged_in_employee_info()
]);
}
public function generate(): void
{
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
$name = $this->request->getPost('name');
$expiresAt = $this->request->getPost('expires_at') ?: null;
$apiKey = $this->apiKeyModel->generateKey($employeeId, $name, $expiresAt);
if ($apiKey) {
echo json_encode([
'success' => true,
'message' => lang('Api_keys.key_generated'),
'apiKey' => $apiKey,
'keyPrefix' => substr($apiKey, 0, 12) . '...'
]);
} else {
echo json_encode([
'success' => false,
'message' => lang('Api_keys.key_generation_failed')
]);
}
}
public function revoke(int $apiKeyId): void
{
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
$success = $this->apiKeyModel->revokeKey($apiKeyId, $employeeId);
echo json_encode([
'success' => $success,
'message' => $success ? lang('Api_keys.key_revoked') : lang('Api_keys.key_revoke_failed')
]);
}
public function regenerate(int $apiKeyId): void
{
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
$newKey = $this->apiKeyModel->regenerateKey($apiKeyId, $employeeId);
if ($newKey) {
echo json_encode([
'success' => true,
'message' => lang('Api_keys.key_regenerated'),
'apiKey' => $newKey,
'keyPrefix' => substr($newKey, 0, 12) . '...'
]);
} else {
echo json_encode([
'success' => false,
'message' => lang('Api_keys.key_regeneration_failed')
]);
}
}
}

View File

@@ -942,7 +942,9 @@ class Config extends Secure_Controller
'work_order_enable' => $this->request->getPost('work_order_enable') != null,
'work_order_format' => $this->request->getPost('work_order_format'),
'last_used_work_order_number' => $this->request->getPost('last_used_work_order_number', FILTER_SANITIZE_NUMBER_INT),
'invoice_type' => $this->request->getPost('invoice_type')
'invoice_type' => Sale_lib::isValidInvoiceType($this->request->getPost('invoice_type'))
? $this->request->getPost('invoice_type')
: 'invoice'
];
$success = $this->appconfig->batch_save($batch_save_data);

View File

@@ -1017,7 +1017,11 @@ class Items extends Secure_Controller
}
if (!$is_failed_row) {
$is_failed_row = $this->data_error_check($row, $item_data, $allowed_stock_locations, $attribute_definition_names, $attribute_data);
$invalidLocations = $this->validateCSVStockLocations($row, $allowedStockLocations);
if (!empty($invalidLocations)) {
$isFailedRow = true;
log_message('error', 'CSV import: Invalid stock location(s) found: ' . implode(', ', $invalidLocations));
}
}
// Remove false, null, '' and empty strings but keep 0
@@ -1063,6 +1067,30 @@ class Items extends Secure_Controller
}
/**
* Validates that stock location columns in CSV row are valid locations
*
* @param array $row
* @param array $allowedLocations
* @return array Returns array of invalid location names, empty if all valid
*/
private function validateCSVStockLocations(array $row, array $allowedLocations): array
{
$invalidLocations = [];
$allowedLocationNames = array_values($allowedLocations);
foreach (array_keys($row) as $key) {
if (str_starts_with($key, 'location_')) {
$locationName = substr($key, 9);
if (!in_array($locationName, $allowedLocationNames)) {
$invalidLocations[] = $locationName;
}
}
}
return $invalidLocations;
}
/**
* Checks the entire line of data in an import file for errors
*

View File

@@ -755,8 +755,11 @@ class Sales extends Secure_Controller
$data['sale_status'] = COMPLETED;
$sale_type = SALE_TYPE_INVOICE;
// The PHP file name is the same as the invoice_type key
$invoice_view = $this->config['invoice_type'];
$invoice_type = $this->config['invoice_type'];
if (!Sale_lib::isValidInvoiceType($invoice_type)) {
$invoice_type = 'invoice';
}
$invoice_view = $invoice_type;
// Save the data to the sales table
$data['sale_id_num'] = $this->sale->save_value($sale_id, $data['sale_status'], $data['cart'], $customer_id, $employee_id, $data['comments'], $invoice_number, $work_order_number, $quote_number, $sale_type, $data['payments'], $data['dinner_table'], $tax_details);
@@ -1107,6 +1110,9 @@ class Sales extends Secure_Controller
}
$invoice_type = $this->config['invoice_type'];
if (!Sale_lib::isValidInvoiceType($invoice_type)) {
$invoice_type = 'invoice';
}
$data['invoice_view'] = $invoice_type;
return $data;

View File

@@ -267,6 +267,8 @@ class Migration_Sales_Tax_Data extends Migration
*/
public function round_number(int $rounding_mode, string $amount, int $decimals): float
{
$amount = (float)$amount;
if ($rounding_mode == Migration_Sales_Tax_Data::ROUND_UP) {
$fig = pow(10, $decimals);
$rounded_total = (ceil($fig * $amount) + ceil($fig * $amount - ceil($fig * $amount))) / $fig;
@@ -376,7 +378,7 @@ class Migration_Sales_Tax_Data extends Migration
$decimals = totals_decimals();
foreach ($sales_taxes as $row_number => $sales_tax) {
$sale_tax_amount = $sales_tax['sale_tax_amount'];
$sale_tax_amount = (float)$sales_tax['sale_tax_amount'];
$rounding_code = $sales_tax['rounding_code'];
$rounded_sale_tax_amount = $sale_tax_amount;

View File

@@ -243,6 +243,8 @@ class Migration_TaxAmount extends Migration
*/
public function round_number(int $rounding_mode, string $amount, int $decimals): float // TODO: is this currency safe?
{ // TODO: This needs to be converted to a switch
$amount = (float)$amount;
if ($rounding_mode == Migration_TaxAmount::ROUND_UP) { // TODO: === ?
$fig = pow(10, $decimals);
$rounded_total = (ceil($fig * $amount) + ceil($fig * $amount - ceil($fig * $amount))) / $fig;
@@ -354,7 +356,7 @@ class Migration_TaxAmount extends Migration
$decimals = totals_decimals();
foreach ($sales_taxes as $row_number => $sales_tax) {
$sale_tax_amount = $sales_tax['sale_tax_amount'];
$sale_tax_amount = (float)$sales_tax['sale_tax_amount'];
$rounding_code = $sales_tax['rounding_code'];
$rounded_sale_tax_amount = $sale_tax_amount;

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class ApiKeys extends Migration
{
public function up(): void
{
$this->forge->addField([
'api_key_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true
],
'employee_id' => [
'type' => 'INT',
'constraint' => 10
],
'key_hash' => [
'type' => 'VARCHAR',
'constraint' => 64
],
'key_prefix' => [
'type' => 'VARCHAR',
'constraint' => 12
],
'name' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true
],
'last_used' => [
'type' => 'DATETIME',
'null' => true
],
'created' => [
'type' => 'DATETIME',
'null' => true
],
'expires_at' => [
'type' => 'DATETIME',
'null' => true
],
'disabled' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0
]
]);
$this->forge->addKey('api_key_id', true);
$this->forge->addKey('employee_id');
$this->forge->addKey('key_hash');
$this->forge->createTable('api_keys', true);
$this->db->query(
'ALTER TABLE ' . $this->db->prefixTable('api_keys') .
' ADD CONSTRAINT ' . $this->db->prefixTable('api_keys') . '_employee_id_foreign' .
' FOREIGN KEY (employee_id) REFERENCES ' . $this->db->prefixTable('employees') .
' (person_id) ON DELETE CASCADE ON UPDATE CASCADE'
);
$this->db->query(
'INSERT INTO ' . $this->db->prefixTable('permissions') . ' (permission_id, module_id)' .
" VALUES ('api_keys', 'office') ON DUPLICATE KEY UPDATE permission_id = 'api_keys'"
);
$this->db->query(
'INSERT INTO ' . $this->db->prefixTable('modules') . ' (module_id, name_lang_key, desc_lang_key, sort)' .
" VALUES ('api_keys', 'module_api_keys', 'module_desc_api_keys', 25)" .
" ON DUPLICATE KEY UPDATE module_id = 'module_id'"
);
}
public function down(): void
{
$this->db->query(
'ALTER TABLE ' . $this->db->prefixTable('api_keys') .
' DROP FOREIGN KEY ' . $this->db->prefixTable('api_keys') . '_employee_id_foreign'
);
$this->db->query(
'DELETE FROM ' . $this->db->prefixTable('permissions') . " WHERE permission_id = 'api_keys'"
);
$this->db->query(
'DELETE FROM ' . $this->db->prefixTable('modules') . " WHERE module_id = 'api_keys'"
);
$this->forge->dropTable('api_keys', true);
}
}

48
app/Filters/ApiAuth.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filters;
use App\Models\ApiKey;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class ApiAuth implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null): mixed
{
$apiKey = $request->getHeaderLine('X-API-Key');
if (empty($apiKey)) {
return $this->unauthorized('API key required');
}
$apiKeyModel = model(ApiKey::class);
$employeeId = $apiKeyModel->validateKey($apiKey);
if (!$employeeId) {
return $this->unauthorized('Invalid or expired API key');
}
$request->employeeId = $employeeId;
Services::set('apiEmployeeId', $employeeId);
return $request;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): mixed
{
return $response;
}
private function unauthorized(string $message): ResponseInterface
{
return Services::response()
->setStatusCode(401)
->setJSON([
'success' => false,
'message' => $message
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
return [
'module_api_keys' => 'API Keys',
'module_desc_api_keys' => 'Manage API access keys for integrations.',
'api_keys' => 'API Keys',
'api_key' => 'API Key',
'generate_key' => 'Generate API Key',
'regenerate_key' => 'Regenerate',
'revoke_key' => 'Revoke',
'key_name' => 'Key Name',
'key_prefix' => 'Key Prefix',
'last_used' => 'Last Used',
'created' => 'Created',
'expires' => 'Expires',
'never' => 'Never',
'disabled' => 'Disabled',
'key_generated' => 'API key generated successfully',
'key_generation_failed' => 'Failed to generate API key',
'key_revoked' => 'API key revoked successfully',
'key_revoke_failed' => 'Failed to revoke API key',
'key_regenerated' => 'API key regenerated successfully',
'key_regeneration_failed' => 'Failed to regenerate API key',
'copy_warning' => 'Copy this key now! It will not be shown again.',
'no_keys' => 'No API keys have been generated yet.',
'confirm_revoke' => 'Are you sure you want to revoke this API key? This action cannot be undone.',
'confirm_regenerate' => 'Are you sure you want to regenerate this API key? The old key will immediately stop working.',
'key_description' => 'API keys allow external applications to access your OSPOS data. Keep your keys secure and never share them publicly.',
];

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Wholesale Price is a required field.",
"count" => "Update Inventory",
"csv_import_failed" => "CSV import failed",
"csv_import_invalid_location" => "Invalid stock location(s) found: {0}. Only valid stock locations are allowed.",
"csv_import_nodata_wrongformat" => "The uploaded CSV file has no data or is formatted incorrectly.",
"csv_import_partially_failed" => "There were {0} item import failure(s) on line(s): {1}. No rows were imported.",
"csv_import_success" => "Item CSV import successful.",

View File

@@ -88,9 +88,13 @@ class Sale_lib
return $register_modes;
}
/**
* @return array
*/
private const ALLOWED_INVOICE_TYPES = [
'invoice',
'tax_invoice',
'custom_invoice',
'custom_tax_invoice'
];
public function get_invoice_type_options(): array
{
$invoice_types = [];
@@ -101,6 +105,11 @@ class Sale_lib
return $invoice_types;
}
public static function isValidInvoiceType(string $invoice_type): bool
{
return in_array($invoice_type, self::ALLOWED_INVOICE_TYPES, true);
}
/**
* @return array
*/

145
app/Models/ApiKey.php Normal file
View File

@@ -0,0 +1,145 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class ApiKey extends Model
{
protected $table = 'api_keys';
protected $primaryKey = 'api_key_id';
protected $useAutoIncrement = true;
protected $useSoftDeletes = false;
protected $allowedFields = [
'employee_id',
'key_hash',
'key_prefix',
'name',
'last_used',
'expires_at',
'disabled'
];
protected $useTimestamps = false;
protected $createdField = 'created';
private const KEY_PREFIX = 'ospos_';
private const KEY_BYTES = 32;
public function generateKey(int $employeeId, ?string $name = null, ?string $expiresAt = null): string|false
{
$rawKey = bin2hex(random_bytes(self::KEY_BYTES));
$apiKey = self::KEY_PREFIX . $rawKey;
$keyHash = hash('sha256', $apiKey);
$keyPrefix = substr($apiKey, 0, 12);
$data = [
'employee_id' => $employeeId,
'key_hash' => $keyHash,
'key_prefix' => $keyPrefix,
'name' => $name,
'expires_at' => $expiresAt
];
if ($this->insert($data)) {
return $apiKey;
}
return false;
}
public function validateKey(string $apiKey): int|false
{
if (!str_starts_with($apiKey, self::KEY_PREFIX)) {
return false;
}
if (strlen($apiKey) !== strlen(self::KEY_PREFIX) + (self::KEY_BYTES * 2)) {
return false;
}
$keyHash = hash('sha256', $apiKey);
$builder = $this->builder();
$builder->where('key_hash', $keyHash);
$builder->where('disabled', 0);
$builder->groupStart();
$builder->where('expires_at IS NULL');
$builder->orWhere('expires_at >', date('Y-m-d H:i:s'));
$builder->groupEnd();
$result = $builder->get()->getRow();
if ($result) {
$this->update($result->api_key_id, ['last_used' => date('Y-m-d H:i:s')]);
return (int) $result->employee_id;
}
return false;
}
public function getKeysForEmployee(int $employeeId): array
{
$builder = $this->builder();
$builder->where('employee_id', $employeeId);
$builder->orderBy('created', 'DESC');
return $builder->get()->getResultArray();
}
public function revokeKey(int $apiKeyId, int $employeeId): bool
{
$builder = $this->builder();
$builder->where('api_key_id', $apiKeyId);
$builder->where('employee_id', $employeeId);
return $builder->update(['disabled' => 1]) !== false;
}
public function regenerateKey(int $apiKeyId, int $employeeId): string|false
{
$existingKey = $this->builder()
->getWhere([
'api_key_id' => $apiKeyId,
'employee_id' => $employeeId
])
->getRow();
if (!$existingKey) {
return false;
}
$newKey = $this->generateKey(
$employeeId,
$existingKey->name,
$existingKey->expires_at
);
if ($newKey) {
$this->delete($apiKeyId);
return $newKey;
}
return false;
}
public function cleanupExpired(): int
{
$builder = $this->builder();
$builder->where('disabled', 0);
$builder->where('expires_at <', date('Y-m-d H:i:s'));
$builder->where('expires_at IS NOT NULL');
$expiredKeys = $builder->get()->getResultArray();
$count = 0;
foreach ($expiredKeys as $key) {
if ($this->update($key['api_key_id'], ['disabled' => 1])) {
$count++;
}
}
return $count;
}
}

11
package-lock.json generated
View File

@@ -28,7 +28,7 @@
"chartist-plugin-tooltips": "^0.0.17",
"clipboard": "^2.0.11",
"coffeescript": "^2.7.0",
"dompurify": "^3.3.0",
"dompurify": "^3.3.2",
"elegant-circles": "github:opensourcepos/elegant-circles#minified",
"es6-promise": "^4.2.8",
"file-saver": "^2.0.5",
@@ -1483,10 +1483,13 @@
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}

View File

@@ -49,7 +49,7 @@
"chartist-plugin-tooltips": "^0.0.17",
"clipboard": "^2.0.11",
"coffeescript": "^2.7.0",
"dompurify": "^3.3.0",
"dompurify": "^3.3.2",
"elegant-circles": "github:opensourcepos/elegant-circles#minified",
"es6-promise": "^4.2.8",
"file-saver": "^2.0.5",

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,981 @@
<?php
namespace Tests\Controllers;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use App\Models\Item;
use App\Models\Item_quantity;
use App\Models\Inventory;
use App\Models\Item_taxes;
use App\Models\Attribute;
use App\Models\Stock_location;
use App\Models\Supplier;
class ItemsCsvImportTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $migrate = true;
protected $migrateOnce = false;
protected $refresh = true;
protected $namespace = null;
protected $item;
protected $item_quantity;
protected $inventory;
protected $item_taxes;
protected $attribute;
protected $stock_location;
protected $supplier;
protected function setUp(): void
{
parent::setUp();
helper('importfile');
helper('attribute');
$this->item = model(Item::class);
$this->item_quantity = model(Item_quantity::class);
$this->inventory = model(Inventory::class);
$this->item_taxes = model(Item_taxes::class);
$this->attribute = model(Attribute::class);
$this->stock_location = model(Stock_location::class);
$this->supplier = model(Supplier::class);
}
protected function tearDown(): void
{
parent::tearDown();
}
public function testGenerateCsvHeaderBasic(): void
{
$stock_locations = ['Warehouse'];
$attributes = [];
$csv = generate_import_items_csv($stock_locations, $attributes);
$this->assertStringContainsString('Id,Barcode,"Item Name"', $csv);
$this->assertStringContainsString('Category,"Supplier ID"', $csv);
$this->assertStringContainsString('"Cost Price","Unit Price"', $csv);
$this->assertStringContainsString('"Tax 1 Name","Tax 1 Percent"', $csv);
$this->assertStringContainsString('"Tax 2 Name","Tax 2 Percent"', $csv);
$this->assertStringContainsString('"Reorder Level"', $csv);
$this->assertStringContainsString('Description,"Allow Alt Description"', $csv);
$this->assertStringContainsString('"Item has Serial Number"', $csv);
$this->assertStringContainsString('Image,HSN', $csv);
$this->assertStringContainsString('"location_Warehouse"', $csv);
$this->assertStringContainsString("\xEF\xBB\xBF", $csv);
}
public function testGenerateCsvHeaderMultipleLocations(): void
{
$stock_locations = ['Warehouse', 'Store', 'Backroom'];
$attributes = [];
$csv = generate_import_items_csv($stock_locations, $attributes);
$this->assertStringContainsString('"location_Warehouse"', $csv);
$this->assertStringContainsString('"location_Store"', $csv);
$this->assertStringContainsString('"location_Backroom"', $csv);
}
public function testGenerateCsvHeaderWithAttributes(): void
{
$stock_locations = ['Warehouse'];
$attributes = ['Color', 'Size', 'Weight'];
$csv = generate_import_items_csv($stock_locations, $attributes);
$this->assertStringContainsString('"attribute_Color"', $csv);
$this->assertStringContainsString('"attribute_Size"', $csv);
$this->assertStringContainsString('"attribute_Weight"', $csv);
}
public function testGenerateStockLocationHeaders(): void
{
$locations = ['Warehouse', 'Store'];
$headers = generate_stock_location_headers($locations);
$this->assertEquals(',"location_Warehouse","location_Store"', $headers);
}
public function testGenerateAttributeHeaders(): void
{
$attributes = ['Color', 'Size'];
$headers = generate_attribute_headers($attributes);
$this->assertEquals(',"attribute_Color","attribute_Size"', $headers);
}
public function testGenerateAttributeHeadersRemovesNegativeOneIndex(): void
{
$attributes = [-1 => 'None', 'Color' => 'Color'];
unset($attributes[-1]);
$headers = generate_attribute_headers($attributes);
$this->assertStringContainsString('"attribute_Color"', $headers);
}
public function testGetCsvFileBasic(): void
{
$csv_content = "Id,Barcode,\"Item Name\",Category,\"Supplier ID\",\"Cost Price\",\"Unit Price\",\"Tax 1 Name\",\"Tax 1 Percent\",\"Tax 2 Name\",\"Tax 2 Percent\",\"Reorder Level\",Description,\"Allow Alt Description\",\"Item has Serial Number\",Image,HSN\n";
$csv_content .= ",ITEM001,Test Item,Electronics,1,10.00,15.00,,,,,5,Test Description,0,0,,HSN001\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_test_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(1, $rows);
$this->assertEquals('', $rows[0]['Id']);
$this->assertEquals('ITEM001', $rows[0]['Barcode']);
$this->assertEquals('Test Item', $rows[0]['Item Name']);
$this->assertEquals('Electronics', $rows[0]['Category']);
unlink($temp_file);
}
public function testGetCsvFileWithBom(): void
{
$bom = pack('CCC', 0xef, 0xbb, 0xbf);
$csv_content = $bom . "Id,\"Item Name\",Category\n";
$csv_content .= "1,Test Item,Electronics\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_test_bom_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(1, $rows);
$this->assertEquals('1', $rows[0]['Id']);
$this->assertEquals('Test Item', $rows[0]['Item Name']);
unlink($temp_file);
}
public function testGetCsvFileMultipleRows(): void
{
$csv_content = "Id,\"Item Name\",Category\n";
$csv_content .= "1,Item One,Cat A\n";
$csv_content .= "2,Item Two,Cat B\n";
$csv_content .= "3,Item Three,Cat C\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_test_multi_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(3, $rows);
$this->assertEquals('Item One', $rows[0]['Item Name']);
$this->assertEquals('Item Two', $rows[1]['Item Name']);
$this->assertEquals('Item Three', $rows[2]['Item Name']);
unlink($temp_file);
}
public function testBomExists(): void
{
$bom = pack('CCC', 0xef, 0xbb, 0xbf);
$content_with_bom = $bom . "test content";
$temp_file = tempnam(sys_get_temp_dir(), 'bom_test_');
file_put_contents($temp_file, $content_with_bom);
$handle = fopen($temp_file, 'r');
$result = bom_exists($handle);
fclose($handle);
$this->assertTrue($result);
unlink($temp_file);
}
public function testBomNotExists(): void
{
$content_without_bom = "test content without BOM";
$temp_file = tempnam(sys_get_temp_dir(), 'no_bom_test_');
file_put_contents($temp_file, $content_without_bom);
$handle = fopen($temp_file, 'r');
$result = bom_exists($handle);
fclose($handle);
$this->assertFalse($result);
unlink($temp_file);
}
public function testImportItemBasicFields(): void
{
$item_data = [
'item_id' => null,
'name' => 'CSV Imported Item',
'description' => 'Description from CSV',
'category' => 'Electronics',
'cost_price' => 10.50,
'unit_price' => 25.99,
'reorder_level' => 5,
'supplier_id' => null,
'item_number' => 'CSV-ITEM-001',
'allow_alt_description' => 0,
'is_serialized' => 0,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$this->assertIsInt($item_id);
$this->assertGreaterThan(0, $item_id);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('CSV Imported Item', $saved_item->name);
$this->assertEquals('Description from CSV', $saved_item->description);
$this->assertEquals('Electronics', $saved_item->category);
$this->assertEquals(10.50, (float)$saved_item->cost_price);
$this->assertEquals(25.99, (float)$saved_item->unit_price);
}
public function testImportItemWithQuantity(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Quantity',
'category' => 'Test Category',
'cost_price' => 5.00,
'unit_price' => 10.00,
'reorder_level' => 2,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$location_id = 1;
$quantity = 100;
$item_quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => $quantity
];
$result = $this->item_quantity->save_value($item_quantity_data, $item_id, $location_id);
$this->assertTrue($result);
$saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id);
$this->assertEquals($quantity, $saved_quantity->quantity);
}
public function testImportItemCreatesInventoryRecord(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Inventory',
'category' => 'Test',
'cost_price' => 5.00,
'unit_price' => 10.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$inventory_data = [
'trans_inventory' => 50,
'trans_items' => $item_id,
'trans_location' => 1,
'trans_comment' => 'CSV Import',
'trans_user' => 1
];
$trans_id = $this->inventory->insert($inventory_data);
$this->assertIsInt($trans_id);
$this->assertGreaterThan(0, $trans_id);
$inventory_records = $this->inventory->get_inventory_data_for_item($item_id, 1);
$this->assertGreaterThanOrEqual(1, $inventory_records->getNumRows());
}
public function testImportItemWithTaxes(): void
{
$item_data = [
'item_id' => null,
'name' => 'Taxable Item',
'category' => 'Test',
'cost_price' => 100.00,
'unit_price' => 150.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$taxes_data = [
['name' => 'VAT', 'percent' => 20],
['name' => 'GST', 'percent' => 10]
];
$result = $this->item_taxes->save_value($taxes_data, $item_id);
$this->assertTrue($result);
$saved_taxes = $this->item_taxes->get_info($item_id);
$tax_names = array_column($saved_taxes, 'name');
$this->assertContains('VAT', $tax_names);
$this->assertContains('GST', $tax_names);
}
public function testImportMultipleItemsFromSimulatedCsv(): void
{
$csv_data = [
[
'Id' => '',
'Barcode' => 'ITEM-A',
'Item Name' => 'First Item',
'Category' => 'Category A',
'Supplier ID' => '',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'Tax 1 Name' => '',
'Tax 1 Percent' => '',
'Tax 2 Name' => '',
'Tax 2 Percent' => '',
'Reorder Level' => '5',
'Description' => 'First item description',
'Allow Alt Description' => '0',
'Item has Serial Number' => '0',
'Image' => '',
'HSN' => '',
'location_Warehouse' => '100'
],
[
'Id' => '',
'Barcode' => 'ITEM-B',
'Item Name' => 'Second Item',
'Category' => 'Category B',
'Supplier ID' => '',
'Cost Price' => '15.00',
'Unit Price' => '30.00',
'Tax 1 Name' => '',
'Tax 1 Percent' => '',
'Tax 2 Name' => '',
'Tax 2 Percent' => '',
'Reorder Level' => '10',
'Description' => 'Second item description',
'Allow Alt Description' => '0',
'Item has Serial Number' => '0',
'Image' => '',
'HSN' => '',
'location_Warehouse' => '50'
]
];
$imported_item_ids = [];
foreach ($csv_data as $row) {
$item_data = [
'item_id' => (int)$row['Id'] ?: null,
'name' => $row['Item Name'],
'description' => $row['Description'],
'category' => $row['Category'],
'cost_price' => (float)$row['Cost Price'],
'unit_price' => (float)$row['Unit Price'],
'reorder_level' => (int)$row['Reorder Level'],
'item_number' => $row['Barcode'] ?: null,
'allow_alt_description' => empty($row['Allow Alt Description']) ? '0' : '1',
'is_serialized' => empty($row['Item has Serial Number']) ? '0' : '1',
'deleted' => false
];
$item_id = $this->item->save_value($item_data);
$imported_item_ids[] = $item_id;
}
$this->assertCount(2, $imported_item_ids);
$item1 = $this->item->get_info($imported_item_ids[0]);
$this->assertEquals('First Item', $item1->name);
$this->assertEquals(10.00, (float)$item1->cost_price);
$item2 = $this->item->get_info($imported_item_ids[1]);
$this->assertEquals('Second Item', $item2->name);
$this->assertEquals(15.00, (float)$item2->cost_price);
}
public function testImportUpdateExistingItem(): void
{
$original_data = [
'item_id' => null,
'name' => 'Original Name',
'category' => 'Original Category',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($original_data);
$updated_data = [
'item_id' => $item_id,
'name' => 'Updated Name',
'category' => 'Updated Category',
'cost_price' => 15.00,
'unit_price' => 30.00,
'description' => 'New description',
'reorder_level' => 10,
'deleted' => 0
];
$this->item->save_value($updated_data);
$updated_item = $this->item->get_info($item_id);
$this->assertEquals('Updated Name', $updated_item->name);
$this->assertEquals('Updated Category', $updated_item->category);
$this->assertEquals(15.00, (float)$updated_item->cost_price);
$this->assertEquals(30.00, (float)$updated_item->unit_price);
}
public function testImportItemWithAttributeText(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Attribute',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$definition_data = [
'definition_name' => 'Color',
'definition_type' => TEXT,
'definition_flags' => 0,
'deleted' => 0
];
$definition_id = $this->attribute->saveDefinition($definition_data);
$attribute_value = 'Red';
$attribute_id = $this->attribute->saveAttributeValue(
$attribute_value,
$definition_id,
$item_id,
false,
TEXT
);
$this->assertNotFalse($attribute_id);
$saved_value = $this->attribute->getAttributeValue($item_id, $definition_id);
$this->assertEquals('Red', $saved_value->attribute_value);
}
public function testImportItemWithAttributeDropdown(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Dropdown',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$definition_data = [
'definition_name' => 'Size',
'definition_type' => DROPDOWN,
'definition_flags' => 0,
'deleted' => 0
];
$definition_id = $this->attribute->saveDefinition($definition_data);
$dropdown_values = ['Small', 'Medium', 'Large'];
foreach ($dropdown_values as $i => $value) {
$this->db->table('attribute_values')->insert([
'attribute_value' => $value,
'definition_id' => $definition_id,
'definition_type' => DROPDOWN,
'attribute_group' => $i,
'deleted' => 0
]);
}
$attribute_value = 'Medium';
$attribute_id = $this->attribute->saveAttributeValue(
$attribute_value,
$definition_id,
$item_id,
false,
DROPDOWN
);
$this->assertNotFalse($attribute_id);
$saved_value = $this->attribute->getAttributeValue($item_id, $definition_id);
$this->assertEquals('Medium', $saved_value->attribute_value);
}
public function testImportItemQuantityZero(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Zero Quantity',
'category' => 'Test',
'cost_price' => 5.00,
'unit_price' => 10.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$location_id = 1;
$item_quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => 0
];
$result = $this->item_quantity->save_value($item_quantity_data, $item_id, $location_id);
$this->assertTrue($result);
$saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id);
$this->assertEquals(0, (int)$saved_quantity->quantity);
}
public function testImportItemWithNegativeReorderLevel(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Negative Reorder',
'category' => 'Test',
'cost_price' => 5.00,
'unit_price' => 10.00,
'reorder_level' => -1,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals(-1, (int)$saved_item->reorder_level);
}
public function testImportItemWithHighPrecisionPrices(): void
{
$item_data = [
'item_id' => null,
'name' => 'High Precision Item',
'category' => 'Test',
'cost_price' => 10.123456,
'unit_price' => 25.876543,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$saved_item = $this->item->get_info($item_id);
$cost_diff = abs(10.123456 - (float)$saved_item->cost_price);
$price_diff = abs(25.876543 - (float)$saved_item->unit_price);
$this->assertLessThan(0.001, $cost_diff, 'Cost price should maintain precision');
$this->assertLessThan(0.001, $price_diff, 'Unit price should maintain precision');
}
public function testImportItemWithHsnCode(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With HSN',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'hsn_code' => '8471',
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('8471', $saved_item->hsn_code);
}
public function testImportItemQuantityMultipleLocations(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Multi Location',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$quantities = [
['location_id' => 1, 'quantity' => 100],
['location_id' => 2, 'quantity' => 50],
['location_id' => 3, 'quantity' => 25]
];
foreach ($quantities as $q) {
$result = $this->item_quantity->save_value(
['item_id' => $item_id, 'location_id' => $q['location_id'], 'quantity' => $q['quantity']],
$item_id,
$q['location_id']
);
$this->assertTrue($result);
}
foreach ($quantities as $q) {
$saved = $this->item_quantity->get_item_quantity($item_id, $q['location_id']);
$this->assertEquals($q['quantity'], (int)$saved->quantity, "Quantity at location {$q['location_id']} should match");
}
}
public function testCsvImportQuantityValidationNumeric(): void
{
$csv_data = [
'Id' => '',
'Barcode' => 'VALID-ITEM',
'Item Name' => 'Valid Item',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_Warehouse' => '100'
];
$this->assertTrue(is_numeric($csv_data['location_Warehouse']));
$this->assertTrue(is_numeric($csv_data['Cost Price']));
$this->assertTrue(is_numeric($csv_data['Unit Price']));
}
public function testCsvImportEmptyBarcodeAllowed(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Without Barcode',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'item_number' => null,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$this->assertIsInt($item_id);
$this->assertGreaterThan(0, $item_id);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('Item Without Barcode', $saved_item->name);
}
public function testCsvImportItemExistsCheck(): void
{
$item_data = [
'item_id' => null,
'name' => 'Existing Item',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$exists = $this->item->exists($item_id);
$this->assertTrue($exists);
$not_exists = $this->item->exists(999999);
$this->assertFalse($not_exists);
}
public function testFullCsvImportFlowSimulated(): void
{
$csv_row = [
'Id' => '',
'Barcode' => 'FULL-TEST-001',
'Item Name' => 'Complete Test Item',
'Category' => 'Electronics',
'Supplier ID' => '',
'Cost Price' => '50.00',
'Unit Price' => '100.00',
'Tax 1 Name' => 'VAT',
'Tax 1 Percent' => '20',
'Tax 2 Name' => '',
'Tax 2 Percent' => '',
'Reorder Level' => '10',
'Description' => 'A complete test item for CSV import',
'Allow Alt Description' => '1',
'Item has Serial Number' => '0',
'Image' => '',
'HSN' => '84713020'
];
$item_data = [
'item_id' => (int)$csv_row['Id'] ?: null,
'name' => $csv_row['Item Name'],
'description' => $csv_row['Description'],
'category' => $csv_row['Category'],
'cost_price' => (float)$csv_row['Cost Price'],
'unit_price' => (float)$csv_row['Unit Price'],
'reorder_level' => (int)$csv_row['Reorder Level'],
'item_number' => $csv_row['Barcode'] ?: null,
'allow_alt_description' => empty($csv_row['Allow Alt Description']) ? '0' : '1',
'is_serialized' => empty($csv_row['Item has Serial Number']) ? '0' : '1',
'hsn_code' => $csv_row['HSN'],
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$taxes_data = [];
if (is_numeric($csv_row['Tax 1 Percent']) && $csv_row['Tax 1 Name'] !== '') {
$taxes_data[] = ['name' => $csv_row['Tax 1 Name'], 'percent' => $csv_row['Tax 1 Percent']];
}
if (is_numeric($csv_row['Tax 2 Percent']) && $csv_row['Tax 2 Name'] !== '') {
$taxes_data[] = ['name' => $csv_row['Tax 2 Name'], 'percent' => $csv_row['Tax 2 Percent']];
}
if (!empty($taxes_data)) {
$this->item_taxes->save_value($taxes_data, $item_id);
}
$location_id = 1;
$quantity = 75;
$quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => $quantity
];
$this->item_quantity->save_value($quantity_data, $item_id, $location_id);
$inventory_data = [
'trans_inventory' => $quantity,
'trans_items' => $item_id,
'trans_location' => $location_id,
'trans_comment' => 'CSV import quantity',
'trans_user' => 1
];
$this->inventory->insert($inventory_data);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('Complete Test Item', $saved_item->name);
$this->assertEquals('Electronics', $saved_item->category);
$this->assertEquals(50.00, (float)$saved_item->cost_price);
$this->assertEquals(100.00, (float)$saved_item->unit_price);
$this->assertEquals('84713020', $saved_item->hsn_code);
$saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id);
$this->assertEquals($quantity, (int)$saved_quantity->quantity);
$saved_taxes = $this->item_taxes->get_info($item_id);
$this->assertCount(1, $saved_taxes);
$this->assertEquals('VAT', $saved_taxes[0]['name']);
$this->assertEquals(20, (float)$saved_taxes[0]['percent']);
$inventory_records = $this->inventory->get_inventory_data_for_item($item_id, $location_id);
$this->assertGreaterThanOrEqual(1, $inventory_records->getNumRows());
}
public function testImportCsvInvalidStockLocationColumn(): void
{
$csv_headers = ['Id', 'Item Name', 'Category', 'Cost Price', 'Unit Price', 'location_NonExistentLocation'];
$csv_row = [
'Id' => '',
'Item Name' => 'Test Item Invalid Location',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_NonExistentLocation' => '100'
];
$allowed_locations = [1 => 'Warehouse'];
$location_columns_in_csv = [];
foreach (array_keys($csv_row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns_in_csv[$key] = substr($key, 9);
}
}
$invalid_locations = [];
foreach ($location_columns_in_csv as $column => $location_name) {
if (!in_array($location_name, $allowed_locations)) {
$invalid_locations[] = $location_name;
}
}
$this->assertNotEmpty($invalid_locations, 'Should detect invalid location in CSV');
$this->assertContains('NonExistentLocation', $invalid_locations);
}
public function testImportCsvValidStockLocationColumn(): void
{
$csv_row = [
'Id' => '',
'Item Name' => 'Test Item Valid Location',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_Warehouse' => '100'
];
$allowed_locations = [1 => 'Warehouse'];
$location_columns_in_csv = [];
foreach (array_keys($csv_row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns_in_csv[$key] = substr($key, 9);
}
}
$invalid_locations = [];
foreach ($location_columns_in_csv as $column => $location_name) {
if (!in_array($location_name, $allowed_locations)) {
$invalid_locations[] = $location_name;
}
}
$this->assertEmpty($invalid_locations, 'Should have no invalid locations');
}
public function testImportCsvMixedValidAndInvalidLocations(): void
{
$csv_row = [
'Id' => '',
'Item Name' => 'Test Item Mixed Locations',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_Warehouse' => '100',
'location_InvalidLocation' => '50'
];
$allowed_locations = [1 => 'Warehouse', 2 => 'Store'];
$location_columns_in_csv = [];
foreach (array_keys($csv_row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns_in_csv[$key] = substr($key, 9);
}
}
$invalid_locations = [];
foreach ($location_columns_in_csv as $column => $location_name) {
if (!in_array($location_name, $allowed_locations)) {
$invalid_locations[] = $location_name;
}
}
$this->assertCount(1, $invalid_locations, 'Should have exactly one invalid location');
$this->assertContains('InvalidLocation', $invalid_locations);
}
public function testValidateCsvStockLocations(): void
{
$csv_content = "Id,\"Item Name\",Category,\"Cost Price\",\"Unit Price\",\"location_Warehouse\",\"location_FakeLocation\"\n";
$csv_content .= ",Test Item,Test,10.00,20.00,100,50\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_location_test_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(1, $rows);
$row = $rows[0];
$this->assertArrayHasKey('location_Warehouse', $row);
$this->assertArrayHasKey('location_FakeLocation', $row);
unlink($temp_file);
}
public function testImportItemQuantityOnlyForValidLocations(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Location Test',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$allowed_locations = [1 => 'Warehouse', 2 => 'Store'];
$csv_row_simulated = [
'location_Warehouse' => '100',
'location_Store' => '50',
'location_NonExistent' => '25'
];
foreach ($allowed_locations as $location_id => $location_name) {
$column_name = "location_$location_name";
if (isset($csv_row_simulated[$column_name]) || $csv_row_simulated[$column_name] === '0') {
$quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => (int)$csv_row_simulated[$column_name]
];
$this->item_quantity->save_value($quantity_data, $item_id, $location_id);
}
}
$warehouse_qty = $this->item_quantity->get_item_quantity($item_id, 1);
$this->assertEquals(100, (int)$warehouse_qty->quantity);
$store_qty = $this->item_quantity->get_item_quantity($item_id, 2);
$this->assertEquals(50, (int)$store_qty->quantity);
$result = $this->item_quantity->exists($item_id, 999);
$this->assertFalse($result, 'Should not have quantity for non-existent location');
}
public function testDetectCsvLocationColumns(): void
{
$row = [
'Id' => '',
'Item Name' => 'Test',
'location_Warehouse' => '100',
'location_Store' => '50',
'attribute_Color' => 'Red'
];
$location_columns = [];
foreach (array_keys($row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns[$key] = substr($key, 9);
}
}
$this->assertCount(2, $location_columns);
$this->assertArrayHasKey('location_Warehouse', $location_columns);
$this->assertArrayHasKey('location_Store', $location_columns);
$this->assertEquals('Warehouse', $location_columns['location_Warehouse']);
$this->assertEquals('Store', $location_columns['location_Store']);
}
public function testValidateLocationNamesCaseSensitivity(): void
{
$allowed_locations = [1 => 'Warehouse', 2 => 'Store'];
$csv_location_name = 'warehouse';
$is_valid = in_array($csv_location_name, $allowed_locations);
$this->assertFalse($is_valid, 'Location names should be case-sensitive');
$csv_location_name = 'Warehouse';
$is_valid = in_array($csv_location_name, $allowed_locations);
$this->assertTrue($is_valid, 'Valid location name should pass validation');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Tests\Filters;
use App\Filters\ApiAuth;
use App\Models\ApiKey;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Response;
use Config\Services;
class ApiAuthTest extends CIUnitTestCase
{
protected ApiAuth $filter;
protected ApiKey $apiKeyModel;
protected int $testEmployeeId = 1;
protected function setUp(): void
{
parent::setUp();
$this->filter = new ApiAuth();
$this->apiKeyModel = model(ApiKey::class);
}
public function testBeforeWithNoApiKey(): void
{
$request = new IncomingRequest(Services::config(), Services::uri(), '');
$request->setHeader('X-API-Key', '');
$result = $this->filter->before($request);
$this->assertInstanceOf(Response::class, $result);
$this->assertEquals(401, $result->getStatusCode());
$body = json_decode($result->getBody(), true);
$this->assertFalse($body['success']);
$this->assertEquals('API key required', $body['message']);
}
public function testBeforeWithInvalidApiKey(): void
{
$request = new IncomingRequest(Services::config(), Services::uri(), '');
$request->setHeader('X-API-Key', 'ospos_invalidkey12345678901234567890123456789012345678');
$result = $this->filter->before($request);
$this->assertInstanceOf(Response::class, $result);
$this->assertEquals(401, $result->getStatusCode());
$body = json_decode($result->getBody(), true);
$this->assertFalse($body['success']);
$this->assertEquals('Invalid or expired API key', $body['message']);
}
public function testBeforeWithValidApiKey(): void
{
$employeeId = $this->testEmployeeId;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$request = new IncomingRequest(Services::config(), Services::uri(), '');
$request->setHeader('X-API-Key', $rawKey);
$result = $this->filter->before($request);
$this->assertInstanceOf(IncomingRequest::class, $result);
$this->assertEquals($employeeId, $result->employeeId);
}
public function testAfterReturnsResponse(): void
{
$request = new IncomingRequest(Services::config(), Services::uri(), '');
$response = new Response(Services::config());
$result = $this->filter->after($request, $response);
$this->assertInstanceOf(Response::class, $result);
}
public function testEmployeeIdSetInService(): void
{
$employeeId = $this->testEmployeeId;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$request = new IncomingRequest(Services::config(), Services::uri(), '');
$request->setHeader('X-API-Key', $rawKey);
$this->filter->before($request);
$this->assertEquals($employeeId, Services::get('apiEmployeeId'));
}
}

191
tests/Models/ApiKeyTest.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
namespace App\Tests\Models;
use App\Models\ApiKey;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
class ApiKeyTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $migrate = true;
protected $migrateOnly = ['api_keys'];
protected $refresh = true;
protected ApiKey $apiKeyModel;
protected function setUp(): void
{
parent::setUp();
$this->apiKeyModel = new ApiKey();
}
public function testGenerateKey(): void
{
$employeeId = 1;
$name = 'Test API Key';
$rawKey = $this->apiKeyModel->generateKey($employeeId, $name);
$this->assertNotFalse($rawKey);
$this->assertStringStartsWith('ospos_', $rawKey);
$this->assertEquals(70, strlen($rawKey)); // ospos_ prefix (6 chars) + 64 hex chars
$keyInDb = $this->apiKeyModel->where('employee_id', $employeeId)
->where('name', $name)
->first();
$this->assertNotNull($keyInDb);
$this->assertEquals(substr($rawKey, 0, 12), $keyInDb->key_prefix);
$this->assertEquals(hash('sha256', $rawKey), $keyInDb->key_hash);
}
public function testValidateKeySuccess(): void
{
$employeeId = 1;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$validatedEmployeeId = $this->apiKeyModel->validateKey($rawKey);
$this->assertEquals($employeeId, $validatedEmployeeId);
}
public function testValidateKeyInvalidFormat(): void
{
$result = $this->apiKeyModel->validateKey('invalid_key');
$this->assertFalse($result);
$result = $this->apiKeyModel->validateKey('ospos_short');
$this->assertFalse($result);
$result = $this->apiKeyModel->validateKey('otherprefix_' . str_repeat('a', 64));
$this->assertFalse($result);
}
public function testValidateKeyDisabled(): void
{
$employeeId = 1;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$this->apiKeyModel->update($keyRecord->api_key_id, ['disabled' => 1]);
$result = $this->apiKeyModel->validateKey($rawKey);
$this->assertFalse($result);
}
public function testValidateKeyExpired(): void
{
$employeeId = 1;
$expiresAt = date('Y-m-d H:i:s', strtotime('-1 day'));
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key', $expiresAt);
$result = $this->apiKeyModel->validateKey($rawKey);
$this->assertFalse($result);
}
public function testValidateKeyNotExpired(): void
{
$employeeId = 1;
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 day'));
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key', $expiresAt);
$result = $this->apiKeyModel->validateKey($rawKey);
$this->assertEquals($employeeId, $result);
}
public function testGetKeysForEmployee(): void
{
$employeeId = 1;
$this->apiKeyModel->generateKey($employeeId, 'Key 1');
$this->apiKeyModel->generateKey($employeeId, 'Key 2');
$this->apiKeyModel->generateKey($employeeId, 'Key 3');
$keys = $this->apiKeyModel->getKeysForEmployee($employeeId);
$this->assertCount(3, $keys);
}
public function testRevokeKey(): void
{
$employeeId = 1;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$result = $this->apiKeyModel->revokeKey($keyRecord->api_key_id, $employeeId);
$this->assertTrue($result);
$updatedKey = $this->apiKeyModel->find($keyRecord->api_key_id);
$this->assertEquals(1, $updatedKey->disabled);
$validateResult = $this->apiKeyModel->validateKey($rawKey);
$this->assertFalse($validateResult);
}
public function testRevokeKeyWrongEmployee(): void
{
$employeeId = 1;
$this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$result = $this->apiKeyModel->revokeKey($keyRecord->api_key_id, 999);
$updatedKey = $this->apiKeyModel->find($keyRecord->api_key_id);
$this->assertEquals(0, $updatedKey->disabled);
}
public function testRegenerateKey(): void
{
$employeeId = 1;
$oldKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$oldKeyId = $keyRecord->api_key_id;
$newKey = $this->apiKeyModel->regenerateKey($oldKeyId, $employeeId);
$this->assertNotFalse($newKey);
$this->assertNotEquals($oldKey, $newKey);
$oldKeyValid = $this->apiKeyModel->validateKey($oldKey);
$this->assertFalse($oldKeyValid);
$newKeyValid = $this->apiKeyModel->validateKey($newKey);
$this->assertEquals($employeeId, $newKeyValid);
$oldKeyExists = $this->apiKeyModel->find($oldKeyId);
$this->assertNull($oldKeyExists);
}
public function testKeyHashNotReversible(): void
{
$employeeId = 1;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$this->assertNotEquals($rawKey, $keyRecord->key_hash);
$this->assertEquals(64, strlen($keyRecord->key_hash));
}
public function testLastUsedUpdatesOnValidation(): void
{
$employeeId = 1;
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
$this->assertNull($keyRecord->last_used);
sleep(1);
$this->apiKeyModel->validateKey($rawKey);
$keyRecord = $this->apiKeyModel->find($keyRecord->api_key_id);
$this->assertNotNull($keyRecord->last_used);
}
}