Compare commits

..

1 Commits

Author SHA1 Message Date
jekkos
09191b9af7 feat: Add OpenAPI 3.1 specification for REST API
Add comprehensive OpenAPI specification for OSPOS REST API including:

- Customers API (CRUD operations)
- Suppliers API (CRUD operations)
- Items API (CRUD operations with inventory quantities)
- Inventory API (stock adjustments)
- Sales API (read-only queries)
- Receivings API (read-only queries)

Features:
- API Key authentication via X-API-Key header
- Pagination with offset/limit parameters
- Soft delete support for customers, suppliers, items
- Batch operations for delete and update
- Search/suggest endpoints for autocomplete
- Comprehensive schema definitions based on existing models

Includes documentation with:
- Endpoint reference tables
- Schema field descriptions
- Implementation notes and discussion topics
- HTTP status codes and response formats

This is a design proposal for discussion before implementation.
2026-03-06 10:27:11 +00:00
26 changed files with 2472 additions and 2850 deletions

295
API.md Normal file
View File

@@ -0,0 +1,295 @@
# 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,10 +12,18 @@ 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,
@@ -26,7 +34,6 @@ class Filters extends BaseFilters
'forcehttps' => ForceHTTPS::class,
'pagecache' => PageCache::class,
'performance' => PerformanceMetrics::class,
'apiauth' => ApiAuth::class,
];
/**
@@ -63,7 +70,7 @@ class Filters extends BaseFilters
public array $globals = [
'before' => [
'honeypot',
'csrf' => ['except' => ['login', 'api/*']],
'csrf' => ['except' => 'login'],
'invalidchars',
],
'after' => [

View File

@@ -39,50 +39,3 @@ $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

@@ -1,129 +0,0 @@
<?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

@@ -1,251 +0,0 @@
<?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

@@ -1,212 +0,0 @@
<?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

@@ -1,237 +0,0 @@
<?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

@@ -1,239 +0,0 @@
<?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

@@ -1,84 +0,0 @@
<?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,9 +942,7 @@ 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' => Sale_lib::isValidInvoiceType($this->request->getPost('invoice_type'))
? $this->request->getPost('invoice_type')
: 'invoice'
'invoice_type' => $this->request->getPost('invoice_type')
];
$success = $this->appconfig->batch_save($batch_save_data);

View File

@@ -1017,11 +1017,7 @@ class Items extends Secure_Controller
}
if (!$is_failed_row) {
$invalidLocations = $this->validateCSVStockLocations($row, $allowedStockLocations);
if (!empty($invalidLocations)) {
$isFailedRow = true;
log_message('error', 'CSV import: Invalid stock location(s) found: ' . implode(', ', $invalidLocations));
}
$is_failed_row = $this->data_error_check($row, $item_data, $allowed_stock_locations, $attribute_definition_names, $attribute_data);
}
// Remove false, null, '' and empty strings but keep 0
@@ -1067,30 +1063,6 @@ 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,11 +755,8 @@ class Sales extends Secure_Controller
$data['sale_status'] = COMPLETED;
$sale_type = SALE_TYPE_INVOICE;
$invoice_type = $this->config['invoice_type'];
if (!Sale_lib::isValidInvoiceType($invoice_type)) {
$invoice_type = 'invoice';
}
$invoice_view = $invoice_type;
// The PHP file name is the same as the invoice_type key
$invoice_view = $this->config['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);
@@ -1110,9 +1107,6 @@ 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,8 +267,6 @@ 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;
@@ -378,7 +376,7 @@ class Migration_Sales_Tax_Data extends Migration
$decimals = totals_decimals();
foreach ($sales_taxes as $row_number => $sales_tax) {
$sale_tax_amount = (float)$sales_tax['sale_tax_amount'];
$sale_tax_amount = $sales_tax['sale_tax_amount'];
$rounding_code = $sales_tax['rounding_code'];
$rounded_sale_tax_amount = $sale_tax_amount;

View File

@@ -243,8 +243,6 @@ 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;
@@ -356,7 +354,7 @@ class Migration_TaxAmount extends Migration
$decimals = totals_decimals();
foreach ($sales_taxes as $row_number => $sales_tax) {
$sale_tax_amount = (float)$sales_tax['sale_tax_amount'];
$sale_tax_amount = $sales_tax['sale_tax_amount'];
$rounding_code = $sales_tax['rounding_code'];
$rounded_sale_tax_amount = $sale_tax_amount;

View File

@@ -1,96 +0,0 @@
<?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);
}
}

View File

@@ -1,48 +0,0 @@
<?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

@@ -1,29 +0,0 @@
<?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,7 +26,6 @@ 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,13 +88,9 @@ class Sale_lib
return $register_modes;
}
private const ALLOWED_INVOICE_TYPES = [
'invoice',
'tax_invoice',
'custom_invoice',
'custom_tax_invoice'
];
/**
* @return array
*/
public function get_invoice_type_options(): array
{
$invoice_types = [];
@@ -105,11 +101,6 @@ 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
*/

View File

@@ -1,145 +0,0 @@
<?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.2",
"dompurify": "^3.3.0",
"elegant-circles": "github:opensourcepos/elegant-circles#minified",
"es6-promise": "^4.2.8",
"file-saver": "^2.0.5",
@@ -1483,13 +1483,10 @@
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
},
"node_modules/dompurify": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"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.2",
"dompurify": "^3.3.0",
"elegant-circles": "github:opensourcepos/elegant-circles#minified",
"es6-promise": "^4.2.8",
"file-saver": "^2.0.5",

2153
public/api/openapi.yaml Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,981 +0,0 @@
<?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

@@ -1,91 +0,0 @@
<?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'));
}
}

View File

@@ -1,191 +0,0 @@
<?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);
}
}