Compare commits

..

37 Commits

Author SHA1 Message Date
Ollama
80fdf2b7b5 fix: add autoload-dev for Tests namespace and fix UrlHelperTest namespace
- Add autoload-dev section to composer.json for Tests namespace
- Rename tests/helpers to tests/Helpers for PSR-4 compliance
- Add proper namespace to UrlHelperTest.php
- This fixes PHPUnit test discovery - previously only 6 tests were running
2026-04-01 21:18:52 +00:00
Ollama
6d02eb12c2 fix: remove duplicate phpunit.xml that prevented tests from running
The tests/phpunit.xml was incomplete - it only configured helpers and
Libraries testsuites, while phpunit.xml.dist at root contains all tests.
PHPUnit was likely using the incomplete config, resulting in empty test
results.
2026-03-27 07:54:37 +00:00
Ollama
0c3574423c refactor: optimize Docker image size
- Combine RUN commands to reduce layers
- Add --no-install-recommends and clean apt cache
- Use COPY --chown to set ownership during copy
- Update .dockerignore to exclude dev files and build configs

Saves ~260MB (21%) in image size
2026-03-24 08:00:56 +00:00
Ollama
6fea9c3abd refactor: remove build-database gulp task (#4447)
The build-database task previously concatenated tables.sql and constraints.sql
into database.sql. Since we now use initial_schema.sql directly in migrations,
this task is no longer needed.

- Remove gulp task 'build-database'
- Keep all other build tasks intact
2026-03-20 19:36:11 +00:00
Ollama
0547d3ba60 refactor: remove tables.sql and constraints.sql (#4447)
These files have been replaced by initial_schema.sql which is now the
authoritative source for the database schema. The initial migration
loads this schema on fresh installs.

- Remove app/Database/tables.sql
- Remove app/Database/constraints.sql
- Schema is frozen in app/Database/Migrations/sqlscripts/initial_schema.sql
2026-03-20 19:33:13 +00:00
Ollama
a18dde4bbe feat: migrate CI from Travis to GitHub Actions with enhancements
- Convert Travis CI configuration to GitHub Actions workflows
- Add multi-arch Docker builds (amd64/arm64)
- Implement initial schema migration for fresh database installs
- Add multi-attribute search with AND logic and sort by attribute columns
- Address various PR review feedback and formatting fixes
2026-03-19 21:34:12 +00:00
dependabot[bot]
e4b92b58c3 Bump jspdf from 4.2.0 to 4.2.1
Bumps [jspdf](https://github.com/parallax/jsPDF) from 4.2.0 to 4.2.1.
- [Release notes](https://github.com/parallax/jsPDF/releases)
- [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md)
- [Commits](https://github.com/parallax/jsPDF/compare/v4.2.0...v4.2.1)

---
updated-dependencies:
- dependency-name: jspdf
  dependency-version: 4.2.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-17 19:06:05 +00:00
Ollama
dc1e448bc3 Fix review comments: remove redundant loop and add XSS escaping
- Remove redundant property assignment loop in Expenses.php
- Add esc() to employee name values to prevent XSS vulnerabilities
2026-03-17 15:32:16 +00:00
Ollama
24b2825b31 Fix: Restrict employee selection in expenses and receivings forms
Users without the 'employees' permission can no longer impersonate other
employees when creating or editing expenses and receivings. The employee
field is now restricted to the current user for new records and shows the
stored employee for existing records.

Changes:
- Expenses controller: Add permission check in getView() and postSave()
- Receivings controller: Add permission check in getEdit() and postSave()
- Form views: Conditionally display dropdown or read-only field

Fixes #3616
2026-03-17 15:32:16 +00:00
Ollama
38d672592b Add seed data to tests for proper integration testing
- Add setUp() to seed test data: items, sales, sales_items, sales_items_taxes
- Add tearDown() to clean up seeded data after tests
- Remove skip conditions since we now have guaranteed test data
- Add testTaxDataIsGroupedByTaxNameAndPercent to verify grouping
- Use narrow date range to isolate seeded data
2026-03-16 18:36:31 +00:00
Ollama
6f7e06e986 Rewrite tests to use database integration testing
Tests now:
- Use DatabaseTestTrait for real database integration
- Actually call getData() and getSummaryData() methods
- Verify row totals (subtotal + tax = total) from real queries
- Verify summary data matches sum of rows
- Test getDataColumns() returns expected structure
- Use assertEqualsWithDelta for float comparisons with tolerance

These tests exercise the actual SQL queries and verify the
mathematical consistency of the calculations returned.
2026-03-16 18:36:31 +00:00
Ollama
fda40d9340 Fix rounding consistency and update tests per review feedback
- Ensure total = subtotal + tax by deriving total from rounded components
- Use assertEqualsWithDelta for float comparisons in tests
- Add defensive null coalescing in calculateSummary helper
- Add missing 'count' key to test data rows
- Add testRoundingAtBoundary test case
2026-03-16 18:36:31 +00:00
Ollama
b49186ec7c Add unit tests for Taxes Summary Report calculations
Tests verify:
- Row totals add up (subtotal + tax = total)
- Summary totals match sum of row values
- Tax-included and tax-not-included modes calculate correctly
- Rounding consistency across calculations
- Negative values (returns) are handled correctly
- Zero tax rows are handled correctly
2026-03-16 18:36:31 +00:00
Ollama
8b56f61b8a Fix Taxes Summary Report totals not matching row values
The report had calculation inconsistencies where:
1. Per-line totals (subtotal + tax) didn't equal the total column
2. Column totals didn't match the sum of individual rows

Root cause: subtotal, tax, and total were calculated independently
using different formulas and rounding at different stages, leading to
cumulative rounding errors.

Fix:
- Use item_tax_amount from database as the source of truth for tax
- Derive subtotal from sale_amount (handling both tax_included and
  tax_not_included modes correctly)
- Calculate total = subtotal + tax consistently for each line
- Override getSummaryData() to sum values from getData() rows,
  ensuring summary totals match the sum of displayed rows

Fixes #4112
2026-03-16 18:36:31 +00:00
Ollama
9820beb0e1 Fix: Add Debit Card filter to Daily Sales and Takings
Add 'only_debit' filter to Daily Sales and Takings dropdown. Reuses
existing 'Sales.debit' language string for the filter label. Includes
filter default initialization in getSearch() to prevent PHP warnings.

Fixes #4439
2026-03-16 18:06:00 +00:00
Ollama
e01dad728f Add AGENTS.md with coding guidelines for AI agents 2026-03-16 18:02:50 +00:00
Ollama
234f930079 Fix strftime directives handling and tighten test assertions
- Remove incorrect %C mapping (was mapping century to full year)
- Add special handling for %C (century), %c (datetime), %n (newline), %t (tab), %x (date)
- Add %h mapping (same as %b for abbreviated month)
- Tighten edge-case test assertions to use assertSame/assertMatchesRegularExpression
- Add tests for new directives: %C, %c, %n, %t, %x, %h
2026-03-14 23:08:39 +00:00
Ollama
3001dc0e17 Fix: Pass parameter to generate() and add composite format tests
- Fixed bug where render() was not passing caller-supplied  to
  generate(), causing ad-hoc tokens to be ignored
- Added %F (yyyy-MM-dd) and %D (MM/dd/yy) composite date formats to
  the IntlDateFormatter pattern map
- Added test coverage for composite date format directives (%F, %D, %T, %R)
2026-03-14 23:08:39 +00:00
Ollama
3ba207e8b9 Use CIUnitTestCase for consistency with other tests 2026-03-14 23:08:39 +00:00
Ollama
d684c49ebd Fix Token_lib::render() for PHP 8.4 compatibility
- Replaced deprecated strftime() with IntlDateFormatter
- Added proper handling for edge cases:
  - Strings with '%' not in date format (e.g., 'Discount: 50%')
  - Invalid date formats (e.g., '%-%-%', '%Y-%q-%bad')
  - Very long strings
- Added comprehensive unit tests for Token_lib
- All date format specifiers now mapped to IntlDateFormatter patterns
2026-03-14 23:08:39 +00:00
Ollama
071e641f95 Fix stored XSS via stock location name
Add esc() to stock_name output in sales/register.php and receivings/receiving.php

GHSA-vmm7-g33q-qqr2
2026-03-14 15:35:32 +00:00
Ollama
48af67bd00 Fix stored XSS in gcaptcha_site_key on login page 2026-03-14 15:35:16 +00:00
Ollama
7cb1d95da7 Fix: Host Header Injection vulnerability (GHSA-jchf-7hr6-h4f3)
Security: Prevent Host Header Injection attacks by validating HTTP_HOST
against a whitelist of allowed hostnames before constructing the baseURL.

Changes:
- Add getValidHost() method to validate HTTP_HOST against allowedHostnames
- If allowedHostnames is empty, log warning and fall back to 'localhost'
- If host not in whitelist, log warning and use first allowed hostname
- Update .env.example with allowedHostnames documentation
- Add security configuration section to INSTALL.md
- Add unit tests for host validation

This addresses the security advisory where the application constructed
baseURL from the attacker-controllable HTTP_HOST header, allowing:
- Login form phishing via manipulated form actions
- Cache poisoning via poisoned asset URLs

Fixes GHSA-jchf-7hr6-h4f3
2026-03-14 15:34:21 +00:00
jekkos
bafe3ddf1b Fix stored XSS vulnerability in Attribute Definitions (GHSA-rvfg-ww4r-rwqf) (#4429)
* Fix stored XSS vulnerability in Attribute Definitions

GHSA-rvfg-ww4r-rwqf: Stored XSS via Attribute Definition Name

Security Impact:
- Authenticated users with attribute management permission can inject XSS payloads
- Payloads execute when viewing/editing attributes in admin panel
- Can steal session cookies, perform CSRF attacks, or compromise admin operations

Root Cause:
1. Input: Attributes.php postSaveDefinition() accepts definition_name without sanitization
2. Output: Views echo definition_name without proper escaping

Fix Applied:
- Input sanitization: Added FILTER_SANITIZE_FULL_SPECIAL_CHARS to definition_name and definition_unit
- Output escaping: Added esc() wrapper when displaying definition_name in views
- Defense-in-depth: htmlspecialchars on attribute values saved to database

Files Changed:
- app/Controllers/Attributes.php - Sanitize inputs on save
- app/Views/attributes/form.php - Escape output on display
- app/Views/attributes/item.php - Escape output on display

* Remove input sanitization, keep output escaping only

Use escaping on output (esc() in views) as the sole XSS prevention
measure instead of sanitizing on input. This preserves the original
data in the database while still protecting against XSS attacks.

* Add validation for definition_fk foreign key in attribute definitions

Validate definition_group input before saving:
- Must be a positive integer (> 0)
- Must exist in attribute_definitions table
- Must be of type GROUP to ensure data integrity

Also add translation for definition_invalid_group error message
in all 45 language files (English placeholder for translations).

* Refactor definition_fk validation into single conditional statement

* Add esc() to attribute value outputs for XSS protection

- Add esc() to TEXT input value in item.php
- Add esc() to definition_unit in form.php

These fields display user-provided content and need output escaping
to prevent stored XSS attacks.

* Refactor definition_group validation into separate method

Extract validation logic for definition_fk into validateDefinitionGroup()
private method to improve code readability and reduce method complexity.

Returns:
- null if input is empty (no group selected)
- false if validation fails (invalid group)
- integer ID if valid

* Add translations for definition_invalid_group in all languages

- Added proper translations for 28 languages (de, es, fr, it, nl, pl, pt-BR, ru, tr, uk, th, zh-Hans, zh-Hant, ro, sv, vi, id, el, he, fa, hu, da, sw-KE, sw-TZ, ar-LB, ar-EG)
- Set empty string for 14 languages to fallback to English (cs, hr-HR, bg, bs, ckb, hy, km, lo, ml, nb, ta, tl, ur, az)

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-03-14 15:33:58 +00:00
jekkos
c482e75304 Fix DECIMAL attribute not respecting locale format (#4422)
* Fix DECIMAL attribute not respecting locale format

Issue: DECIMAL attribute values were displayed as raw database values
instead of being formatted according to the user's locale settings.

Fix:
1. Modified Attribute::get_definitions_by_flags() to optionally return
   definition types along with names (new $include_types parameter)
2. Updated expand_attribute_values() in tabular_helper.php to detect
   DECIMAL attributes and apply to_decimals() locale formatting
3. Updated callers (Reports, Items table) to pass include_types=true
   where attributes are displayed

The DECIMAL values in table views (items, sales reports, receiving reports)
now respect the configured locale number format, matching DATE attributes
which already use locale-based formatting.

* Apply PSR-12 camelCase naming to new variables

Response to PR review comments:
- Rename  to
- Rename  to
- Rename  to

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-03-13 21:23:52 +00:00
jekkos
afc2f82dc6 Fix PHPUnit environment variables not being set (#4434)
PHPUnit 10+/11+ requires force="true" attribute on <env> elements
to properly set environment variables. Without this attribute, the
database connection env vars were not being set during test bootstrap,
causing tests to fail silently with empty junit.xml output.

This fix adds force="true" to all <env> elements in phpunit.xml.dist.

Co-authored-by: Ollama <ollama@steganos.dev>
2026-03-13 18:54:29 +00:00
jekkos
ce411707b4 Fix SQL injection in suggestions column configuration (#4421)
* Fix SQL injection in suggestions column configuration

The suggestions_first_column, suggestions_second_column, and
suggestions_third_column configuration values were concatenated
directly into SQL SELECT statements without validation, allowing
SQL injection attacks through the item search suggestions.

Changes:
- Add whitelist validation in Config controller to only allow
  valid column names (name, item_number, description, cost_price,
  unit_price)
- Add defensive validation in Item model's get_search_suggestion_format()
  and get_search_suggestion_label() methods
- Default invalid values to 'name' column for safety
- Add unit tests to verify malicious inputs are rejected

This is a critical security fix as attackers with config permissions
could inject arbitrary SQL through these configuration fields.

Vulnerability reported as additional injection point in bug report.

* Refactor: Move allowed suggestions columns to Item model constants

Extract the list of valid suggestion columns into two constants in the Item model for better cohesion:
- ALLOWED_SUGGESTIONS_COLUMNS: valid column names
- ALLOWED_SUGGESTIONS_COLUMNS_WITH_EMPTY: includes empty string for config validation

This consolidates the validation logic in one place and makes it reusable across Config controller and Item model.

* Address PR review comments: improve validation and code quality

Changes:
- Use camelCase naming for validateSuggestionsColumn() method (PSR-12)
- Add field-aware validation with different fallbacks for first vs other columns
- Handle non-string POST input by checking is_string() before validation
- Refactor duplicate validation logic into suggestionColumnIsAllowed() helper
- Use consistent camelCase variable names ($suggestionsFirstColumn)
- Update tests to validate constants and behavior rather than implementation
- Tests now focus on security properties of the allowlist itself

The validation now properly handles:
- First column: defaults to 'name' when invalid
- Second/Third columns: defaults to '' (empty) when invalid
- Non-string inputs: treated as invalid with appropriate fallback

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-03-13 18:13:54 +00:00
jekkos
37c6e22fc4 Update SECURITY.md with published security advisories (#4431)
- Add Security Advisories section with 4 published CVEs
- Include CVE ID, vulnerability description, CVSS score, publication date, fixed version, and reporter credits
- Update supported versions table to reflect current state (>= 3.4.2)
- Add link to GitHub Security Advisories page for complete list

CVEs added:
- CVE-2025-68434: CSRF leading to Admin Creation (8.8)
- CVE-2025-68147: Stored XSS in Return Policy (8.1)
- CVE-2025-66924: Stored XSS in Item Kits (7.2)
- CVE-2025-68658: Stored XSS in Company Name (4.3)

Co-authored-by: Ollama <ollama@steganos.dev>
2026-03-13 17:53:32 +00:00
jekkos
3c7ece5c33 Fix permission bypass in Sales.getManage() access control (#4428)
The redirect() in getManage() returned a RedirectResponse that was never
executed, allowing unauthorized access to reports_sales. Updated method
signature to return ResponseInterface|string and properly return the
redirect response.

Refs: GHSA-94jm-c32g-48r5

Co-authored-by: Ollama <ollama@steganos.dev>
2026-03-13 17:52:07 +00:00
jekkos
02fccaf43f Fix XSS vulnerability in tax invoice view (#4432)
Co-authored-by: Ollama <ollama@steganos.dev>
2026-03-13 16:09:04 +00:00
jekkos
ee4d44ed39 Fix IDOR vulnerability in password change (GHSA-mcc2-8rp2-q6ch) (#4427)
* Fix IDOR vulnerability in password change (GHSA-mcc2-8rp2-q6ch)

The previous authorization check using can_modify_employee() was too
permissive - it allowed non-admin users to change other non-admin users'
passwords. For password changes, users should only be able to change
their own password. Only admins should be able to change any user's
password.

This fix replaces the can_modify_employee() check with a stricter
authorization that only allows:
- Users to change their own password
- Admins to change any user's password

Affected endpoints:
- GET /home/changePassword/{employee_id}
- POST /home/save/{employee_id}

Added tests to verify non-admin users cannot access or change other
non-admin users' passwords.

* Address PR review feedback

- Replace header/exit redirect with proper 403 response in getChangePassword
- Refactor createNonAdminEmployee helper to accept overrides array
- Simplify tests by reusing the helper
- Update tests to expect 403 response instead of redirect

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-03-13 12:13:21 +01:00
jekkos
fa3f257e7b Fix PHPUnit test configuration for database connectivity (#4430)
- Add database.tests.* environment variables to phpunit.xml.dist
- Set hostname to 127.0.0.1 to match CI MariaDB container
- Add MYSQL_* env vars for Database.php compatibility
- Tests were not running because database connection failed silently

Co-authored-by: Ollama <ollama@steganos.dev>
2026-03-13 10:38:37 +01:00
jekkos
431a9951e9 Fix filter persistence javascript issues (#4400) 2026-03-11 23:03:21 +01:00
jekkos
f7e8d6e427 Add filter persistence for table views via URL query string (#4400)
This commit adds URL-based filter persistence for table views, allowing
users to navigate away from a filtered view (e.g., clicking into sale
details) and return without losing their filter settings.

The solution uses history.replaceState() to update the URL without
triggering a page reload, providing a seamless user experience while
maintaining shareable/bookmarkable URLs.

Fixes navigation issue where filters are lost when viewing details or
navigating away from table views.

* Move filter restoration to server-side for cleaner architecture

Changes:
- Controllers now restore filters from URL query string on initial page load:
  * Sales.php: Reads start_date, end_date, and filters[] from GET
  * Items.php: Reads start_date, end_date, filters[], and stock_location
  * Expenses.php: Reads start_date, end_date, and filters[]
  * Cashups.php: Reads start_date, end_date, and filters[]

- Views now receive restored filter values from controllers:
  * Server-side date override via JavaScript variables
  * form_multiselect() receives $selected_filters from controller
  * Removed setTimeout hack from table_filter_persistence.php

- Simplified table_filter_persistence.php:
  * Now only handles URL updates on filter changes
  * No longer responsible for restoring state
  * Cleaner, single responsibility (client-side URL management)

Benefits:
- Works without JavaScript for initial render
- Cleaner architecture (server controls initial state)
- Client-side JS only handles "live" filter updates
- Filters persist across navigation via URL query string
- Shareable/bookmarkable URLs

How it works:
1. User visits /sales/manage?start_date=2024-01-01&filters[]=only_cash
2. Controller reads GET params and passes to view
3. View renders with correct initial filter values
4. User changes filter → JavaScript updates URL via replaceState()
5. User navigates away and back → Controller restores from URL again

* Refactor filter restoration into helper function and use PSR-12 naming

* Use array_merge with helper to reduce code duplication

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-03-11 20:11:00 +01:00
dependabot[bot]
85889b6e65 Bump jspdf from 4.1.0 to 4.2.0 (#4383)
Bumps [jspdf](https://github.com/parallax/jsPDF) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/parallax/jsPDF/releases)
- [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md)
- [Commits](https://github.com/parallax/jsPDF/compare/v4.1.0...v4.2.0)

---
updated-dependencies:
- dependency-name: jspdf
  dependency-version: 4.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-03-11 16:36:53 +04:00
Ollama
6818f02ef9 Update SECURITY.md with published security advisories
- Add Security Advisories section with 4 published CVEs
- Include CVE ID, vulnerability description, CVSS score, publication date, fixed version, and reporter credits
- Update supported versions table to reflect current state (>= 3.4.2)
- Add link to GitHub Security Advisories page for complete list

CVEs added:
- CVE-2025-68434: CSRF leading to Admin Creation (8.8)
- CVE-2025-68147: Stored XSS in Return Policy (8.1)
- CVE-2025-66924: Stored XSS in Item Kits (7.2)
- CVE-2025-68658: Stored XSS in Company Name (4.3)
2026-03-10 22:28:09 +01:00
Ollama
436696b11b Add workflow to auto-update issue templates with releases
Adds a GitHub Actions workflow that automatically updates the
OpensourcePOS Version dropdown in bug report and feature request
templates when new releases are published.

Fixes #4317
2026-03-10 22:26:49 +01:00
127 changed files with 2532 additions and 2058 deletions

View File

@@ -1,23 +1,56 @@
node_modules
tmp
# Version control
.git
.gitignore
# Sensitive config (user may mount their own)
app/Config/Email.php
# Build artifacts
node_modules/
dist/
tmp/
*.patch
patches/
# IDE and editor files
.idea/
git-svn-diff.py
*.bash
.vscode/
.swp
*.swp
.buildpath
.project
.settings/*
.git
dist/
node_modules/
*.swp
.settings/
# Development tools and configs
tests/
phpunit.xml
.php-cs-fixer.*
phpstan.neon
*.bash
git-svn-diff.py
# Documentation
*.md
!LICENSE
branding/
# Build configs (not needed at runtime)
composer.json
composer.lock
package.json
package-lock.json
gulpfile.js
.env.example
.dockerignore
# Temporary and backup files
*.rej
*.orig
*~
*.~
*.log
app/writable/session/*
!app/writable/session/index.html
# CI
.github/
.github/workflows/
build/

View File

@@ -4,6 +4,35 @@
CI_ENVIRONMENT = production
#--------------------------------------------------------------------
# SECURITY: ALLOWED HOSTNAMES
#--------------------------------------------------------------------
# IMPORTANT: Whitelist of allowed hostnames to prevent Host Header
# Injection attacks (GHSA-jchf-7hr6-h4f3).
#
# If not configured, the application will default to 'localhost',
# which may break functionality in production.
#
# Configure this with all domains/subdomains that host your application:
# - Primary domain
# - WWW subdomain (if used)
# - Any alternative domains
#
# Examples:
# Single domain:
# app.allowedHostnames.0 = 'example.com'
#
# Multiple domains:
# app.allowedHostnames.0 = 'example.com'
# app.allowedHostnames.1 = 'www.example.com'
# app.allowedHostnames.2 = 'demo.opensourcepos.org'
#
# For localhost development:
# app.allowedHostnames.0 = 'localhost'
#
# Note: Do not include the protocol (http/https) or port number.
#app.allowedHostnames.0 = ''
#--------------------------------------------------------------------
# DATABASE
#--------------------------------------------------------------------

61
.github/workflows/README.md vendored Normal file
View File

@@ -0,0 +1,61 @@
# GitHub Actions
This document describes the CI/CD workflows for OSPOS.
## Build and Release Workflow (`.github/workflows/build-release.yml`)
### Build Process
- Setup PHP 8.2 with required extensions
- Setup Node.js 20
- Install composer dependencies
- Install npm dependencies
- Build frontend assets with Gulp
### Docker Images
- Build and push `opensourcepos` Docker image for multiple architectures (linux/amd64, linux/arm64)
- On master: tagged with version and `latest`
- On other branches: tagged with version only
- Pushed to Docker Hub
### Releases
- Create distribution archives (tar.gz, zip)
- Create/update GitHub "unstable" release on master branch only
## Required Secrets
To use this workflow, you need to add the following secrets to your repository:
1. **DOCKER_USERNAME** - Docker Hub username for pushing images
2. **DOCKER_PASSWORD** - Docker Hub password/token for pushing images
### How to add secrets
1. Go to your repository on GitHub
2. Click **Settings****Secrets and variables****Actions**
3. Click **New repository secret**
4. Add `DOCKER_USERNAME` and `DOCKER_PASSWORD`
The `GITHUB_TOKEN` is automatically provided by GitHub Actions.
## Workflow Triggers
- **Push to master** - Runs build, Docker push (with `latest` tag), and release
- **Push to other branches** - Runs build and Docker push (version tag only)
- **Push tags** - Runs build and Docker push (version tag only)
- **Pull requests** - Runs build only (PHPUnit tests run in parallel via phpunit.yml)
## Existing Workflows
This repository also has these workflows:
- `.github/workflows/main.yml` - PHP linting with PHP-CS-Fixer
- `.github/workflows/phpunit.yml` - PHPUnit tests (runs on all PHP versions 8.1-8.4)
- `.github/workflows/php-linter.yml` - PHP linting
## Testing
PHPUnit tests are run separately via `.github/workflows/phpunit.yml` on every push and pull request, testing against PHP 8.1, 8.2, 8.3, and 8.4.
To test the build workflow:
1. Add the required secrets
2. Push to master or create a PR
3. Monitor the Actions tab in GitHub

218
.github/workflows/build-release.yml vendored Normal file
View File

@@ -0,0 +1,218 @@
name: Build and Release
on:
push:
branches:
- master
tags:
- '*'
pull_request:
branches:
- master
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
name: Build
runs-on: ubuntu-22.04
outputs:
version: ${{ steps.version.outputs.version }}
version-tag: ${{ steps.version.outputs.version-tag }}
short-sha: ${{ steps.version.outputs.short-sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: intl, mbstring, mysqli, gd, bcmath, zip
coverage: none
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Get composer cache directory
run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV
- name: Cache composer dependencies
uses: actions/cache@v4
with:
path: ${{ env.COMPOSER_CACHE_FILES_DIR }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Get npm cache directory
run: echo "NPM_CACHE_DIR=$(npm config get cache)" >> $GITHUB_ENV
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: ${{ env.NPM_CACHE_DIR }}
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install composer dependencies
run: composer install --no-dev --optimize-autoloader
- name: Install npm dependencies
run: npm ci
- name: Install gulp globally
run: npm install -g gulp-cli
- name: Get version info
id: version
run: |
VERSION=$(grep "application_version" app/Config/App.php | sed "s/.*= '\(.*\)';/\1/g")
BRANCH=$(echo "${GITHUB_REF#refs/heads/}" | sed 's/feature\///')
TAG=$(echo "${GITHUB_TAG:-$BRANCH}" | tr '/' '-')
SHORT_SHA=$(git rev-parse --short=6 HEAD)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version-tag=$VERSION-$BRANCH-$SHORT_SHA" >> $GITHUB_OUTPUT
echo "short-sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
env:
GITHUB_TAG: ${{ github.ref_name }}
- name: Create .env file
run: |
cp .env.example .env
sed -i 's/production/development/g' .env
- name: Update commit hash
run: |
SHORT_SHA="${{ steps.version.outputs.short-sha }}"
sed -i "s/commit_sha1 = 'dev'/commit_sha1 = '$SHORT_SHA'/g" app/Config/OSPOS.php
- name: Build frontend assets
run: npm run build
- name: Create distribution archives
run: |
set -euo pipefail
gulp compress
VERSION="${{ steps.version.outputs.version }}"
SHORT_SHA="${{ steps.version.outputs.short-sha }}"
mv dist/opensourcepos.tar "dist/opensourcepos.$VERSION.$SHORT_SHA.tar"
mv dist/opensourcepos.zip "dist/opensourcepos.$VERSION.$SHORT_SHA.zip"
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist-${{ steps.version.outputs.short-sha }}
path: dist/
retention-days: 7
- name: Upload build context for Docker
uses: actions/upload-artifact@v4
with:
name: build-context-${{ steps.version.outputs.short-sha }}
path: |
.
!.git
!node_modules
retention-days: 1
docker:
name: Build Docker Image
runs-on: ubuntu-22.04
needs: build
if: github.event_name == 'push'
steps:
- name: Download build context
uses: actions/download-artifact@v4
with:
name: build-context-${{ needs.build.outputs.short-sha }}
path: .
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Determine Docker tags
id: tags
run: |
BRANCH=$(echo "${GITHUB_REF#refs/heads/}" | tr '/' '-')
if [ "$BRANCH" = "master" ]; then
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }},${{ secrets.DOCKER_USERNAME }}/opensourcepos:latest" >> $GITHUB_OUTPUT
else
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }}" >> $GITHUB_OUTPUT
fi
env:
GITHUB_REF: ${{ github.ref }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
target: ospos
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.tags }}
release:
name: Create Release
needs: build
runs-on: ubuntu-22.04
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist-${{ needs.build.outputs.short-sha }}
path: dist/
- name: Get version info
id: version
run: |
VERSION="${{ needs.build.outputs.version }}"
SHORT_SHA=$(git rev-parse --short=6 HEAD)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "short-sha=$SHORT_SHA" >> $GITHUB_OUTPUT
- name: Create/Update unstable release
uses: softprops/action-gh-release@v2
with:
tag_name: unstable
name: Unstable OpenSourcePOS
body: |
This is a build of the latest master which might contain bugs. Use at your own risk.
Check the releases section for the latest official release.
files: |
dist/opensourcepos.${{ steps.version.outputs.version }}.${{ steps.version.outputs.short-sha }}.zip
prerelease: true
draft: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,71 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '21 12 * * 3'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@@ -69,9 +69,6 @@ jobs:
- name: Install npm dependencies
run: npm install
- name: Build database.sql
run: npm run gulp build-database
- name: Start MariaDB
run: |
docker run -d --name mysql \
@@ -79,7 +76,6 @@ jobs:
-e MYSQL_DATABASE=ospos \
-e MYSQL_USER=admin \
-e MYSQL_PASSWORD=pointofsale \
-v $PWD/app/Database/database.sql:/docker-entrypoint-initdb.d/database.sql \
-p 3306:3306 \
mariadb:10.5
# Wait for MariaDB to be ready

View File

@@ -0,0 +1,72 @@
name: Update Issue Templates
on:
release:
types: [published]
workflow_dispatch:
schedule:
- cron: '0 0 * * 0'
jobs:
update-templates:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch releases and update templates
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Fetch releases from GitHub API
RELEASES=$(gh api repos/${{ github.repository }}/releases --jq '.[].tag_name' | head -n 10)
# Create temporary file with options
OPTIONS_FILE=$(mktemp)
echo " - development (unreleased)" >> "$OPTIONS_FILE"
while IFS= read -r release; do
echo " - opensourcepos $release" >> "$OPTIONS_FILE"
done <<< "$RELEASES"
update_template() {
local template="$1"
local template_path=".github/ISSUE_TEMPLATE/$template"
# Find the line numbers for the OpensourcePOS Version dropdown
start_line=$(grep -n "label: OpensourcePOS Version" "$template_path" | cut -d: -f1)
if [ -z "$start_line" ]; then
echo "Could not find OpensourcePOS Version in $template"
return 1
fi
# Find the options section and default line
options_start=$((start_line + 3))
default_line=$(grep -n "default:" "$template_path" | awk -F: -v opts="$options_start" '$1 > opts {print $1; exit}')
# Create new template file
head -n $((options_start - 1)) "$template_path" > "${template_path}.new"
cat "$OPTIONS_FILE" >> "${template_path}.new"
tail -n +$default_line "$template_path" >> "${template_path}.new"
mv "${template_path}.new" "$template_path"
echo "Updated $template"
}
update_template "bug report.yml"
update_template "feature_request.yml"
- name: Commit and push changes
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .github/ISSUE_TEMPLATE/*.yml
if git diff --staged --quiet; then
echo "No changes to commit"
else
git commit -m "Update issue templates with latest releases [skip ci]"
git push
fi

View File

@@ -1,54 +0,0 @@
sudo: required
branches:
except:
- unstable
- weblate
services:
- docker
dist: jammy
language: node_js
node_js:
- 20
script:
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
- docker run --rm -u $(id -u) -v $(pwd):/app opensourcepos/composer:ci4 composer install
- version=$(grep application_version app/Config/App.php | sed "s/.*=\s'\(.*\)';/\1/g")
- cp .env.example .env && sed -i 's/production/development/g' .env
- sed -i "s/commit_sha1 = 'dev'/commit_sha1 = '$rev'/g" app/Config/OSPOS.php
- echo "$version-$branch-$rev"
- npm version "$version-$branch-$rev" --force || true
- sed -i 's/opensourcepos.tar.gz/opensourcepos.$version.tgz/g' package.json
- npm ci && npm install -g gulp && npm run build
- docker build . --target ospos -t ospos
- docker build . --target ospos_test -t ospos_test
- docker run --rm ospos_test /app/vendor/bin/phpunit --testdox
- docker build app/Database/ -t "jekkos/opensourcepos:sql-$TAG"
env:
global:
- BRANCH=$(echo ${TRAVIS_BRANCH} | sed s/feature\\///)
- TAG=$(echo "${TRAVIS_TAG:-$BRANCH}" | tr '/' '-')
- date=`date +%Y%m%d%H%M%S` && branch=${TRAVIS_BRANCH} && rev=`git rev-parse --short=6 HEAD`
after_success:
- docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" && docker tag "ospos:latest"
"jekkos/opensourcepos:$TAG" && docker push "jekkos/opensourcepos:$TAG" && docker push "jekkos/opensourcepos:sql-$TAG"
- gulp compress
- mv dist/opensourcepos.tar.gz "dist/opensourcepos.$version.$rev.tgz"
- mv dist/opensourcepos.zip "dist/opensourcepos.$version.$rev.zip"
deploy:
- provider: releases
edge: true
file: dist/opensourcepos.$version.$rev.zip
name: "Unstable OpensourcePos"
overwrite: true
release_notes: "This is a build of the latest master which might contain bugs. Use at your own risk. Check releases section for the latest official release"
prerelease: true
tag_name: unstable
user: jekkos
api_key:
secure: "KOukL8IFf/uL/BjMyCSKjf2vylydjcWqgEx0eMqFCg3nZ4ybMaOwPORRthIfyT72/FvGX/aoxxEn0uR/AEtb+hYQXHmNS+kZdX72JCe8LpGuZ7FJ5X+Eo9mhJcsmS+smd1sC95DySSc/GolKPo+0WtJYONY/xGCLxm+9Ay4HREg="
on:
branch: master

40
AGENTS.md Normal file
View File

@@ -0,0 +1,40 @@
# Agent Instructions
This document provides guidance for AI agents working on the Open Source Point of Sale (OSPOS) codebase.
## Code Style
- Follow PHP CodeIgniter 4 coding standards
- Run PHP-CS-Fixer before committing: `vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.no-header.php`
- Write PHP 8.1+ compatible code with proper type declarations
- Use PSR-12 naming conventions: `camelCase` for variables and functions, `PascalCase` for classes, `UPPER_CASE` for constants
## Development
- Create a new git worktree for each issue, based on the latest state of `origin/master`
- Commit fixes to the worktree and push to the remote
## Testing
- Run PHPUnit tests: `composer test`
- Tests must pass before submitting changes
## Build
- Install dependencies: `composer install && npm install`
- Build assets: `npm run build` or `gulp`
## Conventions
- Controllers go in `app/Controllers/`
- Models go in `app/Models/`
- Views go in `app/Views/`
- Database migrations in `app/Database/Migrations/`
- Use CodeIgniter 4 framework patterns and helpers
- Sanitize user input; escape output using `esc()` helper
## Security
- Never commit secrets, credentials, or `.env` files
- Use parameterized queries to prevent SQL injection
- Validate and sanitize all user input

View File

@@ -1,28 +1,22 @@
FROM php:8.2-apache AS ospos
LABEL maintainer="jekkos"
RUN apt update && apt-get install -y libicu-dev libgd-dev
RUN a2enmod rewrite
RUN docker-php-ext-install mysqli bcmath intl gd
RUN apt-get update && apt-get install -y --no-install-recommends \
libicu-dev \
libgd-dev \
&& docker-php-ext-install mysqli bcmath intl gd \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& a2enmod rewrite
RUN echo "date.timezone = \"\${PHP_TIMEZONE}\"" > /usr/local/etc/php/conf.d/timezone.ini
WORKDIR /app
COPY . /app
RUN ln -s /app/*[^public] /var/www && rm -rf /var/www/html && ln -nsf /app/public /var/www/html
RUN chmod -R 770 /app/writable/uploads /app/writable/logs /app/writable/cache && chown -R www-data:www-data /app
FROM ospos AS ospos_test
COPY --from=composer /usr/bin/composer /usr/bin/composer
RUN apt-get install -y libzip-dev wget git
RUN wget https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh -O /bin/wait-for-it.sh && chmod +x /bin/wait-for-it.sh
RUN docker-php-ext-install zip
RUN composer install -d/app
#RUN sed -i 's/backupGlobals="true"/backupGlobals="false"/g' /app/tests/phpunit.xml
WORKDIR /app/tests
CMD ["/app/vendor/phpunit/phpunit/phpunit", "/app/test/helpers"]
COPY --chown=www-data:www-data . /app
RUN chmod 770 /app/writable/uploads /app/writable/logs /app/writable/cache \
&& ln -s /app/*[^public] /var/www \
&& rm -rf /var/www/html \
&& ln -nsf /app/public /var/www/html
FROM ospos AS ospos_dev

3
Dockerfile.test Normal file
View File

@@ -0,0 +1,3 @@
FROM php:8.4-cli
RUN apt-get update && apt-get install -y libicu-dev && docker-php-ext-install intl
WORKDIR /app

View File

@@ -6,22 +6,53 @@
- Raspberry PI based installations proved to work, see [wiki page here](<https://github.com/opensourcepos/opensourcepos/wiki/Installing-on-Raspberry-PI---Orange-PI-(Headless-OSPOS)>).
- For Windows based installations please read [the wiki](https://github.com/opensourcepos/opensourcepos/wiki). There are closed issues about this subject, as this topic has been covered a lot.
## Security Configuration
### Allowed Hostnames (Required for Production)
OpenSourcePOS validates the Host header against a whitelist to prevent Host Header Injection attacks (GHSA-jchf-7hr6-h4f3). **You must configure this for production deployments.**
Add the following to your `.env` file:
```
app.allowedHostnames.0 = 'yourdomain.com'
app.allowedHostnames.1 = 'www.yourdomain.com'
```
**For local development**, use:
```
app.allowedHostnames.0 = 'localhost'
```
If `allowedHostnames` is not configured:
1. A security warning will be logged
2. The application will fall back to 'localhost' as the hostname
3. This means URLs generated by the application (links, redirects, etc.) will point to 'localhost'
### HTTPS Behind Proxy
If your installation is behind a proxy with SSL offloading, set:
```
FORCE_HTTPS = true
```
## Local install
First of all, if you're seeing the message `system folder missing` after launching your browser, or cannot find `database.sql`, that most likely means you have cloned the repository and have not built the project. To build the project from a source commit point instead of from an official release check out [Building OSPOS](BUILD.md). Otherwise, continue with the following steps.
First of all, if you're seeing the message `system folder missing` after launching your browser, that most likely means you have cloned the repository and have not built the project. To build the project from a source commit point instead of from an official release check out [Building OSPOS](BUILD.md). Otherwise, continue with the following steps.
1. Download the a [pre-release for a specific branch](https://github.com/opensourcepos/opensourcepos/releases) or the latest stable [from GitHub here](https://github.com/opensourcepos/opensourcepos/releases). A repository clone will not work unless know how to build the project.
2. Create/locate a new MySQL database to install Open Source Point of Sale into.
3. Execute the file `app/Database/database.sql` to create the tables needed.
4. Unzip and upload Open Source Point of Sale files to the web-server.
5. Open `.env` file and modify credentials to connect to your database if needed. (First copy .env.example to .env and update)
3. Unzip and upload Open Source Point of Sale files to the web-server.
4. If `.env` does not exist, copy `.env.example` to `.env`.
5. Open `.env` and modify credentials to connect to your database if needed.
6. The database schema will be automatically created when you first access the application. Migrations run automatically on fresh installs.
7. Go to your install `public` dir via the browser.
8. Log in using
- Username: admin
- Password: pointofsale
9. If everything works, then set the `CI_ENVIRONMENT` variable to `production` in the .env file
9. Enjoy!
10. Oops, an issue? Please make sure you read the FAQ, wiki page, and you checked open and closed issues on GitHub. PHP `display_errors` is disabled by default. Create an` app/Config/.env` file from the `.env.example` to enable it in a development environment.
10. Enjoy!
11. Oops, an issue? Please make sure you read the FAQ, wiki page, and you checked open and closed issues on GitHub. PHP `display_errors` is disabled by default. Create an` app/Config/.env` file from the `.env.example` to enable it in a development environment.
## Local install using Docker

View File

@@ -8,7 +8,7 @@
</p>
<p align="center">
<a href="https://app.travis-ci.com/opensourcepos/opensourcepos" target="_blank"><img src="https://api.travis-ci.com/opensourcepos/opensourcepos.svg?branch=master" alt="Build Status"></a>
<a href="https://github.com/opensourcepos/opensourcepos/actions/workflows/build-release.yml" target="_blank"><img src="https://github.com/opensourcepos/opensourcepos/actions/workflows/build-release.yml/badge.svg" alt="Build Status"></a>
<a href="https://app.gitter.im/#/room/#opensourcepos_Lobby:gitter.im?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge" target="_blank"><img src="https://badges.gitter.im/jekkos/opensourcepos.svg" alt="Join the chat at https://app.gitter.im"></a>
<a href="https://badge.fury.io/gh/opensourcepos%2Fopensourcepos" target="_blank"><img src="https://badge.fury.io/gh/opensourcepos%2Fopensourcepos.svg" alt="Project Version"></a>
<a href="https://translate.opensourcepos.org/engage/opensourcepos/?utm_source=widget" target="_blank"><img src="https://translate.opensourcepos.org/widgets/opensourcepos/-/svg-badge.svg" alt="Translation Status"></a>
@@ -137,7 +137,7 @@ Any person or company found breaching the license agreement might find a bunch o
## 🙏 Credits
| <div align="center">DigitalOcean</div> | <div align="center">JetBrains</div> | <div align="center">Travis CI</div> |
| <div align="center">DigitalOcean</div> | <div align="center">JetBrains</div> | <div align="center">GitHub</div> |
| --- | --- | --- |
| <div align="center"><a href="https://www.digitalocean.com?utm_medium=opensource&utm_source=opensourcepos" target="_blank"><img src="https://github.com/user-attachments/assets/fbbf7433-ed35-407d-8946-fd03d236d350" alt="DigitalOcean Logo" height="50"></a></div> | <div align="center"><a href="https://www.jetbrains.com/idea/" target="_blank"><img src="https://github.com/opensourcepos/opensourcepos/assets/12870258/187f9bbe-4484-475c-9b58-5e5d5f931f09" alt="IntelliJ IDEA Logo" height="50"></a></div> | <div align="center"><a href="https://www.travis-ci.com/" target="_blank"><img src="https://github.com/opensourcepos/opensourcepos/assets/12870258/71cc2b44-83af-4510-a543-6358285f43c6" alt="Travis CI Logo" height="50"></a></div> |
| Many thanks to [DigitalOcean](https://www.digitalocean.com) for providing the project with hosting credits. | Many thanks to [JetBrains](https://www.jetbrains.com/) for providing a free license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) to kindly support the development of OSPOS. | Many thanks to [Travis CI](https://www.travis-ci.com/) for providing a free continuous integration service for open source projects. |
| <div align="center"><a href="https://www.digitalocean.com?utm_medium=opensource&utm_source=opensourcepos" target="_blank"><img src="https://github.com/user-attachments/assets/fbbf7433-ed35-407d-8946-fd03d236d350" alt="DigitalOcean Logo" height="50"></a></div> | <div align="center"><a href="https://www.jetbrains.com/idea/" target="_blank"><img src="https://github.com/opensourcepos/opensourcepos/assets/12870258/187f9bbe-4484-475c-9b58-5e5d5f931f09" alt="IntelliJ IDEA Logo" height="50"></a></div> | <div align="center"><a href="https://github.com/features/actions" target="_blank"><img src="https://github.githubassets.com/images/modules/site/icons/eyebrow-panel/actions-icon.svg" alt="GitHub Actions Logo" height="50"></a></div> |
| Many thanks to [DigitalOcean](https://www.digitalocean.com) for providing the project with hosting credits. | Many thanks to [JetBrains](https://www.jetbrains.com/) for providing a free license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) to kindly support the development of OSPOS. | Many thanks to [GitHub](https://github.com) for providing free continuous integration via GitHub Actions for open-source projects. |

View File

@@ -1,9 +1,9 @@
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [Security Policy](#security-policy)
- [Supported Versions](#supported-versions)
- [Security Advisories](#security-advisories)
- [Reporting a Vulnerability](#reporting-a-vulnerability)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
@@ -12,14 +12,35 @@
## Supported Versions
We release patches for security vulnerabilities. Which versions are eligible to receive such patches depend on the CVSS v3.0 Rating:
We release patches for security vulnerabilities.
| CVSS v3.0 | Supported Versions |
| --------- | -------------------------------------------------- |
| 7.3 | 3.3.5 |
| 9.8 | 3.3.6 |
| 6.8 | 3.4.2 |
| Version | Supported |
| --------- | ------------------ |
| >= 3.4.2 | :white_check_mark: |
| < 3.4.2 | :x: |
## Security Advisories
The following security vulnerabilities have been published:
### High Severity
| CVE | Vulnerability | CVSS | Published | Fixed In | Credit |
|-----|--------------|------|-----------|----------|--------|
| [CVE-2025-68434](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-wjm4-hfwg-5w5r) | CSRF leading to Admin Creation | 8.8 | 2025-12-17 | 3.4.2 | @Nixon-H, @jekkos |
| [CVE-2025-68147](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-xgr7-7pvw-fpmh) | Stored XSS in Return Policy | 8.1 | 2025-12-17 | 3.4.2 | @Nixon-H, @jekkos |
| [CVE-2025-66924](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-gv8j-f6gq-g59m) | Stored XSS in Item Kits | 7.2 | 2026-03-04 | 3.4.2 | @hungnqdz, @omkaryepre |
### Medium Severity
| CVE | Vulnerability | CVSS | Published | Fixed In | Credit |
|-----|--------------|------|-----------|----------|--------|
| [CVE-2025-68658](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-32r8-8r9r-9chw) | Stored XSS in Company Name | 4.3 | 2026-01-13 | 3.4.2 | @hungnqdz |
For a complete list including draft advisories, see our [GitHub Security Advisories page](https://github.com/opensourcepos/opensourcepos/security/advisories).
## Reporting a Vulnerability
Please report (suspected) security vulnerabilities to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**. You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.
Please report (suspected) security vulnerabilities to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**.
You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.

View File

@@ -55,13 +55,21 @@ class App extends BaseConfig
public string $baseURL; // Defined in the constructor
/**
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
* If you want to accept multiple Hostnames, set this.
*
* E.g.,
* When your site URL ($baseURL) is 'http://example.com/', and your site
* also accepts 'http://media.example.com/' and 'http://accounts.example.com/':
* ['media.example.com', 'accounts.example.com']
* Allowed Hostnames for the Site URL.
*
* Security: This is used to validate the HTTP Host header to prevent
* Host Header Injection attacks. If the Host header doesn't match
* an entry in this list, the request will use the first allowed hostname.
*
* IMPORTANT: This MUST be configured for production deployments.
* If empty, the application will fall back to 'localhost'.
*
* Configure via .env file:
* app.allowedHostnames.0 = 'example.com'
* app.allowedHostnames.1 = 'www.example.com'
*
* For local development:
* app.allowedHostnames.0 = 'localhost'
*
* @var list<string>
*/
@@ -284,8 +292,44 @@ class App extends BaseConfig
{
parent::__construct();
$this->https_on = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') || (isset($_ENV['FORCE_HTTPS']) && $_ENV['FORCE_HTTPS'] == 'true');
$host = $this->getValidHost();
$this->baseURL = $this->https_on ? 'https' : 'http';
$this->baseURL .= '://' . ((isset($_SERVER['HTTP_HOST'])) ? $_SERVER['HTTP_HOST'] : 'localhost') . '/';
$this->baseURL .= '://' . $host . '/';
$this->baseURL .= str_replace(basename($_SERVER['SCRIPT_NAME']), '', $_SERVER['SCRIPT_NAME']);
}
/**
* Validates and returns a trusted hostname.
*
* Security: Prevents Host Header Injection attacks (GHSA-jchf-7hr6-h4f3)
* by validating the HTTP_HOST against a whitelist of allowed hostnames.
*
* @return string A validated hostname
*/
private function getValidHost(): string
{
$httpHost = $_SERVER['HTTP_HOST'] ?? 'localhost';
if (empty($this->allowedHostnames)) {
log_message('warning',
'Security: allowedHostnames is not configured. ' .
'Host header injection protection is disabled. ' .
'Please set app.allowedHostnames in your .env file. ' .
'Received Host: ' . $httpHost
);
return 'localhost';
}
if (in_array($httpHost, $this->allowedHostnames, true)) {
return $httpHost;
}
log_message('warning',
'Security: Rejected HTTP_HOST "' . $httpHost . '" - not in allowedHostnames whitelist. ' .
'Using fallback: ' . $this->allowedHostnames[0]
);
return $this->allowedHostnames[0];
}
}

View File

@@ -205,7 +205,6 @@ class Autoload extends AutoloadConfig
'cookie',
'tabular',
'locale',
'security',
'plugin'
'security'
];
}

View File

@@ -8,7 +8,23 @@ use CodeIgniter\HotReloader\HotReloader;
use App\Events\Db_log;
use App\Events\Load_config;
use App\Events\Method;
use App\Libraries\Plugins\PluginManager;
/*
* --------------------------------------------------------------------
* Application Events
* --------------------------------------------------------------------
* Events allow you to tap into the execution of the program without
* modifying or extending core files. This file provides a central
* location to define your events, though they can always be added
* at run-time, also, if needed.
*
* You create code that can execute by subscribing to events with
* the 'on()' method. This accepts any form of callable, including
* Closures, that will be executed when the event is triggered.
*
* Example:
* Events::on('create', [$myInstance, 'myMethod']);
*/
Events::on('pre_system', static function (): void {
if (ENVIRONMENT !== 'testing') {
@@ -23,19 +39,22 @@ Events::on('pre_system', static function (): void {
ob_start(static fn ($buffer) => $buffer);
}
/*
* --------------------------------------------------------------------
* Debug Toolbar Listeners.
* --------------------------------------------------------------------
* If you delete, they will no longer be collected.
*/
if (CI_DEBUG && ! is_cli()) {
Events::on('DBQuery', 'CodeIgniter\Debug\Toolbar\Collectors\Database::collect');
service('toolbar')->respond();
// Hot Reload route - for framework use on the hot reloader.
if (ENVIRONMENT === 'development') {
service('routes')->get('__hot-reload', static function (): void {
(new HotReloader())->run();
});
}
}
$pluginManager = new PluginManager();
$pluginManager->discoverPlugins();
$pluginManager->registerPluginEvents();
});
$config = new Load_config();
@@ -45,4 +64,4 @@ $db_log = new Db_log();
Events::on('DBQuery', [$db_log, 'db_log_queries']);
$method = new Method();
Events::on('pre_controller', [$method, 'validate_method']);
Events::on('pre_controller', [$method, 'validate_method']);

View File

@@ -106,12 +106,24 @@ class Attributes extends Secure_Controller
$definition_flags |= $flag;
}
// Validate definition_group (definition_fk) foreign key
$definition_group_input = $this->request->getPost('definition_group');
$definition_fk = $this->validateDefinitionGroup($definition_group_input);
if ($definition_fk === false) {
return $this->response->setJSON([
'success' => false,
'message' => lang('Attributes.definition_invalid_group'),
'id' => NEW_ENTRY
]);
}
// Save definition data
$definition_data = [
'definition_name' => $this->request->getPost('definition_name'),
'definition_unit' => $this->request->getPost('definition_unit') != '' ? $this->request->getPost('definition_unit') : null,
'definition_flags' => $definition_flags,
'definition_fk' => $this->request->getPost('definition_group') != '' ? $this->request->getPost('definition_group') : null
'definition_fk' => $definition_fk
];
if ($this->request->getPost('definition_type') != null) {
@@ -150,6 +162,32 @@ class Attributes extends Secure_Controller
}
}
/**
* Validates a definition_group foreign key.
* Returns the validated integer ID, null if empty, or false if invalid.
*
* @param mixed $definition_group_input
* @return int|null|false
*/
private function validateDefinitionGroup(mixed $definition_group_input): int|null|false
{
if ($definition_group_input === '' || $definition_group_input === null) {
return null;
}
$definition_group_id = (int) $definition_group_input;
// Must be a positive integer, exist in attribute_definitions, and be of type GROUP
if ($definition_group_id <= 0
|| !$this->attribute->exists($definition_group_id)
|| $this->attribute->getAttributeInfo($definition_group_id)->definition_type !== GROUP
) {
return false;
}
return $definition_group_id;
}
/**
*
* @param int $definition_id

View File

@@ -36,6 +36,9 @@ class Cashups extends Secure_Controller
// filters that will be loaded in the multiselect dropdown
$data['filters'] = ['is_deleted' => lang('Cashups.is_deleted')];
// Restore filters from URL
$data = array_merge($data, restoreTableFilters($this->request));
return view('cashups/manage', $data);
}

View File

@@ -11,6 +11,7 @@ use App\Models\Appconfig;
use App\Models\Attribute;
use App\Models\Customer_rewards;
use App\Models\Dinner_table;
use App\Models\Item;
use App\Models\Module;
use App\Models\Enums\Rounding_mode;
use App\Models\Stock_location;
@@ -385,9 +386,9 @@ class Config extends Secure_Controller
'gcaptcha_enable' => $this->request->getPost('gcaptcha_enable') != null,
'gcaptcha_secret_key' => $this->request->getPost('gcaptcha_secret_key'),
'gcaptcha_site_key' => $this->request->getPost('gcaptcha_site_key'),
'suggestions_first_column' => $this->request->getPost('suggestions_first_column'),
'suggestions_second_column' => $this->request->getPost('suggestions_second_column'),
'suggestions_third_column' => $this->request->getPost('suggestions_third_column'),
'suggestions_first_column' => $this->validateSuggestionsColumn($this->request->getPost('suggestions_first_column'), 'first'),
'suggestions_second_column' => $this->validateSuggestionsColumn($this->request->getPost('suggestions_second_column'), 'other'),
'suggestions_third_column' => $this->validateSuggestionsColumn($this->request->getPost('suggestions_third_column'), 'other'),
'giftcard_number' => $this->request->getPost('giftcard_number'),
'derive_sale_quantity' => $this->request->getPost('derive_sale_quantity') != null,
'multi_pack_enabled' => $this->request->getPost('multi_pack_enabled') != null,
@@ -976,4 +977,26 @@ class Config extends Secure_Controller
return $this->response->setJSON(['success' => $success]);
}
/**
* Validates suggestions column configuration to prevent SQL injection.
*
* @param mixed $column The column value from POST
* @param string $fieldType Either 'first' or 'other' to determine default fallback
* @return string Validated column name
*/
private function validateSuggestionsColumn(mixed $column, string $fieldType): string
{
if (!is_string($column)) {
return $fieldType === 'first' ? 'name' : '';
}
$allowed = $fieldType === 'first'
? Item::ALLOWED_SUGGESTIONS_COLUMNS
: Item::ALLOWED_SUGGESTIONS_COLUMNS_WITH_EMPTY;
$fallback = $fieldType === 'first' ? 'name' : '';
return in_array($column, $allowed, true) ? $column : $fallback;
}
}

View File

@@ -38,6 +38,9 @@ class Expenses extends Secure_Controller
'is_deleted' => lang('Expenses.is_deleted')
];
// Restore filters from URL
$data = array_merge($data, restoreTableFilters($this->request));
return view('expenses/manage', $data);
}
@@ -90,16 +93,23 @@ class Expenses extends Secure_Controller
{
$data = []; // TODO: Duplicated code
$data['employees'] = [];
foreach ($this->employee->get_all()->getResult() as $employee) {
foreach (get_object_vars($employee) as $property => $value) {
$employee->$property = $value;
}
$data['employees'][$employee->person_id] = $employee->first_name . ' ' . $employee->last_name;
}
$data['expenses_info'] = $this->expense->get_info($expense_id);
$expense_id = $data['expenses_info']->expense_id;
$current_employee_id = $this->employee->get_logged_in_employee_info()->person_id;
$can_assign_employee = $this->employee->has_grant('employees', $current_employee_id);
$data['employees'] = [];
if ($can_assign_employee) {
foreach ($this->employee->get_all()->getResult() as $employee) {
$data['employees'][$employee->person_id] = $employee->first_name . ' ' . $employee->last_name;
}
} else {
$stored_employee_id = $expense_id == NEW_ENTRY ? $current_employee_id : $data['expenses_info']->employee_id;
$stored_employee = $this->employee->get_info($stored_employee_id);
$data['employees'][$stored_employee_id] = $stored_employee->first_name . ' ' . $stored_employee->last_name;
}
$data['can_assign_employee'] = $can_assign_employee;
$expense_categories = [];
foreach ($this->expense_category->get_all(0, 0, true)->getResultArray() as $row) {
@@ -107,11 +117,9 @@ class Expenses extends Secure_Controller
}
$data['expense_categories'] = $expense_categories;
$expense_id = $data['expenses_info']->expense_id;
if ($expense_id == NEW_ENTRY) {
$data['expenses_info']->date = date('Y-m-d H:i:s');
$data['expenses_info']->employee_id = $this->employee->get_logged_in_employee_info()->person_id;
$data['expenses_info']->employee_id = $current_employee_id;
}
$data['payments'] = [];
@@ -152,6 +160,20 @@ class Expenses extends Secure_Controller
$date_formatter = date_create_from_format($config['dateformat'] . ' ' . $config['timeformat'], $newdate);
$current_employee_id = $this->employee->get_logged_in_employee_info()->person_id;
$submitted_employee_id = $this->request->getPost('employee_id', FILTER_SANITIZE_NUMBER_INT);
if (!$this->employee->has_grant('employees', $current_employee_id)) {
if ($expense_id == NEW_ENTRY) {
$employee_id = $current_employee_id;
} else {
$existing_expense = $this->expense->get_info($expense_id);
$employee_id = $existing_expense->employee_id;
}
} else {
$employee_id = $submitted_employee_id;
}
$expense_data = [
'date' => $date_formatter->format('Y-m-d H:i:s'),
'supplier_id' => $this->request->getPost('supplier_id') == '' ? null : $this->request->getPost('supplier_id', FILTER_SANITIZE_NUMBER_INT),
@@ -161,7 +183,7 @@ class Expenses extends Secure_Controller
'payment_type' => $this->request->getPost('payment_type', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
'expense_category_id' => $this->request->getPost('expense_category_id', FILTER_SANITIZE_NUMBER_INT),
'description' => $this->request->getPost('description', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
'employee_id' => $this->request->getPost('employee_id', FILTER_SANITIZE_NUMBER_INT),
'employee_id' => $employee_id,
'deleted' => $this->request->getPost('deleted') != null
];

View File

@@ -36,19 +36,18 @@ class Home extends Secure_Controller
/**
* Load "change employee password" form
*
* @return string
* @return ResponseInterface|string
* @noinspection PhpUnused
*/
public function getChangePassword(int $employeeId = NEW_ENTRY): string
public function getChangePassword(int $employeeId = NEW_ENTRY)
{
$loggedInEmployee = $this->employee->get_logged_in_employee_info();
$currentPersonId = $loggedInEmployee->person_id;
$employeeId = $employeeId === NEW_ENTRY ? $currentPersonId : $employeeId;
if (!$this->employee->can_modify_employee($employeeId, $currentPersonId)) {
header('Location: ' . base_url('no_access/home/home'));
exit();
if (!$this->employee->isAdmin($currentPersonId) && $employeeId !== $currentPersonId) {
return $this->response->setStatusCode(403)->setBody(lang('Employees.unauthorized_modify'));
}
$person_info = $this->employee->get_info($employeeId);
@@ -71,7 +70,7 @@ class Home extends Secure_Controller
$employeeId = $employeeId === NEW_ENTRY ? $currentUser->person_id : $employeeId;
if (!$this->employee->can_modify_employee($employeeId, $currentUser->person_id)) {
if (!$this->employee->isAdmin($currentUser->person_id) && $employeeId !== $currentUser->person_id) {
return $this->response->setStatusCode(403)->setJSON([
'success' => false,
'message' => lang('Employees.unauthorized_modify')

View File

@@ -73,7 +73,12 @@ class Items extends Secure_Controller
$this->session->set('allow_temp_items', 0);
$data['table_headers'] = get_items_manage_table_headers();
$data['stock_location'] = $this->item_lib->get_item_location();
// Restore stock_location from URL or session
$stockLocation = $this->request->getGet('stock_location', FILTER_SANITIZE_NUMBER_INT);
$data['stock_location'] = $stockLocation
? $stockLocation
: $this->item_lib->get_item_location();
$data['stock_locations'] = $this->stock_location->get_allowed_locations();
// Filters that will be loaded in the multiselect dropdown
@@ -87,6 +92,9 @@ class Items extends Secure_Controller
'temporary' => lang('Items.temp')
];
// Restore filters from URL
$data = array_merge($data, restoreTableFilters($this->request));
return view('items/manage', $data);
}

View File

@@ -1,99 +0,0 @@
<?php
namespace App\Controllers\Plugins;
use App\Controllers\Secure_Controller;
use App\Libraries\Plugins\PluginManager;
use CodeIgniter\HTTP\ResponseInterface;
class Manage extends Secure_Controller
{
private PluginManager $pluginManager;
public function __construct()
{
parent::__construct('plugins');
$this->pluginManager = new PluginManager();
$this->pluginManager->discoverPlugins();
}
public function getIndex(): string
{
$plugins = $this->pluginManager->getAllPlugins();
$enabledPlugins = $this->pluginManager->getEnabledPlugins();
$pluginData = [];
foreach ($plugins as $pluginId => $plugin) {
$pluginData[$pluginId] = [
'id' => $plugin->getPluginId(),
'name' => $plugin->getPluginName(),
'description' => $plugin->getPluginDescription(),
'version' => $plugin->getVersion(),
'enabled' => isset($enabledPlugins[$pluginId]),
'has_config' => $plugin->getConfigView() !== null,
];
}
echo view('plugins/manage', ['plugins' => $pluginData]);
return '';
}
public function postEnable(string $pluginId): ResponseInterface
{
if ($this->pluginManager->enablePlugin($pluginId)) {
return $this->response->setJSON(['success' => true, 'message' => lang('Plugins.plugin_enabled')]);
}
return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_enable_failed')]);
}
public function postDisable(string $pluginId): ResponseInterface
{
if ($this->pluginManager->disablePlugin($pluginId)) {
return $this->response->setJSON(['success' => true, 'message' => lang('Plugins.plugin_disabled')]);
}
return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_disable_failed')]);
}
public function postUninstall(string $pluginId): ResponseInterface
{
if ($this->pluginManager->uninstallPlugin($pluginId)) {
return $this->response->setJSON(['success' => true, 'message' => lang('Plugins.plugin_uninstalled')]);
}
return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_uninstall_failed')]);
}
public function getConfig(string $pluginId): ResponseInterface
{
$plugin = $this->pluginManager->getPlugin($pluginId);
if (!$plugin) {
return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_not_found')]);
}
$configView = $plugin->getConfigView();
if (!$configView) {
return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_no_config')]);
}
$settings = $plugin->getSettings();
echo view($configView, ['settings' => $settings, 'plugin' => $plugin]);
return $this->response;
}
public function postSaveConfig(string $pluginId): ResponseInterface
{
$plugin = $this->pluginManager->getPlugin($pluginId);
if (!$plugin) {
return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.plugin_not_found')]);
}
$settings = $this->request->getPost();
unset($settings['_method'], $settings['csrf_token_name']);
if ($plugin->saveSettings($settings)) {
return $this->response->setJSON(['success' => true, 'message' => lang('Plugins.settings_saved')]);
}
return $this->response->setJSON(['success' => false, 'message' => lang('Plugins.settings_save_failed')]);
}
}

View File

@@ -241,15 +241,26 @@ class Receivings extends Secure_Controller
$data['suppliers'][$supplier->person_id] = $supplier->first_name . ' ' . $supplier->last_name;
}
$receiving_info = $this->receiving->get_info($receiving_id)->getRowArray();
$current_employee_id = $this->employee->get_logged_in_employee_info()->person_id;
$can_assign_employee = $this->employee->has_grant('employees', $current_employee_id);
$data['employees'] = [];
foreach ($this->employee->get_all()->getResult() as $employee) {
$data['employees'][$employee->person_id] = $employee->first_name . ' ' . $employee->last_name;
if ($can_assign_employee) {
foreach ($this->employee->get_all()->getResult() as $employee) {
$data['employees'][$employee->person_id] = $employee->first_name . ' ' . $employee->last_name;
}
} else {
$stored_employee_id = $receiving_info['employee_id'];
$stored_employee = $this->employee->get_info($stored_employee_id);
$data['employees'][$stored_employee_id] = $stored_employee->first_name . ' ' . $stored_employee->last_name;
}
$receiving_info = $this->receiving->get_info($receiving_id)->getRowArray();
$data['selected_supplier_name'] = !empty($receiving_info['supplier_id']) ? $receiving_info['company_name'] : '';
$data['selected_supplier_id'] = $receiving_info['supplier_id'];
$data['receiving_info'] = $receiving_info;
$data['can_assign_employee'] = $can_assign_employee;
return view('receivings/form', $data);
}
@@ -491,10 +502,20 @@ class Receivings extends Secure_Controller
$date_formatter = date_create_from_format($this->config['dateformat'] . ' ' . $this->config['timeformat'], $newdate);
$receiving_time = $date_formatter->format('Y-m-d H:i:s');
$current_employee_id = $this->employee->get_logged_in_employee_info()->person_id;
$submitted_employee_id = $this->request->getPost('employee_id', FILTER_SANITIZE_NUMBER_INT);
if (!$this->employee->has_grant('employees', $current_employee_id)) {
$existing_receiving = $this->receiving->get_info($receiving_id)->getRowArray();
$employee_id = $existing_receiving['employee_id'];
} else {
$employee_id = $submitted_employee_id;
}
$receiving_data = [
'receiving_time' => $receiving_time,
'supplier_id' => $this->request->getPost('supplier_id') ? $this->request->getPost('supplier_id', FILTER_SANITIZE_NUMBER_INT) : null,
'employee_id' => $this->request->getPost('employee_id', FILTER_SANITIZE_NUMBER_INT),
'employee_id' => $employee_id,
'comment' => $this->request->getPost('comment', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
'reference' => $this->request->getPost('reference') != '' ? $this->request->getPost('reference', FILTER_SANITIZE_FULL_SPECIAL_CHARS) : null
];

View File

@@ -1776,7 +1776,7 @@ class Reports extends Secure_Controller
{
$this->clearCache();
$definition_names = $this->attribute->get_definitions_by_flags(attribute::SHOW_IN_SALES);
$definition_names = $this->attribute->get_definitions_by_flags(attribute::SHOW_IN_SALES, true);
$inputs = [
'start_date' => $start_date,
@@ -1789,7 +1789,12 @@ class Reports extends Secure_Controller
$this->detailed_sales->create($inputs);
$columns = $this->detailed_sales->getDataColumns();
$columns['details'] = array_merge($columns['details'], $definition_names);
// Extract just names for column headers
$definitionHeaders = [];
foreach ($definition_names as $definition_id => $definitionInfo) {
$definitionHeaders[$definition_id] = $definitionInfo['name'];
}
$columns['details'] = array_merge($columns['details'], $definitionHeaders);
$headers = $columns;
@@ -1930,14 +1935,19 @@ class Reports extends Secure_Controller
{
$this->clearCache();
$definition_names = $this->attribute->get_definitions_by_flags(attribute::SHOW_IN_RECEIVINGS);
$definition_names = $this->attribute->get_definitions_by_flags(attribute::SHOW_IN_RECEIVINGS, true);
$inputs = ['start_date' => $start_date, 'end_date' => $end_date, 'receiving_type' => $receiving_type, 'location_id' => $location_id, 'definition_ids' => array_keys($definition_names)];
$this->detailed_receivings->create($inputs);
$columns = $this->detailed_receivings->getDataColumns();
$columns['details'] = array_merge($columns['details'], $definition_names);
// Extract just names for column headers
$definitionHeaders = [];
foreach ($definition_names as $definition_id => $definitionInfo) {
$definitionHeaders[$definition_id] = $definitionInfo['name'];
}
$columns['details'] = array_merge($columns['details'], $definitionHeaders);
$headers = $columns;
$report_data = $this->detailed_receivings->getData($inputs);

View File

@@ -75,15 +75,15 @@ class Sales extends Secure_Controller
/**
* Load the sale edit modal. Used in app/Views/sales/register.php.
*
* @return string
* @return ResponseInterface|string
* @noinspection PhpUnused
*/
public function getManage(): string
public function getManage(): ResponseInterface|string
{
$person_id = $this->session->get('person_id');
$personId = $this->session->get('person_id');
if (!$this->employee->has_grant('reports_sales', $person_id)) {
redirect('no_access/sales/reports_sales');
if (!$this->employee->has_grant('reports_sales', $personId)) {
return redirect()->to('no_access/sales/reports_sales');
} else {
$data['table_headers'] = get_sales_manage_table_headers();
@@ -92,18 +92,31 @@ class Sales extends Secure_Controller
'only_due' => lang('Sales.due_filter'),
'only_check' => lang('Sales.check_filter'),
'only_creditcard' => lang('Sales.credit_filter'),
'only_debit' => lang('Sales.debit'),
'only_invoices' => lang('Sales.invoice_filter'),
'selected_customer' => lang('Sales.selected_customer')
];
if ($this->sale_lib->get_customer() != -1) {
$selected_filters = ['selected_customer'];
$selectedFilters = ['selected_customer'];
$data['customer_selected'] = true;
} else {
$data['customer_selected'] = false;
$selected_filters = [];
$selectedFilters = [];
}
$data['selected_filters'] = $selected_filters;
// Restore filters from URL query string
$filters = restoreTableFilters($this->request);
if (!empty($filters['selected_filters'])) {
$selectedFilters = array_merge($selectedFilters, $filters['selected_filters']);
}
if (isset($filters['start_date'])) {
$data['start_date'] = $filters['start_date'];
}
if (isset($filters['end_date'])) {
$data['end_date'] = $filters['end_date'];
}
$data['selected_filters'] = $selectedFilters;
return view('sales/manage', $data);
}
@@ -142,6 +155,7 @@ class Sales extends Secure_Controller
'only_check' => false,
'selected_customer' => false,
'only_creditcard' => false,
'only_debit' => false,
'only_invoices' => $this->config['invoice_enable'] && $this->request->getGet('only_invoices', FILTER_SANITIZE_NUMBER_INT),
'is_valid_receipt' => $this->sale->is_valid_receipt($search)
];

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class Migration_Initial_Schema extends Migration
{
public function __construct()
{
parent::__construct();
}
/**
* Perform a migration step.
* Only runs on fresh installs - skips if database already has tables.
*
* For testing: CI4's DatabaseTestTrait with $refresh=true handles table
* cleanup/creation automatically. This migration only loads initial schema
* on fresh databases where no application tables exist.
*/
public function up(): void
{
// Check if core application tables exist (existing install)
// Note: migrations table may exist even on fresh DB due to migration tracking
$tables = $this->db->listTables();
// Check for a core application table, not just migrations table
foreach ($tables as $table) {
// Strip prefix if present for comparison
$tableName = str_replace($this->db->getPrefix(), '', $table);
if (in_array($tableName, ['app_config', 'items', 'employees', 'people'])) {
// Database already populated - skip initial schema
// This is an existing installation upgrading from older version
return;
}
}
// Fresh install - load initial schema
helper('migration');
execute_script(APPPATH . 'Database/Migrations/sqlscripts/initial_schema.sql');
}
/**
* Revert a migration step.
* Cannot revert initial schema - would lose all data.
*/
public function down(): void
{
// Cannot safely revert initial schema
// Would require dropping all tables which would lose all data
$this->db->query('SET FOREIGN_KEY_CHECKS = 0');
foreach ($this->db->listTables() as $table) {
$this->db->query('DROP TABLE IF EXISTS `' . $table . '`');
}
$this->db->query('SET FOREIGN_KEY_CHECKS = 1');
}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class PluginConfigTableCreate extends Migration
{
public function up(): void
{
log_message('info', 'Migrating plugin_config table started');
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.1_PluginConfigTableCreate.sql');
}
public function down(): void
{
$this->forge->dropTable('plugin_config', true);
}
}

View File

@@ -1,7 +0,0 @@
CREATE TABLE IF NOT EXISTS `ospos_plugin_config` (
`key` varchar(100) NOT NULL,
`value` text NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -730,3 +730,148 @@ CREATE TABLE `ospos_suppliers` (
--
-- Dumping data for table `ospos_suppliers`
--
--
-- Constraints for dumped tables
--
--
-- Constraints for table `ospos_customers`
--
ALTER TABLE `ospos_customers`
ADD CONSTRAINT `ospos_customers_ibfk_1` FOREIGN KEY (`person_id`) REFERENCES `ospos_people` (`person_id`);
--
-- Constraints for table `ospos_employees`
--
ALTER TABLE `ospos_employees`
ADD CONSTRAINT `ospos_employees_ibfk_1` FOREIGN KEY (`person_id`) REFERENCES `ospos_people` (`person_id`);
--
-- Constraints for table `ospos_inventory`
--
ALTER TABLE `ospos_inventory`
ADD CONSTRAINT `ospos_inventory_ibfk_1` FOREIGN KEY (`trans_items`) REFERENCES `ospos_items` (`item_id`),
ADD CONSTRAINT `ospos_inventory_ibfk_2` FOREIGN KEY (`trans_user`) REFERENCES `ospos_employees` (`person_id`),
ADD CONSTRAINT `ospos_inventory_ibfk_3` FOREIGN KEY (`trans_location`) REFERENCES `ospos_stock_locations` (`location_id`);
--
-- Constraints for table `ospos_items`
--
ALTER TABLE `ospos_items`
ADD CONSTRAINT `ospos_items_ibfk_1` FOREIGN KEY (`supplier_id`) REFERENCES `ospos_suppliers` (`person_id`);
--
-- Constraints for table `ospos_items_taxes`
--
ALTER TABLE `ospos_items_taxes`
ADD CONSTRAINT `ospos_items_taxes_ibfk_1` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`) ON DELETE CASCADE;
--
-- Constraints for table `ospos_item_kit_items`
--
ALTER TABLE `ospos_item_kit_items`
ADD CONSTRAINT `ospos_item_kit_items_ibfk_1` FOREIGN KEY (`item_kit_id`) REFERENCES `ospos_item_kits` (`item_kit_id`) ON DELETE CASCADE,
ADD CONSTRAINT `ospos_item_kit_items_ibfk_2` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`) ON DELETE CASCADE;
--
-- Constraints for table `ospos_permissions`
--
ALTER TABLE `ospos_permissions`
ADD CONSTRAINT `ospos_permissions_ibfk_1` FOREIGN KEY (`module_id`) REFERENCES `ospos_modules` (`module_id`) ON DELETE CASCADE,
ADD CONSTRAINT `ospos_permissions_ibfk_2` FOREIGN KEY (`location_id`) REFERENCES `ospos_stock_locations` (`location_id`) ON DELETE CASCADE;
--
-- Constraints for table `ospos_grants`
--
ALTER TABLE `ospos_grants`
ADD CONSTRAINT `ospos_grants_ibfk_1` foreign key (`permission_id`) references `ospos_permissions` (`permission_id`) ON DELETE CASCADE,
ADD CONSTRAINT `ospos_grants_ibfk_2` foreign key (`person_id`) references `ospos_employees` (`person_id`) ON DELETE CASCADE;
--
-- Constraints for table `ospos_receivings`
--
ALTER TABLE `ospos_receivings`
ADD CONSTRAINT `ospos_receivings_ibfk_1` FOREIGN KEY (`employee_id`) REFERENCES `ospos_employees` (`person_id`),
ADD CONSTRAINT `ospos_receivings_ibfk_2` FOREIGN KEY (`supplier_id`) REFERENCES `ospos_suppliers` (`person_id`);
--
-- Constraints for table `ospos_receivings_items`
--
ALTER TABLE `ospos_receivings_items`
ADD CONSTRAINT `ospos_receivings_items_ibfk_1` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`),
ADD CONSTRAINT `ospos_receivings_items_ibfk_2` FOREIGN KEY (`receiving_id`) REFERENCES `ospos_receivings` (`receiving_id`);
--
-- Constraints for table `ospos_sales`
--
ALTER TABLE `ospos_sales`
ADD CONSTRAINT `ospos_sales_ibfk_1` FOREIGN KEY (`employee_id`) REFERENCES `ospos_employees` (`person_id`),
ADD CONSTRAINT `ospos_sales_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `ospos_customers` (`person_id`);
--
-- Constraints for table `ospos_sales_items`
--
ALTER TABLE `ospos_sales_items`
ADD CONSTRAINT `ospos_sales_items_ibfk_1` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`),
ADD CONSTRAINT `ospos_sales_items_ibfk_2` FOREIGN KEY (`sale_id`) REFERENCES `ospos_sales` (`sale_id`),
ADD CONSTRAINT `ospos_sales_items_ibfk_3` FOREIGN KEY (`item_location`) REFERENCES `ospos_stock_locations` (`location_id`);
--
-- Constraints for table `ospos_sales_items_taxes`
--
ALTER TABLE `ospos_sales_items_taxes`
ADD CONSTRAINT `ospos_sales_items_taxes_ibfk_1` FOREIGN KEY (`sale_id`,`item_id`,`line`) REFERENCES `ospos_sales_items` (`sale_id`,`item_id`,`line`),
ADD CONSTRAINT `ospos_sales_items_taxes_ibfk_2` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`);
--
-- Constraints for table `ospos_sales_payments`
--
ALTER TABLE `ospos_sales_payments`
ADD CONSTRAINT `ospos_sales_payments_ibfk_1` FOREIGN KEY (`sale_id`) REFERENCES `ospos_sales` (`sale_id`);
--
-- Constraints for table `ospos_sales_suspended`
--
ALTER TABLE `ospos_sales_suspended`
ADD CONSTRAINT `ospos_sales_suspended_ibfk_1` FOREIGN KEY (`employee_id`) REFERENCES `ospos_employees` (`person_id`),
ADD CONSTRAINT `ospos_sales_suspended_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `ospos_customers` (`person_id`);
--
-- Constraints for table `ospos_sales_suspended_items`
--
ALTER TABLE `ospos_sales_suspended_items`
ADD CONSTRAINT `ospos_sales_suspended_items_ibfk_1` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`),
ADD CONSTRAINT `ospos_sales_suspended_items_ibfk_2` FOREIGN KEY (`sale_id`) REFERENCES `ospos_sales_suspended` (`sale_id`),
ADD CONSTRAINT `ospos_sales_suspended_items_ibfk_3` FOREIGN KEY (`item_location`) REFERENCES `ospos_stock_locations` (`location_id`);
--
-- Constraints for table `ospos_sales_suspended_items_taxes`
--
ALTER TABLE `ospos_sales_suspended_items_taxes`
ADD CONSTRAINT `ospos_sales_suspended_items_taxes_ibfk_1` FOREIGN KEY (`sale_id`,`item_id`,`line`) REFERENCES `ospos_sales_suspended_items` (`sale_id`,`item_id`,`line`),
ADD CONSTRAINT `ospos_sales_suspended_items_taxes_ibfk_2` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`);
--
-- Constraints for table `ospos_sales_suspended_payments`
--
ALTER TABLE `ospos_sales_suspended_payments`
ADD CONSTRAINT `ospos_sales_suspended_payments_ibfk_1` FOREIGN KEY (`sale_id`) REFERENCES `ospos_sales_suspended` (`sale_id`);
--
-- Constraints for table `ospos_item_quantities`
--
ALTER TABLE `ospos_item_quantities`
ADD CONSTRAINT `ospos_item_quantities_ibfk_1` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`),
ADD CONSTRAINT `ospos_item_quantities_ibfk_2` FOREIGN KEY (`location_id`) REFERENCES `ospos_stock_locations` (`location_id`);
--
-- Constraints for table `ospos_suppliers`
--
ALTER TABLE `ospos_suppliers`
ADD CONSTRAINT `ospos_suppliers_ibfk_1` FOREIGN KEY (`person_id`) REFERENCES `ospos_people` (`person_id`);
--
-- Constraints for table `ospos_giftcards`
--
ALTER TABLE `ospos_giftcards`
ADD CONSTRAINT `ospos_giftcards_ibfk_1` FOREIGN KEY (`person_id`) REFERENCES `ospos_people` (`person_id`);

View File

@@ -1,145 +0,0 @@
--
-- Constraints for dumped tables
--
--
-- Constraints for table `ospos_customers`
--
ALTER TABLE `ospos_customers`
ADD CONSTRAINT `ospos_customers_ibfk_1` FOREIGN KEY (`person_id`) REFERENCES `ospos_people` (`person_id`);
--
-- Constraints for table `ospos_employees`
--
ALTER TABLE `ospos_employees`
ADD CONSTRAINT `ospos_employees_ibfk_1` FOREIGN KEY (`person_id`) REFERENCES `ospos_people` (`person_id`);
--
-- Constraints for table `ospos_inventory`
--
ALTER TABLE `ospos_inventory`
ADD CONSTRAINT `ospos_inventory_ibfk_1` FOREIGN KEY (`trans_items`) REFERENCES `ospos_items` (`item_id`),
ADD CONSTRAINT `ospos_inventory_ibfk_2` FOREIGN KEY (`trans_user`) REFERENCES `ospos_employees` (`person_id`),
ADD CONSTRAINT `ospos_inventory_ibfk_3` FOREIGN KEY (`trans_location`) REFERENCES `ospos_stock_locations` (`location_id`);
--
-- Constraints for table `ospos_items`
--
ALTER TABLE `ospos_items`
ADD CONSTRAINT `ospos_items_ibfk_1` FOREIGN KEY (`supplier_id`) REFERENCES `ospos_suppliers` (`person_id`);
--
-- Constraints for table `ospos_items_taxes`
--
ALTER TABLE `ospos_items_taxes`
ADD CONSTRAINT `ospos_items_taxes_ibfk_1` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`) ON DELETE CASCADE;
--
-- Constraints for table `ospos_item_kit_items`
--
ALTER TABLE `ospos_item_kit_items`
ADD CONSTRAINT `ospos_item_kit_items_ibfk_1` FOREIGN KEY (`item_kit_id`) REFERENCES `ospos_item_kits` (`item_kit_id`) ON DELETE CASCADE,
ADD CONSTRAINT `ospos_item_kit_items_ibfk_2` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`) ON DELETE CASCADE;
--
-- Constraints for table `ospos_permissions`
--
ALTER TABLE `ospos_permissions`
ADD CONSTRAINT `ospos_permissions_ibfk_1` FOREIGN KEY (`module_id`) REFERENCES `ospos_modules` (`module_id`) ON DELETE CASCADE,
ADD CONSTRAINT `ospos_permissions_ibfk_2` FOREIGN KEY (`location_id`) REFERENCES `ospos_stock_locations` (`location_id`) ON DELETE CASCADE;
--
-- Constraints for table `ospos_grants`
--
ALTER TABLE `ospos_grants`
ADD CONSTRAINT `ospos_grants_ibfk_1` foreign key (`permission_id`) references `ospos_permissions` (`permission_id`) ON DELETE CASCADE,
ADD CONSTRAINT `ospos_grants_ibfk_2` foreign key (`person_id`) references `ospos_employees` (`person_id`) ON DELETE CASCADE;
--
-- Constraints for table `ospos_receivings`
--
ALTER TABLE `ospos_receivings`
ADD CONSTRAINT `ospos_receivings_ibfk_1` FOREIGN KEY (`employee_id`) REFERENCES `ospos_employees` (`person_id`),
ADD CONSTRAINT `ospos_receivings_ibfk_2` FOREIGN KEY (`supplier_id`) REFERENCES `ospos_suppliers` (`person_id`);
--
-- Constraints for table `ospos_receivings_items`
--
ALTER TABLE `ospos_receivings_items`
ADD CONSTRAINT `ospos_receivings_items_ibfk_1` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`),
ADD CONSTRAINT `ospos_receivings_items_ibfk_2` FOREIGN KEY (`receiving_id`) REFERENCES `ospos_receivings` (`receiving_id`);
--
-- Constraints for table `ospos_sales`
--
ALTER TABLE `ospos_sales`
ADD CONSTRAINT `ospos_sales_ibfk_1` FOREIGN KEY (`employee_id`) REFERENCES `ospos_employees` (`person_id`),
ADD CONSTRAINT `ospos_sales_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `ospos_customers` (`person_id`);
--
-- Constraints for table `ospos_sales_items`
--
ALTER TABLE `ospos_sales_items`
ADD CONSTRAINT `ospos_sales_items_ibfk_1` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`),
ADD CONSTRAINT `ospos_sales_items_ibfk_2` FOREIGN KEY (`sale_id`) REFERENCES `ospos_sales` (`sale_id`),
ADD CONSTRAINT `ospos_sales_items_ibfk_3` FOREIGN KEY (`item_location`) REFERENCES `ospos_stock_locations` (`location_id`);
--
-- Constraints for table `ospos_sales_items_taxes`
--
ALTER TABLE `ospos_sales_items_taxes`
ADD CONSTRAINT `ospos_sales_items_taxes_ibfk_1` FOREIGN KEY (`sale_id`,`item_id`,`line`) REFERENCES `ospos_sales_items` (`sale_id`,`item_id`,`line`),
ADD CONSTRAINT `ospos_sales_items_taxes_ibfk_2` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`);
--
-- Constraints for table `ospos_sales_payments`
--
ALTER TABLE `ospos_sales_payments`
ADD CONSTRAINT `ospos_sales_payments_ibfk_1` FOREIGN KEY (`sale_id`) REFERENCES `ospos_sales` (`sale_id`);
--
-- Constraints for table `ospos_sales_suspended`
--
ALTER TABLE `ospos_sales_suspended`
ADD CONSTRAINT `ospos_sales_suspended_ibfk_1` FOREIGN KEY (`employee_id`) REFERENCES `ospos_employees` (`person_id`),
ADD CONSTRAINT `ospos_sales_suspended_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `ospos_customers` (`person_id`);
--
-- Constraints for table `ospos_sales_suspended_items`
--
ALTER TABLE `ospos_sales_suspended_items`
ADD CONSTRAINT `ospos_sales_suspended_items_ibfk_1` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`),
ADD CONSTRAINT `ospos_sales_suspended_items_ibfk_2` FOREIGN KEY (`sale_id`) REFERENCES `ospos_sales_suspended` (`sale_id`),
ADD CONSTRAINT `ospos_sales_suspended_items_ibfk_3` FOREIGN KEY (`item_location`) REFERENCES `ospos_stock_locations` (`location_id`);
--
-- Constraints for table `ospos_sales_suspended_items_taxes`
--
ALTER TABLE `ospos_sales_suspended_items_taxes`
ADD CONSTRAINT `ospos_sales_suspended_items_taxes_ibfk_1` FOREIGN KEY (`sale_id`,`item_id`,`line`) REFERENCES `ospos_sales_suspended_items` (`sale_id`,`item_id`,`line`),
ADD CONSTRAINT `ospos_sales_suspended_items_taxes_ibfk_2` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`);
--
-- Constraints for table `ospos_sales_suspended_payments`
--
ALTER TABLE `ospos_sales_suspended_payments`
ADD CONSTRAINT `ospos_sales_suspended_payments_ibfk_1` FOREIGN KEY (`sale_id`) REFERENCES `ospos_sales_suspended` (`sale_id`);
--
-- Constraints for table `ospos_item_quantities`
--
ALTER TABLE `ospos_item_quantities`
ADD CONSTRAINT `ospos_item_quantities_ibfk_1` FOREIGN KEY (`item_id`) REFERENCES `ospos_items` (`item_id`),
ADD CONSTRAINT `ospos_item_quantities_ibfk_2` FOREIGN KEY (`location_id`) REFERENCES `ospos_stock_locations` (`location_id`);
--
-- Constraints for table `ospos_suppliers`
--
ALTER TABLE `ospos_suppliers`
ADD CONSTRAINT `ospos_suppliers_ibfk_1` FOREIGN KEY (`person_id`) REFERENCES `ospos_people` (`person_id`);
--
-- Constraints for table `ospos_giftcards`
--
ALTER TABLE `ospos_giftcards`
ADD CONSTRAINT `ospos_giftcards_ibfk_1` FOREIGN KEY (`person_id`) REFERENCES `ospos_people` (`person_id`);

View File

@@ -1,24 +0,0 @@
<?php
use CodeIgniter\Events\Events;
if (!function_exists('plugin_content')) {
function plugin_content(string $section, array $data = []): string
{
$results = Events::trigger("view:{$section}", $data);
if (is_array($results)) {
return implode('', array_filter($results, fn($r) => is_string($r)));
}
return is_string($results) ? $results : '';
}
}
if (!function_exists('plugin_content_exists')) {
function plugin_content_exists(string $section): bool
{
$observers = Events::listRegistered("view:{$section}");
return !empty($observers);
}
}

View File

@@ -408,7 +408,7 @@ function get_items_manage_table_headers(): string
{
$attribute = model(Attribute::class);
$config = config(OSPOS::class)->settings;
$definition_names = $attribute->get_definitions_by_flags($attribute::SHOW_IN_ITEMS); // TODO: this should be made into a constant in constants.php
$definitionsWithTypes = $attribute->get_definitions_by_flags($attribute::SHOW_IN_ITEMS, true);
$headers = item_headers();
@@ -420,8 +420,8 @@ function get_items_manage_table_headers(): string
$headers[] = ['item_pic' => lang('Items.image'), 'sortable' => false];
foreach ($definition_names as $definition_id => $definition_name) {
$headers[] = [$definition_id => $definition_name, 'sortable' => false];
foreach ($definitionsWithTypes as $definition_id => $definitionInfo) {
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => false];
}
$headers[] = ['inventory' => '', 'escape' => false];
@@ -479,7 +479,7 @@ function get_item_data_row(object $item): array
$item->name .= NAME_SEPARATOR . $item->pack_name;
}
$definition_names = $attribute->get_definitions_by_flags($attribute::SHOW_IN_ITEMS);
$definition_names = $attribute->get_definitions_by_flags($attribute::SHOW_IN_ITEMS, true);
$columns = [
'items.item_id' => $item->item_id,
@@ -634,7 +634,7 @@ function parse_attribute_values(array $columns, array $row): array
}
/**
* @param array $definition_names
* @param array $definition_names Array of definition_id => ['name' => name, 'type' => type] or definition_id => name
* @param array $row
* @return array
*/
@@ -651,10 +651,16 @@ function expand_attribute_values(array $definition_names, array $row): array
}
$attribute_values = [];
foreach ($definition_names as $definition_id => $definition_name) {
foreach ($definition_names as $definition_id => $definitionInfo) {
if (isset($indexed_values[$definition_id])) {
$attribute_value = $indexed_values[$definition_id];
$attribute_values["$definition_id"] = $attribute_value;
$raw_value = $indexed_values[$definition_id];
// Format DECIMAL attributes according to locale
if (is_array($definitionInfo) && isset($definitionInfo['type']) && $definitionInfo['type'] === DECIMAL) {
$attribute_values["$definition_id"] = to_decimals($raw_value);
} else {
$attribute_values["$definition_id"] = $raw_value;
}
} else {
$attribute_values["$definition_id"] = "";
}
@@ -925,3 +931,24 @@ function get_controller(): string
$controller_name_parts = explode('\\', $controller_name);
return end($controller_name_parts);
}
/**
* Restores filter values from URL query string.
*
* @param CodeIgniter\HTTP\IncomingRequest $request The request object
* @return array Array with 'start_date', 'end_date', and 'selected_filters' keys
*/
function restoreTableFilters($request): array
{
$startDate = $request->getGet('start_date', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$endDate = $request->getGet('end_date', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$urlFilters = $request->getGet('filters', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
return array_filter([
'start_date' => $startDate ?: null,
'end_date' => $endDate ?: null,
'selected_filters' => $urlFilters ?? []
], function($value) {
return $value !== null && $value !== [];
});
}

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "هل أنت متأكد من أنك تريد حذف الميزات المحددة ؟",
"confirm_restore" => "هل أنت متأكد من أنك تريد استعادة السمة (السمات) المحددة؟",
"definition_cannot_be_deleted" => "لا يمكن حذف السمات المحددة",
"definition_invalid_group" => "المجموعة المحددة غير موجودة أو غير صالحة.",
"definition_error_adding_updating" => "لا يمكن إضافة السمة {0} أو تحديثها. يرجى التحقق من سجل الخطأ.",
"definition_flags" => "رؤية الميزات",
"definition_group" => "المجموعة",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "هل أنت متأكد من أنك تريد حذف الميزات المحددة ؟",
"confirm_restore" => "هل أنت متأكد من أنك تريد استعادة السمة (السمات) المحددة؟",
"definition_cannot_be_deleted" => "لا يمكن حذف السمات المحددة",
"definition_invalid_group" => "المجموعة المحددة غير موجودة أو غير صالحة.",
"definition_error_adding_updating" => "لا يمكن إضافة السمة {0} أو تحديثها. يرجى التحقق من سجل الخطأ.",
"definition_flags" => "رؤية الميزات",
"definition_group" => "المجموعة",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Seçilmiş Atributları silmək istədiyinizdən əminsinizmi?",
"confirm_restore" => "Seçilmiş atributları bərpa etmək istədiyinizə əminsinizmi?",
"definition_cannot_be_deleted" => "Seçilmiş xüsusiyyətləri silmək olmadı",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "{0} -in atributları əlavə oluna və yenilənə bilmədi. Lütfən XƏTA loq faylını yoxlayın.",
"definition_flags" => "Atribut görünüşü",
"definition_group" => "Qrup",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "",
"confirm_restore" => "",
"definition_cannot_be_deleted" => "",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "",
"definition_flags" => "",
"definition_group" => "",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Da li ste sigurni da želite da izbrišete izabrani atribut?",
"confirm_restore" => "Da li ste sigurni da želite vratiti izabrane atribute?",
"definition_cannot_be_deleted" => "Nije moguće izbrisati izabrane atribut",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "Atribut {0} nije moguće dodati ili ažurirati. Molimo provjerite dnevnik grešaka.",
"definition_flags" => "Vidljivost atributa",
"definition_group" => "Grupa",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "ئایا دڵنیای کە دەتەوێت تایبەتمەندییە هەڵبژێردراوەکە(کان) بسڕیتەوە؟",
"confirm_restore" => "ئایا دڵنیای کە دەتەوێت تایبەتمەندییە هەڵبژێردراوەکە(کان) بگەڕێنیتەوە؟",
"definition_cannot_be_deleted" => "نەتوانرا تایبەتمەندی هەڵبژێردراو بسڕدرێتەوە",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "تایبەتمەندی {0} نەتوانرا زیاد بکرێت یان نوێ بکرێتەوە. تکایە لیستی هەڵەکان بپشکنە.",
"definition_flags" => "توانای بینراویی تایبەتمەندی",
"definition_group" => "گروپ",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "",
"confirm_restore" => "",
"definition_cannot_be_deleted" => "",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "",
"definition_flags" => "",
"definition_group" => "",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Er du sikker på, at du vil slette de valgte egenskaber?",
"confirm_restore" => "Er du sikker på, at du vil gendanne de valgte egenskaber?",
"definition_cannot_be_deleted" => "De valgte egenskaber kunne ikke slettes",
"definition_invalid_group" => "Den valgte gruppe findes ikke eller er ugyldig.",
"definition_error_adding_updating" => "Egenskab {0} Kunne ikke tilføjes eller opdateres. Tjek venligst fejlprotokollen.",
"definition_flags" => "Egenskabens Synlighed",
"definition_group" => "Gruppe",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "",
"confirm_restore" => "",
"definition_cannot_be_deleted" => "",
"definition_invalid_group" => "Die ausgewählte Gruppe existiert nicht oder ist ungültig.",
"definition_error_adding_updating" => "",
"definition_flags" => "",
"definition_group" => "",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Sind Sie sicher, dass Sie die ausgewählten Attribute löschen möchten?",
"confirm_restore" => "Sind Sie sicher, dass Sie die ausgewählten Attribute wiederherstellen möchten?",
"definition_cannot_be_deleted" => "Ausgewählte Attribute konnten nicht gelöscht werden",
"definition_invalid_group" => "Die ausgewählte Gruppe existiert nicht oder ist ungültig.",
"definition_error_adding_updating" => "Das Attribut {0} konnte nicht hinzugefügt oder aktualisiert werden. Bitte überprüfen Sie den Error-Log.",
"definition_flags" => "Attribut Sichtbarkeit",
"definition_group" => "Gruppe",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Είστε βέβαιοι ότι θέλετε να διαγράψετε τα επιλεγμένα χαρακτηριστικά;",
"confirm_restore" => "Είστε βέβαιοι ότι θέλετε να επαναφέρετε τα επιλεγμένα χαρακτηριστικά;",
"definition_cannot_be_deleted" => "Δεν ήταν δυνατή η διαγραφή των επιλεγμένων χαρακτηριστικών",
"definition_invalid_group" => "Η επιλεγμένη ομάδα δεν υπάρχει ή δεν είναι έγκυρη.",
"definition_error_adding_updating" => "Το χαρακτηριστικό {0} δεν ήταν δυνατό να προστεθεί ή να ενημερωθεί. Ελέγξτε το αρχείο καταγραφής σφαλμάτων.",
"definition_flags" => "Ορατότητα χαρακτηριστικών",
"definition_group" => "Ομάδα",

View File

@@ -6,6 +6,7 @@ return [
"confirm_restore" => "Are you sure you want to restore the selected attribute(s)?",
"definition_cannot_be_deleted" => "Could not delete selected attribute(s)",
"definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.",
"definition_invalid_group" => "The selected group does not exist or is invalid.",
"definition_flags" => "Attribute Visibility",
"definition_group" => "Group",
"definition_id" => "Id",

View File

@@ -6,6 +6,7 @@ return [
"confirm_restore" => "Are you sure you want to restore the selected attribute(s)?",
"definition_cannot_be_deleted" => "Could not delete selected attribute(s)",
"definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.",
"definition_invalid_group" => "The selected group does not exist or is invalid.",
"definition_flags" => "Attribute Visibility",
"definition_group" => "Group",
"definition_id" => "Id",

View File

@@ -1,27 +0,0 @@
<?php
return [
// Plugin Management
"plugins" => "Plugins",
"plugin_management" => "Plugin Management",
"plugin_name" => "Plugin Name",
"plugin_description" => "Description",
"plugin_version" => "Version",
"plugin_status" => "Status",
"plugin_enabled" => "Plugin enabled successfully",
"plugin_enable_failed" => "Failed to enable plugin",
"plugin_disabled" => "Plugin disabled successfully",
"plugin_disable_failed" => "Failed to disable plugin",
"plugin_uninstalled" => "Plugin uninstalled successfully",
"plugin_uninstall_failed" => "Failed to uninstall plugin",
"plugin_not_found" => "Plugin not found",
"plugin_no_config" => "This plugin has no configuration options",
"settings_saved" => "Plugin settings saved successfully",
"settings_save_failed" => "Failed to save plugin settings",
"enable" => "Enable",
"disable" => "Disable",
"configure" => "Configure",
"uninstall" => "Uninstall",
"no_plugins_found" => "No plugins found",
"active" => "Active",
"inactive" => "Inactive",
];

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "¿Está seguro de que desea borrar los atributos seleccionados?",
"confirm_restore" => "¿Está seguro de que desea restaurar los atributos seleccionados?",
"definition_cannot_be_deleted" => "No se han podido borrar los atributos seleccionados",
"definition_invalid_group" => "El grupo seleccionado no existe o no es válido.",
"definition_error_adding_updating" => "El atributo {0} no pudo ser agregado o actulizado. Por favor compruebe el registro de errores.",
"definition_flags" => "Visibilidad del atributo",
"definition_group" => "Grupo",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "¿Está seguro de eliminar el/los atributo(s) seleccionado(s)?",
"confirm_restore" => "¿Está seguro que quiere restaurar los atributos seleccionados?",
"definition_cannot_be_deleted" => "No ha sido posible eliminar el/los atributo(s) seleccionado(s)",
"definition_invalid_group" => "El grupo seleccionado no existe o no es válido.",
"definition_error_adding_updating" => "El atributo {0} no pudo ser agregado o actualizado. Favor de revisar el registro de errorres.",
"definition_flags" => "Visibilidad del atributo",
"definition_group" => "Grupo",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "آیا مطمئن هستید که می خواهید ویژگی (های) انتخاب شده را حذف کنید؟",
"confirm_restore" => "آیا مطمئن هستید که می خواهید ویژگی (های) انتخاب شده را بازیابی کنید؟",
"definition_cannot_be_deleted" => "نمی توان ویژگی (های) انتخابی را حذف کرد",
"definition_invalid_group" => "گروه انتخاب شده وجود ندارد یا نامعتبر است.",
"definition_error_adding_updating" => "ویژگی{0} اضافه نشد یا به روز نمی شود. لطفا گزارش خطا را بررسی کنید.",
"definition_flags" => "قابلیت مشاهده ویژگی",
"definition_group" => "گروه",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Êtes-vous certain de vouloir supprimer le(s) attribut(s) sélectionné(s) ?",
"confirm_restore" => "Êtes-vous certain de vouloir restaurer le(s) attribut(s) sélectionné(s) ?",
"definition_cannot_be_deleted" => "Le(s) attribut(s) sélectionné(s) n'ont pas pu être supprimé(s)",
"definition_invalid_group" => "Le groupe sélectionné n'existe pas ou est invalide.",
"definition_error_adding_updating" => "L'attribut {0} n'a pas pu être ajouté ou mis à jour. Veuillez vérifier le journal d'erreurs.",
"definition_flags" => "Visibilité de l'attribut",
"definition_group" => "Groupe",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "האם אתה בטוח שברצונך למחוק את המאפיינים שנבחרו?",
"confirm_restore" => "האם אתה בטוח שברצונך לשחזר את המאפיינים שנבחרו?",
"definition_cannot_be_deleted" => "לא ניתן למחוק מאפיינים נבחר(ים)",
"definition_invalid_group" => "הקבוצה שנבחרה לא קיימת או אינה תקינה.",
"definition_error_adding_updating" => "לא ניתן להוסיף או לעדכן את הערך {0}. בדוק את יומן השגיאות.",
"definition_flags" => "מאפיין גלוי",
"definition_group" => "קבוצה",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "",
"confirm_restore" => "",
"definition_cannot_be_deleted" => "",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "",
"definition_flags" => "",
"definition_group" => "",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Biztosan törli szeretné a kijelölt tulajdonságokat?",
"confirm_restore" => "Biztosan visszaállítja a kijelölt tulajdonságokat?",
"definition_cannot_be_deleted" => "Nem sikerült törölni a kijelölt tulajdonságokat",
"definition_invalid_group" => "A kiválasztott csoport nem létezik vagy érvénytelen.",
"definition_error_adding_updating" => "{0} attribútum nem adható hozzá és nem frissíthető. Kérjük, ellenőrizze a hibanaplót.",
"definition_flags" => "Tulajdonság láthatósága",
"definition_group" => "Csoport",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Are you sure you want to delete the selected attribute(s)?",
"confirm_restore" => "Are you sure you want to restore the selected attribute(s)?",
"definition_cannot_be_deleted" => "Could not delete selected attribute(s)",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.",
"definition_flags" => "Attribute Visibility",
"definition_group" => "Group",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Apakah Anda yakin ingin menghapus atribut tersebut?",
"confirm_restore" => "Apakah Anda yakin ingin mengembalikan atribut tersebut?",
"definition_cannot_be_deleted" => "Tidak bisa menghapus atribut terpilih",
"definition_invalid_group" => "Grup yang dipilih tidak ada atau tidak valid.",
"definition_error_adding_updating" => "Atribut {0} tidak dapat ditambah atau diperbaharui. Silahkan periksa log kesalahan.",
"definition_flags" => "Visibilitas Atribut",
"definition_group" => "Grup",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Sei sicuro di voler eliminare gli attributi selezionati?",
"confirm_restore" => "Sei sicuro di voler ripristinare l'attributo selezionato?",
"definition_cannot_be_deleted" => "Non riesco a cancellare l'attributo selezionato",
"definition_invalid_group" => "Il gruppo selezionato non esiste o non è valido.",
"definition_error_adding_updating" => "Impossibile aggiungere o aggiornare l'attributo {0}. Si prega di controllare il registro degli errori.",
"definition_flags" => "Visibilità attributo",
"definition_group" => "Gruppo",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "តើអ្នកពិតជាចង់លុប ព៌តមានបន្ថែម ដែលបានជ្រើសរើស?",
"confirm_restore" => "តើអ្នកពិតជាដាក់ឡើងវិញនៅ ព៌តមានបន្ថែម ដែលបានជ្រើសរើស?",
"definition_cannot_be_deleted" => "មិនអាចលុបព៌តមានបន្ថែមដែលបានជ្រើសរើស",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "ព៌តមានបន្ថែម {0} មិនអាចថែម រឺកែប្រែបានឡើយ។​ សូមចូលទៅឆែករបាយការណ៍កំហុស។",
"definition_flags" => "ដាក់បង្ហាញព៌តមានបន្ថែម",
"definition_group" => "ក្រុម",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "ແນ່ໃຈຫຼືບໍທີ່ຈະລືບລາຍການທີ່ເລືອກ",
"confirm_restore" => "ແນ່ໃຈຫຼືບໍທີ່ຈະຄືນຄ່າແອັດທິບິ້ວດັ່ງກ່າວ?",
"definition_cannot_be_deleted" => "ບໍສາມາດລືບລາຍການທີ່ເລືອກໄດ້",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "ລາຍການ {0} ບໍສາມາດເພີ່ມ ຫຼື ແກ້ໄຂ. ກະລຸນາກວດສອບຢູ່ log ຂໍ້ຜິດຜາດ",
"definition_flags" => "ຄູນສົມບັດການເບິ່ງເຫັນ",
"definition_group" => "ກຸ່ມ",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Are you sure you want to delete the selected attribute(s)?",
"confirm_restore" => "",
"definition_cannot_be_deleted" => "Could not delete selected attribute(s)",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "",
"definition_flags" => "Attribute Visibility",
"definition_group" => "Group",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Are you sure you want to delete the selected attribute(s)?",
"confirm_restore" => "Are you sure you want to restore the selected attribute(s)?",
"definition_cannot_be_deleted" => "Could not delete selected attribute(s)",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.",
"definition_flags" => "Attribute Visibility",
"definition_group" => "Group",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Bent u zeker dat u de geselecteerde attributen wil verwijderen?",
"confirm_restore" => "Bent u zeker dat u de geselecteerde attributen wil herstellen?",
"definition_cannot_be_deleted" => "De geselecteerde attributen konden niet verwijderd worden",
"definition_invalid_group" => "De geselecteerde groep bestaat niet of is ongeldig.",
"definition_error_adding_updating" => "Attribuut {0} kon niet toegevoegd of gewijzigd worden. Kijk de error logs na.",
"definition_flags" => "Zichtbaarheid",
"definition_group" => "Groep",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Weet u zeker dat u de geselecteerde kenmerken wilt verwijderen?",
"confirm_restore" => "Weet u zeker dat u de geselecteerde kenmerken wilt herstellen?",
"definition_cannot_be_deleted" => "Kan geselecteerde kenmerk(en) niet verwijderen",
"definition_invalid_group" => "De geselecteerde groep bestaat niet of is ongeldig.",
"definition_error_adding_updating" => "Kenmerk {0} kan niet worden toegevoegd of bijgewerkt. Bekijk het foutenlogboek.",
"definition_flags" => "Kenmerk zichtbaarheid",
"definition_group" => "Groep",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Czy jesteś pewny, że chcesz usunąć wybrane atrybuty?",
"confirm_restore" => "Czy jesteś pewien, że chcesz przywrócić zaznaczone atrybuty?",
"definition_cannot_be_deleted" => "Nie można usunąć wybranych atrybutów",
"definition_invalid_group" => "Wybrana grupa nie istnieje lub jest nieprawidłowa.",
"definition_error_adding_updating" => "Atrybut 51 nie może zostać dodany lub zaktualizowany. Sprawdź dziennik błędów.",
"definition_flags" => "Widoczność atrybutu",
"definition_group" => "Grupa",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Tem certeza de que deseja excluir os atributos selecionados?",
"confirm_restore" => "Tem certeza de que deseja restaurar o(s) atributo(s) selecionado(s)?",
"definition_cannot_be_deleted" => "Não foi possível excluir atributo selecionado (s)",
"definition_invalid_group" => "O grupo selecionado não existe ou é inválido.",
"definition_error_adding_updating" => "Atributo {0} não pode ser adicionado ou atualizado. Por favor verifique o log de erros.",
"definition_flags" => "Visibilidade de atributo",
"definition_group" => "Grupo",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Sigur doriti stergerea atributului/atributelor selectat(e)?",
"confirm_restore" => "",
"definition_cannot_be_deleted" => "Nu se poate sterge atributul/atributele selectat(e)",
"definition_invalid_group" => "Grupul selectat nu există sau este invalid.",
"definition_error_adding_updating" => "",
"definition_flags" => "Vizibilitate atribut",
"definition_group" => "Grup",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Вы уверены, что хотите удалить выбранные атрибут(ы)?",
"confirm_restore" => "Вы уверены, что хотите восстановить выбранные атрибут(ы)?",
"definition_cannot_be_deleted" => "Не удалось удалить выбранные атрибут(ы)",
"definition_invalid_group" => "Выбранная группа не существует или недействительна.",
"definition_error_adding_updating" => "Атрибут {0} не может быть добавлен или обновлен. Пожалуйста, проверьте журнал ошибок.",
"definition_flags" => "Видимость атрибута",
"definition_group" => "Группа",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Är du säker på att du vill ta bort de valda attributen?",
"confirm_restore" => "Är du säker på att du vill återställa de valda attributen?",
"definition_cannot_be_deleted" => "Det gick inte att ta bort valda attribut",
"definition_invalid_group" => "Den valda gruppen finns inte eller är ogiltig.",
"definition_error_adding_updating" => "Attribut{0} kunde inte läggas till eller uppdateras. Kontrollera felloggen.",
"definition_flags" => "Attribut synlighet",
"definition_group" => "Grupp",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Una uhakika unataka kufuta sifa iliyochaguliwa/zilizochaguliwa?",
"confirm_restore" => "Una uhakika unataka kurejesha sifa iliyochaguliwa/zilizochaguliwa?",
"definition_cannot_be_deleted" => "Haiwezekani kufuta sifa iliyochaguliwa/zilizochaguliwa",
"definition_invalid_group" => "Kikundi ulichochagua hakipo au hakitoshi.",
"definition_error_adding_updating" => "Sifa {0} haiwezekani kuongezwa au kusasishwa. Tafadhali angalia logi ya makosa.",
"definition_flags" => "Uonekano wa Sifa",
"definition_group" => "Kundi",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Una uhakika unataka kufuta sifa iliyochaguliwa/zilizochaguliwa?",
"confirm_restore" => "Una uhakika unataka kurejesha sifa iliyochaguliwa/zilizochaguliwa?",
"definition_cannot_be_deleted" => "Haiwezekani kufuta sifa iliyochaguliwa/zilizochaguliwa",
"definition_invalid_group" => "Kikundi ulichochagua hakipo au hakitoshi.",
"definition_error_adding_updating" => "Sifa {0} haiwezekani kuongezwa au kusasishwa. Tafadhali angalia logi ya makosa.",
"definition_flags" => "Uonekano wa Sifa",
"definition_group" => "Kundi",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "தேர்ந்தெடுக்கப்பட்ட பண்புக்கூறு (களை) நீக்க விரும்புகிறீர்களா?",
"confirm_restore" => "தேர்ந்தெடுக்கப்பட்ட பண்புக்கூறுகளை (களை) மீட்டெடுக்க விரும்புகிறீர்களா?",
"definition_cannot_be_deleted" => "Could not delete selected attribute(s)",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.",
"definition_flags" => "Attribute Visibility",
"definition_group" => "Group",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "ต้องการลบคุณลักษณะที่เลือกหรือไม่ ?",
"confirm_restore" => "ต้องการคืนค่าคุณลักษณะที่เลือกหรือไม่ ?",
"definition_cannot_be_deleted" => "ไม่สามารถลบคุณลักษณะที่เลือก",
"definition_invalid_group" => "กลุ่มที่เลือกไม่มีอยู่หรือไม่ถูกต้อง",
"definition_error_adding_updating" => "ไม่สามารถเพิ่มหรือแก้ไขคุณลักษณะ {0}, โปรดตรวจสอบความผิดพลาดในบันทึก",
"definition_flags" => "การมองเห็นคุณลักษณะ",
"definition_group" => "กลุ่ม",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Are you sure you want to restore the selected attribute(s)?",
"confirm_restore" => "Are you sure you want to delete the selected attribute(s)?",
"definition_cannot_be_deleted" => "Could not delete selected attribute(s)",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.",
"definition_flags" => "Attribute Visibility",
"definition_group" => "Group",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Seçili niteliği ya da nitelikleri silmek istediğinize emin misiniz?",
"confirm_restore" => "Seçili nitelik ya da nitelikleri kurtarmak istediğinize emin misiniz?",
"definition_cannot_be_deleted" => "Seçili nitelik ya da nitelikler silinemedi",
"definition_invalid_group" => "Seçilen grup mevcut değil veya geçersiz.",
"definition_error_adding_updating" => "Nitelik {0} eklenemedi ya da güncellenemedi. Lütfen hata kaydını gözden geçirin.",
"definition_flags" => "Nitelik Görünebilirliği",
"definition_group" => "Küme",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Ви впевнені, що хочете видалити вибрані атрибут(и)?",
"confirm_restore" => "Ви впевнені, що хочете відновити вибрані атрибут(и)?",
"definition_cannot_be_deleted" => "Не вдалося видалити вибрані атрибут(и)",
"definition_invalid_group" => "Вибрана група не існує або недійсна.",
"definition_error_adding_updating" => "Атрибут {0} не може бути доданий або оновлений. Будь ласка, перевірте журнал помилок.",
"definition_flags" => "Видимість атрибуту",
"definition_group" => "Група",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "کیا آپ منتخب شدہ کو حذف کرنا چاہتے ہیں ؟",
"confirm_restore" => "کیا آپ منتخب شدہ کو بحال کرنا چاہتے ہیں ؟",
"definition_cannot_be_deleted" => "منتخب شدہ کو حذف نہیں کیا جا سکتا",
"definition_invalid_group" => "",
"definition_error_adding_updating" => "Attribute {0} could not be added or updated. Please check the error log.",
"definition_flags" => "Attribute Visibility",
"definition_group" => "Group",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "Bạn có chắc chắn muốn xóa (các) thuộc tính đã chọn không?",
"confirm_restore" => "Bạn có chắc chắn muốn khôi phục (các) thuộc tính đã chọn không?",
"definition_cannot_be_deleted" => "Không thể xóa (các) thuộc tính được chọn",
"definition_invalid_group" => "Nhóm đã chọn không tồn tại hoặc không hợp lệ.",
"definition_error_adding_updating" => "Thuộc tính {0} không thể thêm hoặc cập nhật. Vui lòng kiểm tra nhật ký lỗi.",
"definition_flags" => "Hiển thị thuộc tính",
"definition_group" => "Nhóm",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "确定要删除所选属性吗",
"confirm_restore" => "您确定要还原所选属性吗?",
"definition_cannot_be_deleted" => "不能删除该特征属性",
"definition_invalid_group" => "所选组不存在或无效。",
"definition_error_adding_updating" => "无法添加或更新属性{0}。 请检查错误日志。",
"definition_flags" => "属性可见性",
"definition_group" => "",

View File

@@ -5,6 +5,7 @@ return [
"confirm_delete" => "您確定要刪除此屬性?",
"confirm_restore" => "您確定要還原所選屬性嗎?",
"definition_cannot_be_deleted" => "無法刪除所選屬性",
"definition_invalid_group" => "所選擇的群組不存在或無效。",
"definition_error_adding_updating" => "無法添加或更新屬性 {0}。 請檢查錯誤日誌。",
"definition_flags" => "屬性可見性",
"definition_group" => "群組",

View File

@@ -1,70 +0,0 @@
<?php
namespace App\Libraries\Plugins;
use App\Models\PluginConfig;
abstract class BasePlugin implements PluginInterface
{
protected PluginConfig $configModel;
public function __construct()
{
$this->configModel = new PluginConfig();
}
public function install(): bool
{
return true;
}
public function uninstall(): bool
{
return true;
}
public function isEnabled(): bool
{
$enabled = $this->configModel->getValue("{$this->getPluginId()}_enabled");
return $enabled === '1' || $enabled === 'true';
}
protected function getSetting(string $key, mixed $default = null): mixed
{
$value = $this->configModel->getValue("{$this->getPluginId()}_{$key}");
return $value ?? $default;
}
protected function setSetting(string $key, mixed $value): bool
{
$stringValue = is_array($value) || is_object($value)
? json_encode($value)
: (string)$value;
return $this->configModel->setValue("{$this->getPluginId()}_{$key}", $stringValue);
}
public function getSettings(): array
{
return $this->configModel->getPluginSettings($this->getPluginId());
}
public function saveSettings(array $settings): bool
{
$prefixedSettings = [];
foreach ($settings as $key => $value) {
if (is_array($value) || is_object($value)) {
$prefixedSettings["{$this->getPluginId()}_{$key}"] = json_encode($value);
} else {
$prefixedSettings["{$this->getPluginId()}_{$key}"] = (string)$value;
}
}
return $this->configModel->batchSave($prefixedSettings);
}
protected function log(string $level, string $message): void
{
log_message($level, "[Plugin:{$this->getPluginName()}] {$message}");
}
}

View File

@@ -1,56 +0,0 @@
<?php
namespace App\Libraries\Plugins;
interface PluginInterface
{
public function getPluginId(): string;
public function getPluginName(): string;
public function getPluginDescription(): string;
public function getVersion(): string;
/**
* Register event listeners for this plugin.
*
* Use Events::on() to register callbacks for OSPOS events.
* This method is called when the plugin is loaded and enabled.
*
* Example:
* Events::on('item_sale', [$this, 'onItemSale']);
* Events::on('item_change', [$this, 'onItemChange']);
*/
public function registerEvents(): void;
/**
* Install the plugin.
*
* Called when the plugin is first enabled. Use this to create database tables,
* set default configuration values, and run any setup required.
*/
public function install(): bool;
/**
* Uninstall the plugin.
*
* Called when the plugin is being removed. Use this to remove database tables,
* clean up configuration, etc.
*/
public function uninstall(): bool;
public function isEnabled(): bool;
/**
* Get the path to the plugin's configuration view file.
* Returns null if the plugin has no configuration UI.
*
* Example: 'Plugins/mailchimp/config'
*/
public function getConfigView(): ?string;
public function getSettings(): array;
public function saveSettings(array $settings): bool;
}

View File

@@ -1,174 +0,0 @@
<?php
namespace App\Libraries\Plugins;
use App\Models\PluginConfig;
use CodeIgniter\Events\Events;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class PluginManager
{
private array $plugins = [];
private array $enabledPlugins = [];
private PluginConfig $configModel;
private string $pluginsPath;
public function __construct()
{
$this->configModel = new PluginConfig();
$this->pluginsPath = APPPATH . 'Plugins';
}
public function discoverPlugins(): void
{
if (!is_dir($this->pluginsPath)) {
log_message('debug', 'Plugins directory does not exist: ' . $this->pluginsPath);
return;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->pluginsPath, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isDir() || $file->getExtension() !== 'php') {
continue;
}
$className = $this->getClassNameFromFile($file->getPathname());
if (!$className) {
continue;
}
if (!class_exists($className)) {
continue;
}
if (!is_subclass_of($className, PluginInterface::class)) {
continue;
}
$plugin = new $className();
$this->plugins[$plugin->getPluginId()] = $plugin;
log_message('debug', "Discovered plugin: {$plugin->getPluginName()}");
}
}
private function getClassNameFromFile(string $pathname): ?string
{
$relativePath = str_replace($this->pluginsPath . DIRECTORY_SEPARATOR, '', $pathname);
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
$className = 'App\\Plugins\\' . str_replace('.php', '', $relativePath);
return $className;
}
public function registerPluginEvents(): void
{
foreach ($this->plugins as $pluginId => $plugin) {
if ($this->isPluginEnabled($pluginId)) {
$this->enabledPlugins[$pluginId] = $plugin;
$plugin->registerEvents();
log_message('debug', "Registered events for plugin: {$plugin->getPluginName()}");
}
}
}
public function getAllPlugins(): array
{
return $this->plugins;
}
public function getEnabledPlugins(): array
{
return $this->enabledPlugins;
}
public function getPlugin(string $pluginId): ?PluginInterface
{
return $this->plugins[$pluginId] ?? null;
}
public function isPluginEnabled(string $pluginId): bool
{
$enabled = $this->configModel->getValue($this->getEnabledKey($pluginId));
return $enabled === '1' || $enabled === 'true';
}
public function enablePlugin(string $pluginId): bool
{
$plugin = $this->getPlugin($pluginId);
if (!$plugin) {
log_message('error', "Plugin not found: {$pluginId}");
return false;
}
if (!$this->configModel->exists($this->getInstalledKey($pluginId))) {
if (!$plugin->install()) {
log_message('error', "Failed to install plugin: {$pluginId}");
return false;
}
$this->configModel->setValue($this->getInstalledKey($pluginId), '1');
}
$this->configModel->setValue($this->getEnabledKey($pluginId), '1');
log_message('info', "Plugin enabled: {$pluginId}");
return true;
}
public function disablePlugin(string $pluginId): bool
{
if (!$this->getPlugin($pluginId)) {
log_message('error', "Plugin not found: {$pluginId}");
return false;
}
$this->configModel->setValue($this->getEnabledKey($pluginId), '0');
log_message('info', "Plugin disabled: {$pluginId}");
return true;
}
public function uninstallPlugin(string $pluginId): bool
{
$plugin = $this->getPlugin($pluginId);
if (!$plugin) {
log_message('error', "Plugin not found: {$pluginId}");
return false;
}
if (!$plugin->uninstall()) {
log_message('error', "Failed to uninstall plugin: {$pluginId}");
return false;
}
$this->configModel->deleteAllStartingWith($pluginId . '_');
return true;
}
public function getSetting(string $pluginId, string $key, mixed $default = null): mixed
{
return $this->configModel->getValue("{$pluginId}_{$key}") ?? $default;
}
public function setSetting(string $pluginId, string $key, mixed $value): bool
{
return $this->configModel->setValue("{$pluginId}_{$key}", $value);
}
private function getEnabledKey(string $pluginId): string
{
return "{$pluginId}_enabled";
}
private function getInstalledKey(string $pluginId): string
{
return "{$pluginId}_installed";
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace app\Libraries;
namespace App\Libraries;
use App\Models\Tokens\Token;
use Config\OSPOS;
@@ -14,40 +14,137 @@ use DateTime;
*/
class Token_lib
{
private array $strftimeToIntlPatternMap = [
'%a' => 'EEE',
'%A' => 'EEEE',
'%b' => 'MMM',
'%B' => 'MMMM',
'%d' => 'dd',
'%D' => 'MM/dd/yy',
'%e' => 'd',
'%F' => 'yyyy-MM-dd',
'%h' => 'MMM',
'%j' => 'D',
'%m' => 'MM',
'%U' => 'w',
'%V' => 'ww',
'%W' => 'ww',
'%y' => 'yy',
'%Y' => 'yyyy',
'%H' => 'HH',
'%I' => 'hh',
'%l' => 'h',
'%M' => 'mm',
'%p' => 'a',
'%P' => 'a',
'%r' => 'hh:mm:ss a',
'%R' => 'HH:mm',
'%S' => 'ss',
'%T' => 'HH:mm:ss',
'%X' => 'HH:mm:ss',
'%z' => 'ZZZZZ',
'%Z' => 'z',
'%g' => 'yy',
'%G' => 'yyyy',
'%u' => 'e',
'%w' => 'c',
];
private array $validStrftimeFormats = [
'a', 'A', 'b', 'B', 'c', 'd', 'D', 'e', 'F', 'g', 'G',
'h', 'H', 'I', 'j', 'm', 'M', 'n', 'p', 'P', 'r', 'R',
'S', 't', 'T', 'u', 'U', 'V', 'w', 'W', 'x', 'X', 'y', 'Y', 'z', 'Z'
];
/**
* Expands all the tokens found in a given text string and returns the results.
*/
public function render(string $tokened_text, array $tokens = [], $save = true): string
{
// Apply the transformation for the "%" tokens if any are used
if (strpos($tokened_text, '%') !== false) {
$tokened_text = strftime($tokened_text); // TODO: these need to be converted to IntlDateFormatter::format()
if (str_contains($tokened_text, '%')) {
$tokened_text = $this->applyDateFormats($tokened_text);
}
// Call scan to build an array of all of the tokens used in the text to be transformed
$token_tree = $this->scan($tokened_text);
if (empty($token_tree)) {
if (strpos($tokened_text, '%') !== false) {
return strftime($tokened_text);
} else {
return $tokened_text;
}
return $tokened_text;
}
$token_values = [];
$tokens_to_replace = [];
$this->generate($token_tree, $tokens_to_replace, $token_values, $tokens, $save);
$this->generate($token_tree, $tokens, $tokens_to_replace, $token_values, $save);
return str_replace($tokens_to_replace, $token_values, $tokened_text);
}
/**
* Parses out the all the tokens enclosed in braces {} and subparses on the colon : character where supplied
*/
private function applyDateFormats(string $text): string
{
$formatter = new IntlDateFormatter(
null,
IntlDateFormatter::FULL,
IntlDateFormatter::FULL,
null,
null,
''
);
$dateTime = new DateTime();
return preg_replace_callback(
'/%([a-zA-Z%])/',
function ($match) use ($formatter, $dateTime) {
$formatChar = $match[1];
if ($formatChar === '%') {
return '%';
}
if ($formatChar === 'n') {
return "\n";
}
if ($formatChar === 't') {
return "\t";
}
if ($formatChar === 'C') {
return str_pad((string) intdiv((int) $dateTime->format('Y'), 100), 2, '0', STR_PAD_LEFT);
}
if ($formatChar === 'c') {
$formatter->setPattern('yyyy-MM-dd HH:mm:ss');
$result = $formatter->format($dateTime);
return $result !== false ? $result : $match[0];
}
if ($formatChar === 'x') {
$formatter->setPattern('yyyy-MM-dd');
$result = $formatter->format($dateTime);
return $result !== false ? $result : $match[0];
}
if (!in_array($formatChar, $this->validStrftimeFormats, true)) {
return $match[0];
}
$intlPattern = $this->strftimeToIntlPatternMap[$match[0]] ?? null;
if ($intlPattern === null) {
return $match[0];
}
$formatter->setPattern($intlPattern);
$result = $formatter->format($dateTime);
return $result !== false ? $result : $match[0];
},
$text
);
}
public function scan(string $text): array
{
// Matches tokens with the following pattern: [$token:$length]
preg_match_all('/
\{ # [ - pattern start
([^\s\{\}:]+) # match $token not containing whitespace : { or }
@@ -69,12 +166,6 @@ class Token_lib
return $token_tree;
}
/**
* @param string|null $quantity
* @param string|null $price
* @param string|null $item_id_or_number_or_item_kit_or_receipt
* @return void
*/
public function parse_barcode(?string &$quantity, ?string &$price, ?string &$item_id_or_number_or_item_kit_or_receipt): void
{
$config = config(OSPOS::class)->settings;
@@ -90,17 +181,11 @@ class Token_lib
$price = (isset($parsed_results['P'])) ? (double) $parsed_results['P'] : null;
}
} else {
$quantity = 1; // TODO: Quantity is handled using bcmath functions so that it is precision safe. This should be '1'
$quantity = 1;
}
}
/**
* @param string $string
* @param string $pattern
* @param array $tokens
* @return array
*/
public function parse(string $string, string $pattern, array $tokens = []): array // TODO: $string is a poor name for this parameter.
public function parse(string $string, string $pattern, array $tokens = []): array
{
$token_tree = $this->scan($pattern);
@@ -129,19 +214,10 @@ class Token_lib
return $results;
}
/**
* @param array $used_tokens
* @param array $tokens_to_replace
* @param array $token_values
* @param array $tokens
* @param bool $save
* @return array
*/
public function generate(array $used_tokens, array &$tokens_to_replace, array &$token_values, array $tokens, bool $save = true): array // TODO: $tokens
private function generate(array $used_tokens, array $tokens, array &$tokens_to_replace, array &$token_values, bool $save = true): void
{
foreach ($used_tokens as $token_code => $token_info) {
// Generate value here based on the key value
$token_value = $this->resolve_token($token_code, [], $save);
$token_value = $this->resolve_token($token_code, $tokens, $save);
foreach ($token_info as $length => $token_spec) {
$tokens_to_replace[] = $token_spec;
@@ -152,16 +228,8 @@ class Token_lib
}
}
}
return $token_values;
}
/**
* @param $token_code
* @param array $tokens
* @param bool $save
* @return string
*/
private function resolve_token($token_code, array $tokens = [], bool $save = true): string
{
foreach (array_merge($tokens, Token::get_tokens()) as $token) {
@@ -172,4 +240,4 @@ class Token_lib
return '';
}
}
}

View File

@@ -262,9 +262,10 @@ class Attribute extends Model
/**
* @param int $definition_flags
* @param bool $include_types If true, returns array with definition_id => ['name' => name, 'type' => type]
* @return array
*/
public function get_definitions_by_flags(int $definition_flags): array
public function get_definitions_by_flags(int $definition_flags, bool $include_types = false): array
{
$builder = $this->db->table('attribute_definitions');
$builder->where(new RawSql("definition_flags & $definition_flags")); // TODO: we need to heed CI warnings to escape properly
@@ -274,6 +275,17 @@ class Attribute extends Model
$results = $builder->get()->getResultArray();
if ($include_types) {
$definitions = [];
foreach ($results as $result) {
$definitions[$result['definition_id']] = [
'name' => $result['definition_name'],
'type' => $result['definition_type']
];
}
return $definitions;
}
return $this->to_array($results, 'definition_id', 'definition_name');
}

View File

@@ -16,6 +16,10 @@ use stdClass;
*/
class Item extends Model
{
public const ALLOWED_SUGGESTIONS_COLUMNS = ['name', 'item_number', 'description', 'cost_price', 'unit_price'];
public const ALLOWED_SUGGESTIONS_COLUMNS_WITH_EMPTY = ['', 'name', 'item_number', 'description', 'cost_price', 'unit_price'];
public const ALLOWED_BULK_EDIT_FIELDS = [
'name',
'category',
@@ -27,7 +31,6 @@ class Item extends Model
'allow_alt_description',
'is_serialized'
];
protected $table = 'items';
protected $primaryKey = 'item_id';
protected $useAutoIncrement = true;
@@ -544,13 +547,17 @@ class Item extends Model
public function get_search_suggestion_format(?string $seed = null): string
{
$config = config(OSPOS::class)->settings;
$seed .= ',' . $config['suggestions_first_column'];
$suggestionsFirstColumn = $this->suggestionColumnIsAllowed($config['suggestions_first_column'])
? $config['suggestions_first_column']
: 'name';
$seed .= ',' . $suggestionsFirstColumn;
if ($config['suggestions_second_column'] !== '') {
if ($config['suggestions_second_column'] !== '' && $this->suggestionColumnIsAllowed($config['suggestions_second_column'])) {
$seed .= ',' . $config['suggestions_second_column'];
}
if ($config['suggestions_third_column'] !== '') {
if ($config['suggestions_third_column'] !== '' && $this->suggestionColumnIsAllowed($config['suggestions_third_column'])) {
$seed .= ',' . $config['suggestions_third_column'];
}
@@ -566,9 +573,15 @@ class Item extends Model
$config = config(OSPOS::class)->settings;
$label = '';
$label1 = $config['suggestions_first_column'];
$label2 = $config['suggestions_second_column'];
$label3 = $config['suggestions_third_column'];
$label1 = $this->suggestionColumnIsAllowed($config['suggestions_first_column'])
? $config['suggestions_first_column']
: 'name';
$label2 = $this->suggestionColumnIsAllowed($config['suggestions_second_column'])
? $config['suggestions_second_column']
: '';
$label3 = $this->suggestionColumnIsAllowed($config['suggestions_third_column'])
? $config['suggestions_third_column']
: '';
$this->format_result_numbers($result_row);
@@ -592,6 +605,17 @@ class Item extends Model
return $label;
}
/**
* Validates if a column name is in the allowed suggestions columns.
*
* @param string $columnName
* @return bool
*/
private function suggestionColumnIsAllowed(string $columnName): bool
{
return in_array($columnName, self::ALLOWED_SUGGESTIONS_COLUMNS, true);
}
/**
* Converts decimal money values to their correct locale format.
*

View File

@@ -1,107 +0,0 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class PluginConfig extends Model
{
protected $table = 'plugin_config';
protected $primaryKey = 'key';
protected $useAutoIncrement = false;
protected $useSoftDeletes = false;
protected $allowedFields = [
'key',
'value'
];
public function exists(string $key): bool
{
$builder = $this->db->table('plugin_config');
$builder->where('key', $key);
return ($builder->get()->getNumRows() === 1);
}
public function getValue(string $key): ?string
{
$builder = $this->db->table('plugin_config');
$query = $builder->getWhere(['key' => $key], 1);
if ($query->getNumRows() === 1) {
return $query->getRow()->value;
}
return null;
}
public function setValue(string $key, string $value): bool
{
$builder = $this->db->table('plugin_config');
if ($this->exists($key)) {
return $builder->update(['value' => $value], ['key' => $key]);
}
return $builder->insert(['key' => $key, 'value' => $value]);
}
public function getPluginSettings(string $pluginId): array
{
$builder = $this->db->table('plugin_config');
$builder->like('key', $pluginId . '_', 'after');
$query = $builder->get();
$settings = [];
$prefix = $pluginId . '_';
foreach ($query->getResult() as $row) {
$key = str_starts_with($row->key, $prefix)
? substr($row->key, strlen($prefix))
: $row->key;
$settings[$key] = $row->value;
}
return $settings;
}
public function deleteKey(string $key): bool
{
$builder = $this->db->table('plugin_config');
return $builder->delete(['key' => $key]);
}
public function deleteAllStartingWith(string $prefix): bool
{
$builder = $this->db->table('plugin_config');
$builder->like('key', $prefix, 'after');
return $builder->delete();
}
public function batchSave(array $data): bool
{
$success = true;
$this->db->transStart();
foreach ($data as $key => $value) {
$success &= $this->setValue($key, $value);
}
$this->db->transComplete();
return $success && $this->db->transStatus();
}
public function getAll(): array
{
$builder = $this->db->table('plugin_config');
$query = $builder->get();
$configs = [];
foreach ($query->getResult() as $row) {
$configs[$row->key] = $row->value;
}
return $configs;
}
}

View File

@@ -14,10 +14,7 @@ class Summary_taxes extends Summary_report
$this->config = config(OSPOS::class)->settings;
}
/**
* @return array[]
*/
protected function _get_data_columns(): array // TODO: hungarian notation
protected function _get_data_columns(): array
{
return [
['tax_name' => lang('Reports.tax_name'), 'sortable' => false],
@@ -29,12 +26,7 @@ class Summary_taxes extends Summary_report
];
}
/**
* @param array $inputs
* @param $builder
* @return void
*/
protected function _where(array $inputs, &$builder): void // TODO: hungarian notation
protected function _where(array $inputs, &$builder): void
{
$builder->where('sales.sale_status', COMPLETED);
@@ -45,51 +37,90 @@ class Summary_taxes extends Summary_report
}
}
/**
* @param array $inputs
* @return array
*/
public function getData(array $inputs): array
{
$decimals = totals_decimals();
$db_prefix = $this->db->getPrefix();
$sale_amount = '(CASE WHEN ' . $db_prefix . 'sales_items.discount_type = ' . PERCENT
. " THEN " . $db_prefix . "sales_items.quantity_purchased * " . $db_prefix . "sales_items.item_unit_price - ROUND(" . $db_prefix . "sales_items.quantity_purchased * " . $db_prefix . "sales_items.item_unit_price * " . $db_prefix . "sales_items.discount / 100, $decimals)"
. ' ELSE ' . $db_prefix . 'sales_items.quantity_purchased * (' . $db_prefix . "sales_items.item_unit_price - " . $db_prefix . "sales_items.discount) END)";
$sale_tax = "IFNULL(" . $db_prefix . "sales_items_taxes.item_tax_amount, 0)";
if ($this->config['tax_included']) {
$sale_total = '(CASE WHEN ' . $db_prefix . 'sales_items.discount_type = ' . PERCENT
. " THEN " . $db_prefix . "sales_items.quantity_purchased * " . $db_prefix . "sales_items.item_unit_price - ROUND(" . $db_prefix . "sales_items.quantity_purchased * " . $db_prefix . "sales_items.item_unit_price * " . $db_prefix . "sales_items.discount / 100, $decimals)"
. ' ELSE ' . $db_prefix . 'sales_items.quantity_purchased * (' . $db_prefix . 'sales_items.item_unit_price - ' . $db_prefix . 'sales_items.discount) END)';
$sale_subtotal = '(CASE WHEN ' . $db_prefix . 'sales_items.discount_type = ' . PERCENT
. " THEN " . $db_prefix . "sales_items.quantity_purchased * " . $db_prefix . "sales_items.item_unit_price - ROUND(" . $db_prefix . "sales_items.quantity_purchased * " . $db_prefix . "sales_items.item_unit_price * " . $db_prefix . "sales_items.discount / 100, $decimals) "
. 'ELSE ' . $db_prefix . 'sales_items.quantity_purchased * ' . $db_prefix . 'sales_items.item_unit_price - ' . $db_prefix . 'sales_items.discount END * (100 / (100 + ' . $db_prefix . 'sales_items_taxes.percent)))';
$sale_subtotal = "ROUND($sale_amount - $sale_tax, $decimals)";
} else {
$sale_total = '(CASE WHEN ' . $db_prefix . 'sales_items.discount_type = ' . PERCENT
. " THEN " . $db_prefix . "sales_items.quantity_purchased * " . $db_prefix . "sales_items.item_unit_price - ROUND(" . $db_prefix . "sales_items.quantity_purchased * " . $db_prefix . "sales_items.item_unit_price * " . $db_prefix . "sales_items.discount / 100, $decimals)"
. ' ELSE ' . $db_prefix . 'sales_items.quantity_purchased * ' . $db_prefix . 'sales_items.item_unit_price - ' . $db_prefix . 'sales_items.discount END * (1 + (' . $db_prefix . 'sales_items_taxes.percent / 100)))';
$sale_subtotal = '(CASE WHEN ' . $db_prefix . 'sales_items.discount_type = ' . PERCENT
. " THEN " . $db_prefix . "sales_items.quantity_purchased * " . $db_prefix . "sales_items.item_unit_price - ROUND(" . $db_prefix . "sales_items.quantity_purchased * " . $db_prefix . "sales_items.item_unit_price * " . $db_prefix . "sales_items.discount / 100, $decimals)"
. ' ELSE ' . $db_prefix . 'sales_items.quantity_purchased * (' . $db_prefix . 'sales_items.item_unit_price - ' . $db_prefix . 'sales_items.discount) END)';
$sale_subtotal = "ROUND($sale_amount, $decimals)";
}
$sale_tax_rounded = "ROUND($sale_tax, $decimals)";
$sale_total = "($sale_subtotal + $sale_tax_rounded)";
$subquery_builder = $this->db->table('sales_items');
$subquery_builder->select("name AS name, CONCAT(IFNULL(ROUND(percent, $decimals), 0), '%') AS percent, sales.sale_id AS sale_id, $sale_subtotal AS subtotal, IFNULL($db_prefix" . "sales_items_taxes.item_tax_amount, 0) AS tax, IFNULL($sale_total, $sale_subtotal) AS total");
$subquery_builder->select(
"name AS name, "
. "CONCAT(IFNULL(ROUND(percent, $decimals), 0), '%') AS percent, "
. "sales.sale_id AS sale_id, "
. "$sale_subtotal AS subtotal, "
. "$sale_tax_rounded AS tax, "
. "$sale_total AS total"
);
$subquery_builder->join('sales', 'sales_items.sale_id = sales.sale_id', 'inner');
$subquery_builder->join('sales_items_taxes', 'sales_items.sale_id = sales_items_taxes.sale_id AND sales_items.item_id = sales_items_taxes.item_id AND sales_items.line = sales_items_taxes.line', 'left outer');
$subquery_builder->join(
'sales_items_taxes',
'sales_items.sale_id = sales_items_taxes.sale_id AND sales_items.item_id = sales_items_taxes.item_id AND sales_items.line = sales_items_taxes.line',
'left outer'
);
$subquery_builder->where('sale_status', COMPLETED);
if (empty($this->config['date_or_time_format'])) {
$subquery_builder->where('DATE(' . $db_prefix . 'sales.sale_time) BETWEEN ' . $this->db->escape($inputs['start_date']) . ' AND ' . $this->db->escape($inputs['end_date']));
$subquery_builder->where(
'DATE(' . $db_prefix . 'sales.sale_time) BETWEEN ' . $this->db->escape($inputs['start_date'])
. ' AND ' . $this->db->escape($inputs['end_date'])
);
} else {
$subquery_builder->where('sales.sale_time BETWEEN ' . $this->db->escape(rawurldecode($inputs['start_date'])) . ' AND ' . $this->db->escape(rawurldecode($inputs['end_date'])));
$subquery_builder->where(
'sales.sale_time BETWEEN ' . $this->db->escape(rawurldecode($inputs['start_date']))
. ' AND ' . $this->db->escape(rawurldecode($inputs['end_date']))
);
}
$builder = $this->db->newQuery()->fromSubquery($subquery_builder, 'temp_taxes');
$builder->select("name, percent, COUNT(DISTINCT sale_id) AS count, ROUND(SUM(subtotal), $decimals) AS subtotal, ROUND(SUM(tax), $decimals) AS tax, ROUND(SUM(total), $decimals) total");
$builder->select(
"name, percent, COUNT(DISTINCT sale_id) AS count, "
. "ROUND(SUM(subtotal), $decimals) AS subtotal, "
. "ROUND(SUM(tax), $decimals) AS tax, "
. "ROUND(SUM(total), $decimals) AS total"
);
$builder->groupBy('percent, name');
return $builder->get()->getResultArray();
}
}
public function getSummaryData(array $inputs): array
{
$decimals = totals_decimals();
$data = $this->getData($inputs);
$subtotal = 0;
$tax = 0;
$total = 0;
$count = 0;
foreach ($data as $row) {
$subtotal += (float) $row['subtotal'];
$tax += (float) $row['tax'];
$total += (float) $row['total'];
$count += (int) $row['count'];
}
return [
'subtotal' => round($subtotal, $decimals),
'tax' => round($tax, $decimals),
'total' => round($total, $decimals),
'count' => $count
];
}
}

View File

@@ -273,6 +273,10 @@ class Sale extends Model
$builder->like('payment_type', lang('Sales.credit'));
}
if ($filters['only_debit']) {
$builder->like('payment_type', lang('Sales.debit'));
}
$builder->groupBy('payment_type');
$payments = $builder->get()->getResultArray();
@@ -1494,6 +1498,10 @@ class Sale extends Model
$builder->like('payments.payment_type', lang('Sales.credit'));
}
if ($filters['only_debit']) {
$builder->like('payments.payment_type', lang('Sales.debit'));
}
if ($filters['only_due']) {
$builder->like('payments.payment_type', lang('Sales.due'));
}

View File

@@ -1,193 +0,0 @@
<?php
namespace App\Plugins;
use App\Libraries\Plugins\BasePlugin;
use App\Libraries\Mailchimp_lib;
use CodeIgniter\Events\Events;
/**
* Plugin that integrates OSPOS with Mailchimp for customer newsletter subscriptions.
*/
class MailchimpPlugin extends BasePlugin
{
private ?Mailchimp_lib $mailchimpLib = null;
public function getPluginId(): string
{
return 'mailchimp';
}
public function getPluginName(): string
{
return 'Mailchimp';
}
public function getPluginDescription(): string
{
return $this->lang('mailchimp_description');
}
public function getVersion(): string
{
return '1.0.0';
}
public function registerEvents(): void
{
Events::on('customer_saved', [$this, 'onCustomerSaved']);
Events::on('customer_deleted', [$this, 'onCustomerDeleted']);
log_message('debug', 'Mailchimp plugin events registered');
}
public function install(): bool
{
log_message('info', 'Installing Mailchimp plugin');
$this->setSetting('api_key', '');
$this->setSetting('list_id', '');
$this->setSetting('sync_on_save', '1');
$this->setSetting('enabled', '0');
return true;
}
public function uninstall(): bool
{
log_message('info', 'Uninstalling Mailchimp plugin');
return true;
}
public function getConfigView(): ?string
{
return 'Plugins/mailchimp/config';
}
public function getSettings(): array
{
return [
'api_key' => $this->getSetting('api_key', ''),
'list_id' => $this->getSetting('list_id', ''),
'sync_on_save' => $this->getSetting('sync_on_save', '1'),
'enabled' => $this->getSetting('enabled', '0'),
];
}
public function saveSettings(array $settings): bool
{
if (isset($settings['api_key'])) {
$this->setSetting('api_key', $settings['api_key']);
}
if (isset($settings['list_id'])) {
$this->setSetting('list_id', $settings['list_id']);
}
if (isset($settings['sync_on_save'])) {
$this->setSetting('sync_on_save', $settings['sync_on_save'] ? '1' : '0');
}
return true;
}
public function onCustomerSaved(array $customerData): void
{
if (!$this->shouldSyncOnSave()) {
return;
}
log_message('debug', "Customer saved event received for ID: {$customerData['person_id']}");
try {
$this->subscribeCustomer($customerData);
} catch (\Exception $e) {
log_message('error', "Failed to sync customer to Mailchimp: {$e->getMessage()}");
}
}
public function onCustomerDeleted(int $customerId): void
{
log_message('debug', "Customer deleted event received for ID: {$customerId}");
}
private function subscribeCustomer(array $customerData): bool
{
$apiKey = $this->getSetting('api_key');
$listId = $this->getSetting('list_id');
if (empty($apiKey) || empty($listId)) {
log_message('warning', 'Mailchimp API key or List ID not configured');
return false;
}
if (empty($customerData['email'])) {
log_message('debug', 'Customer has no email, skipping Mailchimp sync');
return false;
}
$mailchimp = $this->getMailchimpLib(['api_key' => $apiKey]);
$result = $mailchimp->addOrUpdateMember(
$listId,
$customerData['email'],
$customerData['first_name'] ?? '',
$customerData['last_name'] ?? '',
'subscribed'
);
if ($result) {
log_message('info', "Successfully subscribed customer ID {$customerData['person_id']} to Mailchimp");
return true;
}
return false;
}
private function shouldSyncOnSave(): bool
{
return $this->getSetting('sync_on_save', '1') === '1';
}
private function getMailchimpLib(array $params = []): Mailchimp_lib
{
if ($this->mailchimpLib === null) {
$this->mailchimpLib = new Mailchimp_lib($params);
}
return $this->mailchimpLib;
}
public function testConnection(): array
{
$apiKey = $this->getSetting('api_key');
if (empty($apiKey)) {
return ['success' => false, 'message' => $this->lang('mailchimp_api_key_required')];
}
$mailchimp = $this->getMailchimpLib(['api_key' => $apiKey]);
$result = $mailchimp->getLists();
if ($result && isset($result['lists'])) {
return [
'success' => true,
'message' => $this->lang('mailchimp_key_successfully'),
'lists' => $result['lists']
];
}
return ['success' => false, 'message' => $this->lang('mailchimp_key_unsuccessfully')];
}
protected function lang(string $key, array $data = []): string
{
$language = \Config\Services::language();
$language->addLanguagePath(APPPATH . 'Plugins/MailchimpPlugin/Language/');
return $language->getLine($key, $data);
}
protected function getPluginDir(): string
{
return 'MailchimpPlugin';
}
}

View File

@@ -1,13 +0,0 @@
<?php
return [
'mailchimp' => 'Mailchimp',
'mailchimp_description' => 'Integrate with Mailchimp to sync customers to mailing lists when they are created or updated.',
'mailchimp_api_key' => 'Mailchimp API Key',
'mailchimp_api_key_required' => 'API key not configured',
'mailchimp_configuration' => 'Mailchimp Configuration',
'mailchimp_key_successfully' => 'API Key is valid.',
'mailchimp_key_unsuccessfully' => 'API Key is invalid.',
'mailchimp_lists' => 'Mailchimp List(s)',
'mailchimp_tooltip' => 'Click the icon for an API Key.',
];

View File

@@ -1,528 +0,0 @@
# OSPOS Plugin System
## Overview
The OSPOS Plugin System allows third-party integrations to extend the application's functionality without modifying core code. Plugins can listen to events, add configuration settings, and integrate with external services.
## Installation
### Self-Contained Plugin Packages
Plugins are self-contained packages that can be installed by simply dropping the plugin folder into `app/Plugins/`:
```
app/Plugins/
├── MailchimpPlugin/ # Plugin directory (self-contained)
│ ├── MailchimpPlugin.php # Main plugin class (required - must match directory name)
│ ├── Language/ # Plugin-specific translations (self-contained)
│ │ ├── en/
│ │ │ └── MailchimpPlugin.php
│ │ └── es-ES/
│ │ └── MailchimpPlugin.php
│ └── Views/ # Plugin-specific views
│ └── config.php
```
### Installation Steps
1. **Download the plugin** - Copy the plugin folder/file to `app/Plugins/`
2. **Auto-discovery** - The plugin will be automatically discovered on next page load
3. **Enable** - Enable it from the admin interface (Plugins menu)
4. **Configure** - Configure plugin settings if needed
### Plugin Discovery
The PluginManager recursively scans `app/Plugins/` directory:
- **Single-file plugins**: `app/Plugins/MyPlugin.php` with namespace `App\Plugins\MyPlugin`
- **Directory plugins**: `app/Plugins/MyPlugin/MyPlugin.php` with namespace `App\Plugins\MyPlugin\MyPlugin`
Both formats are supported, but directory plugins allow for self-contained packages with their own components.
## Architecture
### Plugin Interface
All plugins must implement `App\Libraries\Plugins\PluginInterface`:
```php
interface PluginInterface
{
public function getPluginId(): string; // Unique identifier
public function getPluginName(): string; // Display name
public function getPluginDescription(): string;
public function getVersion(): string;
public function registerEvents(): void; // Register event listeners
public function install(): bool; // First-time setup
public function uninstall(): bool; // Cleanup
public function isEnabled(): bool;
public function getConfigView(): ?string; // Configuration view path
public function getSettings(): array;
public function saveSettings(array $settings): bool;
}
```
### Base Plugin Class
Extend `App\Libraries\Plugins\BasePlugin` for common functionality:
```php
class MyPlugin extends BasePlugin
{
public function getPluginId(): string { return 'my_plugin'; }
public function getPluginName(): string { return 'My Plugin'; }
// ... implement other methods
}
```
### Plugin Manager
The `PluginManager` class handles:
- Plugin discovery from `app/Plugins/` directory (recursive scan)
- Loading and registering enabled plugins
- Managing plugin settings
**Important:** The PluginManager only calls `registerEvents()` for enabled plugins. Disabled plugins never have their event callbacks registered with `Events::on()`. This means **you do not need to check `$this->isEnabled()` in your callback methods** - if the callback is registered, the plugin is enabled.
## Available Events
OSPOS fires these events that plugins can listen to:
| Event | Arguments | Description |
|-------|-----------|-------------|
| `item_sale` | `array $saleData` | Fired when a sale is completed |
| `item_return` | `array $returnData` | Fired when a return is processed |
| `item_change` | `int $itemId` | Fired when an item is created/updated/deleted |
| `item_inventory` | `array $inventoryData` | Fired on inventory changes |
| `items_csv_import` | `array $importData` | Fired after items CSV import |
| `customers_csv_import` | `array $importData` | Fired after customers CSV import |
## View Hooks (Injecting Plugin Content into Views)
Plugins can inject UI elements into core views using the event-based view hook system. This allows plugins to add buttons, tabs, or other content without modifying core view files.
### How It Works
1. **Core views define hook points** using the `plugin_content()` helper
2. **Plugins register listeners** for these view hooks in `registerEvents()`
3. **Content is rendered** only when the plugin is enabled
### Step 1: Adding Hook Points in Core Views
In your core view files, use the `plugin_content()` helper to define injection points:
```php
// In app/Views/sales/receipt.php
<div class="receipt-actions">
<!-- Existing buttons -->
<?= plugin_content('receipt_actions', ['sale' => $sale]) ?>
</div>
// In app/Views/customers/form.php
<ul class="nav nav-tabs">
<!-- Existing tabs -->
<?= plugin_content('customer_tabs', ['customer' => $customer]) ?>
</ul>
```
### Step 2: Plugin Registers View Hook
In your plugin class, register a listener that returns HTML content:
```php
class MailchimpPlugin extends BasePlugin
{
public function registerEvents(): void
{
Events::on('customer_saved', [$this, 'onCustomerSaved']);
// View hooks - inject content into core views
Events::on('view:customer_tabs', [$this, 'injectCustomerTab']);
}
public function injectCustomerTab(array $data): string
{
return view('Plugins/MailchimpPlugin/Views/customer_tab', $data);
}
}
```
### Plugin View Files
The plugin's view files are self-contained within the plugin directory:
```php
// app/Plugins/MailchimpPlugin/Views/customer_tab.php
<li>
<a href="#mailchimp_panel" data-toggle="tab">
<span class="glyphicon glyphicon-envelope">&nbsp;</span>
Mailchimp
</a>
</li>
```
### Helper Functions
The `plugin_helper.php` provides two functions:
```php
// Render plugin content for a hook point
plugin_content(string $section, array $data = []): string
// Check if any plugin has registered for a hook (for conditional rendering)
plugin_content_exists(string $section): bool
```
### Standard Hook Points
Core views should define these standard hook points:
| Hook Name | Location | Usage |
|-----------|----------|-------|
| `view:receipt_actions` | Receipt view action buttons | Add receipt-related buttons |
| `view:customer_tabs` | Customer form tabs | Add customer-related tabs |
| `view:item_form_buttons` | Item form action buttons | Add item-related buttons |
| `view:sales_complete` | Sale complete screen | Post-sale integration UI |
| `view:reports_menu` | Reports menu | Add custom report links |
### Benefits
- **Self-Contained**: Plugin UI stays in plugin directory
- **Conditional**: Only renders when plugin is enabled
- **Data Access**: Pass context (sale, customer, etc.) to plugin views
- **Multiple Plugins**: Multiple plugins can hook the same location
- **Clean Separation**: Core views remain unmodified
## Creating a Plugin
### Simple Plugin (Single File)
For plugins that only need to listen to events without complex UI or database tables:
```php
<?php
// app/Plugins/MyPlugin.php
namespace App\Plugins;
use App\Libraries\Plugins\BasePlugin;
use CodeIgniter\Events\Events;
class MyPlugin extends BasePlugin
{
public function getPluginId(): string
{
return 'my_plugin';
}
public function getPluginName(): string
{
return 'My Integration Plugin';
}
public function getPluginDescription(): string
{
return 'Integrates OSPOS with external service';
}
public function getVersion(): string
{
return '1.0.0';
}
public function registerEvents(): void
{
Events::on('item_sale', [$this, 'onItemSale']);
Events::on('item_change', [$this, 'onItemChange']);
}
public function onItemSale(array $saleData): void
{
log_message('info', "Processing sale: {$saleData['sale_id_num']}");
}
public function onItemChange(int $itemId): void
{
log_message('info', "Item changed: {$itemId}");
}
public function install(): bool
{
$this->setSetting('api_key', '');
$this->setSetting('enabled', '0');
return true;
}
public function getConfigView(): ?string
{
return 'Plugins/my_plugin/config';
}
}
```
### Complex Plugin (Self-Contained Directory)
For plugins that need database tables, controllers, models, and views:
```
app/Plugins/
└── MailchimpPlugin/ # Plugin directory
├── MailchimpPlugin.php # Main class - namespace: App\Plugins\MailchimpPlugin\MailchimpPlugin
├── Models/ # Plugin models
│ └── MailchimpData.php
├── Controllers/ # Plugin controllers
│ └── Dashboard.php
├── Views/ # Plugin views
│ ├── config.php
│ └── dashboard.php
├── Language/ # Plugin translations (self-contained)
│ ├── en/
│ │ └── MailchimpPlugin.php
│ └── es-ES/
│ └── MailchimpPlugin.php
└── Libraries/ # Plugin libraries
└── ApiClient.php
```
**Main Plugin Class:**
```php
<?php
// app/Plugins/MailchimpPlugin/MailchimpPlugin.php
namespace App\Plugins\MailchimpPlugin;
use App\Libraries\Plugins\BasePlugin;
use App\Plugins\MailchimpPlugin\Models\MailchimpData;
use CodeIgniter\Events\Events;
class MailchimpPlugin extends BasePlugin
{
private ?MailchimpData $dataModel = null;
public function getPluginId(): string
{
return 'mailchimp';
}
public function getPluginName(): string
{
return 'Mailchimp';
}
public function getPluginDescription(): string
{
return 'Integrate with Mailchimp to sync customers to mailing lists.';
}
public function getVersion(): string
{
return '1.0.0';
}
public function registerEvents(): void
{
Events::on('customer_saved', [$this, 'onCustomerSaved']);
Events::on('customer_deleted', [$this, 'onCustomerDeleted']);
}
private function getDataModel(): MailchimpData
{
if ($this->dataModel === null) {
$this->dataModel = new MailchimpData();
}
return $this->dataModel;
}
public function onCustomerSaved(array $customerData): void
{
if (!$this->shouldSyncOnSave()) {
return;
}
$this->getDataModel()->syncCustomer($customerData);
}
public function install(): bool
{
$this->setSetting('api_key', '');
$this->setSetting('list_id', '');
$this->setSetting('sync_on_save', '1');
return true;
}
public function uninstall(): bool
{
$this->getDataModel()->dropTable();
return true;
}
public function getConfigView(): ?string
{
return 'Plugins/MailchimpPlugin/Views/config';
}
protected function lang(string $key, array $data = []): string
{
$language = \Config\Services::language();
$language->addLanguagePath(APPPATH . 'Plugins/MailchimpPlugin/Language/');
return $language->getLine($key, $data);
}
protected function getPluginDir(): string
{
return 'MailchimpPlugin';
}
}
```
## Internationalization (Language Files)
Plugins can include their own language files, making them completely self-contained. This allows plugins to provide translations without modifying core language files.
### Plugin Language Directory Structure
```
app/Plugins/
└── MailchimpPlugin/
├── MailchimpPlugin.php
├── Language/
│ ├── en/
│ │ └── MailchimpPlugin.php # English translations
│ ├── es-ES/
│ │ └── MailchimpPlugin.php # Spanish translations
│ └── de-DE/
│ └── MailchimpPlugin.php # German translations
└── Views/
└── config.php
```
### Language File Format
Each language file returns an array of translation strings:
```php
<?php
// app/Plugins/MailchimpPlugin/Language/en/MailchimpPlugin.php
return [
'mailchimp' => 'Mailchimp',
'mailchimp_description' => 'Integrate with Mailchimp to sync customers to mailing lists.',
'mailchimp_api_key' => 'Mailchimp API Key',
'mailchimp_configuration' => 'Mailchimp Configuration',
'mailchimp_key_successfully' => 'API Key is valid.',
'mailchimp_key_unsuccessfully' => 'API Key is invalid.',
];
```
### Loading Language Strings in Plugins
The `BasePlugin` class can provide a helper method to load plugin-specific language strings:
```php
protected function lang(string $key, array $data = []): string
{
$language = \Config\Services::language();
$language->addLanguagePath(APPPATH . 'Plugins/' . $this->getPluginDir() . '/Language/');
return $language->getLine($key, $data);
}
protected function getPluginDir(): string
{
return 'MailchimpPlugin';
}
```
### Benefits of Self-Contained Language Files
1. **Plugin Independence**: No need to modify core language files
2. **Easy Distribution**: Plugin zip includes all translations
3. **Fallback Support**: Missing translations fall back to English
4. **User Contributions**: Users can add translations to `Language/{locale}/` in the plugin directory
## Plugin Settings
Store plugin-specific settings using:
```php
// Get setting
$value = $this->getSetting('setting_key', 'default_value');
// Set setting
$this->setSetting('setting_key', 'value');
// Get all plugin settings
$settings = $this->getSettings();
// Save multiple settings
$this->saveSettings(['key1' => 'value1', 'key2' => 'value2']);
```
Settings are prefixed with the plugin ID (e.g., `mailchimp_api_key`) and stored in `ospos_plugin_config` table.
## Namespace Reference
| File Location | Namespace |
|--------------|-----------|
| `app/Plugins/MyPlugin.php` | `App\Plugins\MyPlugin` |
| `app/Plugins/MailchimpPlugin/MailchimpPlugin.php` | `App\Plugins\MailchimpPlugin\MailchimpPlugin` |
| `app/Plugins/MailchimpPlugin/Models/MailchimpData.php` | `App\Plugins\MailchimpPlugin\Models\MailchimpData` |
| `app/Plugins/MailchimpPlugin/Controllers/Dashboard.php` | `App\Plugins\MailchimpPlugin\Controllers\Dashboard` |
| `app/Plugins/MailchimpPlugin/Libraries/ApiClient.php` | `App\Plugins\MailchimpPlugin\Libraries\ApiClient` |
| `app/Plugins/MailchimpPlugin/Language/en/MailchimpPlugin.php` | *(Language file - returns array, no namespace)* |
## Database
Plugin settings are stored in the `ospos_plugin_config` table:
```sql
CREATE TABLE IF NOT EXISTS `ospos_plugin_config` (
`key` varchar(100) NOT NULL,
`value` text NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
For custom tables, plugins can create them during `install()` and drop them during `uninstall()`.
## Event Flow
1. Application triggers event: `Events::trigger('item_sale', $data)`
2. PluginManager recursively scans `app/Plugins/` directory
3. Each enabled plugin registers its listeners via `registerEvents()`
4. Events::on() callbacks are invoked automatically
## Testing
Enable plugin logging to debug:
```php
log_message('debug', 'Debug message');
log_message('info', 'Info message');
log_message('error', 'Error message');
```
Check logs in `writable/logs/`.
## Distributing Plugins
Plugin developers can package their plugins as zip files:
```
MailchimpPlugin-1.0.0.zip
└── MailchimpPlugin/
├── MailchimpPlugin.php
├── Models/
├── Controllers/
├── Views/
├── Language/
│ ├── en/
│ │ └── MailchimpPlugin.php
│ └── es-ES/
│ └── MailchimpPlugin.php
└── README.md # Plugin documentation
```
Users extract the zip to `app/Plugins/` and the plugin is ready to use.

View File

@@ -23,7 +23,7 @@
'name' => 'definition_name',
'id' => 'definition_name',
'class' => 'form-control input-sm',
'value' => $definition_info->definition_name
'value' => esc($definition_info->definition_name)
]) ?>
</div>
</div>
@@ -69,7 +69,7 @@
<div class="input-group">
<?= form_input([
'name' => 'definition_unit',
'value' => $definition_info->definition_unit,
'value' => esc($definition_info->definition_unit),
'class' => 'form-control input-sm',
'id' => 'definition_unit'
]) ?>

View File

@@ -23,7 +23,7 @@
<?php foreach ($definition_values as $definition_id => $definition_value) { ?>
<div class="form-group form-group-sm">
<?= form_label($definition_value['definition_name'], $definition_value['definition_name'], ['class' => 'control-label col-xs-3']) ?>
<?= form_label(esc($definition_value['definition_name']), esc($definition_value['definition_name']), ['class' => 'control-label col-xs-3']) ?>
<div class="col-xs-8">
<div class="input-group">
<?php
@@ -55,7 +55,7 @@
$value = (empty($attribute_value) || empty($attribute_value->attribute_value)) ? $definition_value['selected_value'] : $attribute_value->attribute_value;
echo form_input([
'name' => "attribute_links[$definition_id]",
'value' => $value,
'value' => esc($value),
'class' => 'form-control valid_chars',
'data-definition-id' => $definition_id
]);

View File

@@ -3,7 +3,10 @@
* @var string $controller_name
* @var string $table_headers
* @var array $filters
* @var array $selected_filters
* @var array $config
* @var string|null $start_date
* @var string|null $end_date
*/
?>
@@ -11,20 +14,19 @@
<script type="text/javascript">
$(document).ready(function() {
// When any filter is clicked and the dropdown window is closed
$('#filters').on('hidden.bs.select', function(e) {
table_support.refresh();
});
// Load the preset datarange picker
<?= view('partial/daterangepicker') ?>
$("#daterangepicker").on('apply.daterangepicker', function(ev, picker) {
table_support.refresh();
});
<?= view('partial/bootstrap_tables_locale') ?>
// Override dates from server if provided
<?php if (isset($start_date) && $start_date): ?>
start_date = "<?= esc($start_date) ?>";
<?php endif; ?>
<?php if (isset($end_date) && $end_date): ?>
end_date = "<?= esc($end_date) ?>";
<?php endif; ?>
table_support.init({
resource: '<?= esc($controller_name) ?>',
headers: <?= $table_headers ?>,
@@ -40,6 +42,7 @@
});
});
</script>
<?= view('partial/table_filter_persistence') ?>
<?= view('partial/print_receipt', ['print_after_sale' => false, 'selected_printer' => 'takings_printer']) ?>
@@ -58,7 +61,7 @@
<span class="glyphicon glyphicon-trash">&nbsp;</span><?= lang('Common.delete') ?>
</button>
<?= form_input(['name' => 'daterangepicker', 'class' => 'form-control input-sm', 'id' => 'daterangepicker']) ?>
<?= form_multiselect('filters[]', $filters, [''], [
<?= form_multiselect('filters[]', $filters, $selected_filters ?? [], [
'id' => 'filters',
'data-none-selected-text' => lang('Common.none_selected_text'),
'class' => 'selectpicker show-menu-arrow',

View File

@@ -126,7 +126,12 @@
<div class="form-group form-group-sm">
<?= form_label(lang('Expenses.employee'), 'employee', ['class' => 'control-label col-xs-3']) ?>
<div class="col-xs-6">
<?= form_dropdown('employee_id', $employees, $expenses_info->employee_id, 'id="employee_id" class="form-control"') ?>
<?php if ($can_assign_employee): ?>
<?= form_dropdown('employee_id', $employees, $expenses_info->employee_id, 'id="employee_id" class="form-control"') ?>
<?php else: ?>
<?= form_hidden('employee_id', $expenses_info->employee_id) ?>
<?= form_input(['name' => 'employee_name', 'value' => esc($employees[$expenses_info->employee_id] ?? ''), 'class' => 'form-control', 'readonly' => 'readonly']) ?>
<?php endif; ?>
</div>
</div>

View File

@@ -3,7 +3,10 @@
* @var string $controller_name
* @var string $table_headers
* @var array $filters
* @var array $selected_filters
* @var array $config
* @var string|null $start_date
* @var string|null $end_date
*/
?>
@@ -11,20 +14,19 @@
<script type="text/javascript">
$(document).ready(function() {
// When any filter is clicked and the dropdown window is closed
$('#filters').on('hidden.bs.select', function(e) {
table_support.refresh();
});
// Load the preset datarange picker
<?= view('partial/daterangepicker') ?>
$("#daterangepicker").on('apply.daterangepicker', function(ev, picker) {
table_support.refresh();
});
<?= view('partial/bootstrap_tables_locale') ?>
// Override dates from server if provided
<?php if (isset($start_date) && $start_date): ?>
start_date = "<?= esc($start_date) ?>";
<?php endif; ?>
<?php if (isset($end_date) && $end_date): ?>
end_date = "<?= esc($end_date) ?>";
<?php endif; ?>
table_support.init({
resource: '<?= esc($controller_name) ?>',
headers: <?= $table_headers ?>,
@@ -45,8 +47,10 @@
});
}
});
});
</script>
</script
<?= view('partial/table_filter_persistence') ?>>
<?= view('partial/print_receipt', ['print_after_sale' => false, 'selected_printer' => 'takings_printer']) ?>
@@ -65,7 +69,7 @@
<span class="glyphicon glyphicon-trash">&nbsp;</span><?= lang('Common.delete') ?>
</button>
<?= form_input(['name' => 'daterangepicker', 'class' => 'form-control input-sm', 'id' => 'daterangepicker']) ?>
<?= form_multiselect('filters[]', esc($filters), [''], [
<?= form_multiselect('filters[]', esc($filters), $selected_filters ?? [], [
'id' => 'filters',
'data-none-selected-text' => lang('Common.none_selected_text'),
'class' => 'selectpicker show-menu-arrow',

View File

@@ -6,6 +6,9 @@
* @var array $stock_locations
* @var int $stock_location
* @var array $config
* @var string|null $start_date
* @var string|null $end_date
* @var array $selected_filters
*/
use App\Models\Employee;
@@ -22,24 +25,20 @@ use App\Models\Employee;
);
});
// When any filter is clicked and the dropdown window is closed
$('#filters').on('hidden.bs.select', function(e) {
table_support.refresh();
});
// Load the preset daterange picker
<?= view('partial/daterangepicker') ?>
// Set the beginning of time as starting date
$('#daterangepicker').data('daterangepicker').setStartDate("<?= date($config['dateformat'], mktime(0, 0, 0, 01, 01, 2010)) ?>");
// Update the hidden inputs with the selected dates before submitting the search data
var start_date = "<?= date('Y-m-d', mktime(0, 0, 0, 01, 01, 2010)) ?>";
$("#daterangepicker").on('apply.daterangepicker', function(ev, picker) {
table_support.refresh();
});
$("#stock_location").change(function() {
table_support.refresh();
});
// Override dates from server if provided
<?php if (isset($start_date) && $start_date): ?>
start_date = "<?= esc($start_date) ?>";
<?php endif; ?>
<?php if (isset($end_date) && $end_date): ?>
end_date = "<?= esc($end_date) ?>";
<?php endif; ?>
<?php
echo view('partial/bootstrap_tables_locale');
@@ -75,6 +74,8 @@ use App\Models\Employee;
});
</script>
<?= view('partial/table_filter_persistence', ['additional_params' => ['stock_location']]) ?>
<div id="title_bar" class="btn-toolbar print_hide">
<button class="btn btn-info btn-sm pull-right modal-dlg" data-btn-submit="<?= lang('Common.submit') ?>" data-href="<?= "$controller_name/csvImport" ?>" title="<?= lang('Items.import_items_csv') ?>">
<span class="glyphicon glyphicon-import">&nbsp;</span><?= lang('Common.import_csv') ?>
@@ -97,7 +98,7 @@ use App\Models\Employee;
<span class="glyphicon glyphicon-barcode">&nbsp;</span><?= lang('Items.generate_barcodes') ?>
</button>
<?= form_input(['name' => 'daterangepicker', 'class' => 'form-control input-sm', 'id' => 'daterangepicker']) ?>
<?= form_multiselect('filters[]', $filters, [''], [
<?= form_multiselect('filters[]', $filters, $selected_filters ?? [], [
'id' => 'filters',
'class' => 'selectpicker show-menu-arrow',
'data-none-selected-text' => lang('Common.none_selected_text'),

View File

@@ -92,7 +92,7 @@
<?php
if ($gcaptcha_enabled) {
echo '<script src="https://www.google.com/recaptcha/api.js"></script>';
echo '<div class="g-recaptcha mb-3" style="text-align: center;" data-sitekey="' . $config['gcaptcha_site_key'] . '"></div>';
echo '<div class="g-recaptcha mb-3" style="text-align: center;" data-sitekey="' . esc($config['gcaptcha_site_key']) . '"></div>';
}
?>
<div class="d-grid">

View File

@@ -0,0 +1,84 @@
<?php
/**
* Table Filter Persistence
*
* This partial updates the URL when filters change, allowing users to
* share/bookmark filtered views and maintain state on back navigation.
*
* Filter restoration from URL is handled server-side in the controller.
*
* @param array $options Additional filter options
* - 'additional_params': Array of additional parameter names to track (e.g., ['stock_location'])
* - 'filter_select_id': Filter multiselect element ID (default: 'filters')
*/
$options = $options ?? [];
$additional_params = $options['additional_params'] ?? [];
$filter_select_id = $options['filter_select_id'] ?? 'filters';
?>
<script type="text/javascript">
$(document).ready(function() {
var additional_params = <?= json_encode($additional_params) ?>;
var filter_select_id = '<?= esc($filter_select_id) ?>';
function update_url() {
var params = new URLSearchParams();
// Add dates
if (typeof start_date !== 'undefined') {
params.set('start_date', start_date);
}
if (typeof end_date !== 'undefined') {
params.set('end_date', end_date);
}
// Add filters
var filters = $('#' + filter_select_id).val();
if (filters) {
filters.forEach(function(filter) {
params.append('filters[]', filter);
});
}
// Add additional params
additional_params.forEach(function(param) {
var element = $('#' + param);
if (element.length) {
var value = element.val();
if (Array.isArray(value) && value.length > 0) {
value.forEach(function(v) {
params.append(param + '[]', v);
});
} else if (value) {
params.set(param, value);
}
}
});
// Update URL without page reload
var new_url = window.location.pathname;
var params_str = params.toString();
if (params_str) {
new_url += '?' + params_str;
}
window.history.replaceState({}, '', new_url);
}
// Update URL when filter dropdown changes
$('#' + filter_select_id).on('hidden.bs.select', function(e) {
update_url();
});
// Update URL when stock location changes (if exists)
if ($('#stock_location').length) {
$("#stock_location").change(function() {
update_url();
});
}
// Update URL when daterangepicker changes
$("#daterangepicker").on('apply.daterangepicker', function(ev, picker) {
update_url();
});
});
</script>

Some files were not shown because too many files have changed in this diff Show More